diff --git a/.gitignore b/.gitignore index ee8c78dad3..833038e151 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ rebuild-docker.sh .run .claude .planning +docs/superpowers/ \ No newline at end of file diff --git a/application/pom.xml b/application/pom.xml index 144dcc0038..1ae95e99ed 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT thingsboard application @@ -390,11 +390,7 @@ org.thingsboard.langchain4j - langchain4j-google-ai-gemini - - - org.thingsboard.langchain4j - langchain4j-vertex-ai-gemini + langchain4j-google-genai org.thingsboard.langchain4j @@ -410,13 +406,7 @@ org.thingsboard.langchain4j - langchain4j-github-models - - - com.azure - azure-core-test - - + langchain4j-open-ai-official org.thingsboard.langchain4j diff --git a/application/src/main/data/json/system/widget_bundles/air_quality.json b/application/src/main/data/json/system/widget_bundles/air_quality.json deleted file mode 100644 index 534b509dba..0000000000 --- a/application/src/main/data/json/system/widget_bundles/air_quality.json +++ /dev/null @@ -1,111 +0,0 @@ -{ - "widgetsBundle": { - "alias": "air_quality", - "title": "Air quality", - "image": "tb-image:YWlyX3F1YWxpdHlfd2lkZ2V0c19idW5kbGUuc3Zn:YWlyX3F1YWxpdHlfd2lkZ2V0c19idW5kbGUuc3Zn;data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMDAiIGhlaWdodD0iMTYwIiBmaWxsPSJub25lIj4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9Ijc2IiBmaWxsPSIjZmZmIiByeD0iNCIgLz4KICA8cmVjdCB3aWR0aD0iMTk5IiBoZWlnaHQ9Ijc1IiB4PSIuNSIgeT0iLjUiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLW9wYWNpdHk9Ii4xIiByeD0iMy41IiAvPgogIDxwYXRoIGZpbGw9IiMwMDAiIGZpbGwtb3BhY2l0eT0iLjkiCiAgICBkPSJNMTIgMTYuMWgxYTIuMyAyLjMgMCAwIDEtLjQgMSAxLjkgMS45IDAgMCAxLS43LjdsLTEuMi4zYy0uNCAwLS43IDAtMS0uMmEyIDIgMCAwIDEtLjctLjUgMi40IDIuNCAwIDAgMS0uNC0uOWwtLjItMS4xdi0uNWEzLjYgMy42IDAgMCAxIC4yLTFjMC0uNC4yLS43LjQtMWwuOC0uNSAxLS4yYy40IDAgLjguMSAxIC4zLjQuMi42LjQuOC43LjIuMy4zLjYuMyAxaC0xYTEuOCAxLjggMCAwIDAtLjEtLjYuOS45IDAgMCAwLS40LS40bC0uNy0uMi0uNi4xYTEuMSAxLjEgMCAwIDAtLjQuNCAxLjggMS44IDAgMCAwLS4yLjYgMy42IDMuNiAwIDAgMC0uMS44djEuM2wuMy42LjQuNC42LjEuNy0uMWEuOS45IDAgMCAwIC40LS40IDEuOCAxLjggMCAwIDAgLjEtLjdabTYuNi0xLjF2LjNjMCAuNCAwIC44LS4yIDEuMmEyLjUgMi41IDAgMCAxLS41LjggMiAyIDAgMCAxLS43LjZsLTEgLjJjLS40IDAtLjYgMC0xLS4yYTIuMSAyLjEgMCAwIDEtLjctLjYgMi42IDIuNiAwIDAgMS0uNS0uOCAzLjYgMy42IDAgMCAxLS4xLTEuMlYxNWwuMS0xLjIuNS0uOGMuMi0uMy41LS40LjgtLjZsMS0uMi45LjIuNy42LjUuOC4yIDEuMlptLTEgLjN2LTEuMWwtLjQtLjdhMS4xIDEuMSAwIDAgMC0uNC0uMyAxLjMgMS4zIDAgMCAwLS42LS4yYy0uMiAwLS40IDAtLjUuMi0uMiAwLS40LjItLjUuM2ExLjggMS44IDAgMCAwLS4zLjcgMy40IDMuNCAwIDAgMCAwIC44djEuMmwuMy42LjUuNGguNWExLjMgMS4zIDAgMCAwIC42IDBsLjQtLjQuMy0uNi4xLS45Wm01LjkgMnYuN2gtMy44di0uNmwxLjgtMmE3IDcgMCAwIDAgLjUtLjdsLjItLjRhMS4xIDEuMSAwIDAgMCAwLS45LjcuNyAwIDAgMC0uMy0uMy45LjkgMCAwIDAtLjQtLjEgMSAxIDAgMCAwLS42LjEuOS45IDAgMCAwLS4zLjRsLS4xLjVoLTFsLjMtLjkuNi0uNmEyIDIgMCAwIDEgMS0uM2wxIC4yLjcuNi4yLjgtLjEuNS0uMy42YTQuNSA0LjUgMCAwIDEtLjMuNWwtLjUuNS0xLjIgMS4zaDIuNlptNC4zLTUuM3Y2aC0xdi02aDFabTMuMSA2aC0uOGExLjkgMS45IDAgMCAxLS43LS41IDIgMiAwIDAgMS0uNC0uN0wyOSAxNlYxNmwuMS0xYTIgMiAwIDAgMSAuNC0uNmwuNi0uNS44LS4xLjguMS42LjUuMy42LjEuOXYuNGgtMy4zdi0uN2gyLjRhMS4zIDEuMyAwIDAgMC0uMS0uNi44LjggMCAwIDAtLjMtLjMuOC44IDAgMCAwLS41LS4yLjguOCAwIDAgMC0uNy40bC0uMi41YTIuNSAyLjUgMCAwIDAgMCAuNnYuNmwuMi41YTEgMSAwIDAgMCAuOS4zIDEuMyAxLjMgMCAwIDAgMS0uNWwuNS41LS4zLjQtLjYuM0gzMVptNC0uNyAxLTMuNWgxTDM1LjUgMThoLS43bC4xLS43Wm0tLjgtMy41IDEgMy41LjEuN2gtLjZsLTEuNS00LjJoMVptNS41IDQuM2MtLjMgMC0uNiAwLS45LS4yYTEuOSAxLjkgMCAwIDEtLjYtLjQgMiAyIDAgMCAxLS40LS43bC0uMi0uOFYxNmMwLS40IDAtLjcuMi0xIDAtLjIuMi0uNC40LS42bC42LS41LjgtLjEuNy4xYy4zLjEuNS4zLjYuNWwuMy42LjIuOXYuNEgzOHYtLjdoMi4zYTEuMyAxLjMgMCAwIDAgMC0uNi44LjggMCAwIDAtLjQtLjMuOC44IDAgMCAwLS41LS4yLjguOCAwIDAgMC0uNC4xLjguOCAwIDAgMC0uMy4zbC0uMi41YTIuNSAyLjUgMCAwIDAgMCAuNnYuNmwuMy41LjMuMi41LjFhMS4zIDEuMyAwIDAgMCAxLS41bC42LjVhMS44IDEuOCAwIDAgMS0xIC43aC0uNlptMy42LTYuMXY2aC0uOXYtNmgxWiIgLz4KICA8cGF0aCBmaWxsPSIjM0ZBNzFBIgogICAgZD0iTTY1LjYgNDl2Mi4ySDUzLjN2LTEuN2w3LjMtMTEuNmgyLjNsLTIuNSA0LjItNC4yIDYuOGg5LjRabS0yLTExdjE3aC0zVjM4aDNabTE1IDd2Mi44YzAgMS40IDAgMi41LS4zIDMuNWE2IDYgMCAwIDEtMS4yIDIuMiA0LjQgNC40IDAgMCAxLTEuNyAxLjMgNiA2IDAgMCAxLTIuMy40IDYuNiA2LjYgMCAwIDEtMS45LS4yIDQuNiA0LjYgMCAwIDEtMS42LS44IDQuOCA0LjggMCAwIDEtMS4xLTEuNSA4IDggMCAwIDEtLjgtMmMtLjItLjktLjItMS44LS4yLTIuOVY0NWMwLTEuMyAwLTIuNC40LTMuNGE2IDYgMCAwIDEgMS0yLjIgNC4zIDQuMyAwIDAgMSAxLjgtMS4zYy43LS4zIDEuNS0uNCAyLjMtLjQuNyAwIDEuNCAwIDIgLjNhNC40IDQuNCAwIDAgMSAxLjUuNyA3IDcgMCAwIDEgMiAzLjVsLjEgMi44Wm0tMi44IDMuMnYtNS40bC0uNC0xLjNhMi42IDIuNiAwIDAgMC0uNi0uOSAyIDIgMCAwIDAtLjgtLjUgMi43IDIuNyAwIDAgMC0xLS4yYy0uNCAwLS44LjEtMS4xLjMtLjQuMi0uNy40LS45LjgtLjIuNC0uNC44LS41IDEuNGwtLjIgMi4ydjMuNmwuMSAxLjhjMCAuNi4yIDEgLjQgMS40bC41IDEgLjguNCAxIC4yIDEuMi0uM2MuMy0uMS42LS40LjgtLjguMy0uNC40LS45LjUtMS41LjItLjYuMi0xLjMuMi0yLjJaTTkyLjMgNDV2Mi44YzAgMS40LS4xIDIuNS0uNCAzLjVhNiA2IDAgMCAxLTEuMSAyLjIgNC40IDQuNCAwIDAgMS0xLjggMS4zIDYgNiAwIDAgMS0yLjMuNCA2LjYgNi42IDAgMCAxLTEuOS0uMiA0LjYgNC42IDAgMCAxLTEuNS0uOCA0LjggNC44IDAgMCAxLTEuMi0xLjUgOCA4IDAgMCAxLS43LTJjLS4yLS45LS4zLTEuOC0uMy0yLjlWNDVjMC0xLjMuMS0yLjQuNC0zLjRhNiA2IDAgMCAxIDEuMS0yLjIgNC4zIDQuMyAwIDAgMSAxLjgtMS4zYy43LS4zIDEuNC0uNCAyLjMtLjQuNyAwIDEuMyAwIDEuOS4zYTQuNCA0LjQgMCAwIDEgMS41LjdjLjUuNC45LjkgMS4yIDEuNS4zLjUuNiAxLjIuNyAyIC4yLjguMyAxLjguMyAyLjhabS0yLjggMy4ydi0zLjZjMC0uNyAwLTEuMy0uMi0xLjggMC0uNS0uMS0xLS4zLTEuM2EyLjYgMi42IDAgMCAwLS42LS45IDIgMiAwIDAgMC0uNy0uNSAyLjcgMi43IDAgMCAwLTEtLjJjLS41IDAtLjguMS0xLjIuM2wtLjkuOGMtLjIuNC0uNC44LS41IDEuNGwtLjIgMi4ydjMuNmMwIC43IDAgMS4zLjIgMS44IDAgLjYuMSAxIC4zIDEuNC4yLjQuMy43LjYgMWwuNy40IDEgLjJjLjUgMCAuOS0uMSAxLjItLjMuMy0uMS42LS40LjktLjhsLjUtMS41LjItMi4yWm0xNC4zLTMuNHYxNUgxMDFWNDIuNGgyLjZsLjIgMi41Wm04LjIgMy43di4zYzAgMSAwIDEuOC0uMyAyLjZhNi4zIDYuMyAwIDAgMS0xIDIgNC40IDQuNCAwIDAgMS0xLjUgMS40Yy0uNi4zLTEuMy40LTIgLjQtLjkgMC0xLjYtLjEtMi4xLS40YTQuMyA0LjMgMCAwIDEtMS41LTEuMyA2LjggNi44IDAgMCAxLTEtMS45IDEyLjIgMTIuMiAwIDAgMS0uNS0yLjR2LTFjMC0uOC4yLTEuNy41LTIuNC4yLS44LjYtMS41IDEtMmE0LjIgNC4yIDAgMCAxIDEuNS0xLjNjLjUtLjMgMS4yLS40IDItLjRzMS41LjEgMi4xLjVhNC4zIDQuMyAwIDAgMSAxLjYgMS4zYy40LjUuNyAxLjIgMSAybC4yIDIuNlptLTIuOC4zdi0uM2wtLjEtMS42YTQuMyA0LjMgMCAwIDAtLjYtMS4zIDIuNSAyLjUgMCAwIDAtLjktMWwtMS4zLS4yYy0uNSAwLS45IDAtMS4yLjJsLTEgLjdhMy4zIDMuMyAwIDAgMC0uNSAxbC0uMyAxLjNWNTBsLjUgMS41Yy4yLjUuNS45IDEgMS4yLjQuMiAxIC40IDEuNi40LjUgMCAuOS0uMSAxLjMtLjNsLjgtMWMuMy0uMy40LS44LjYtMS4zYTcuNyA3LjcgMCAwIDAgLjEtMS42Wm04LTR2MTVoLTIuN1Y0Mi40aDIuNmwuMiAyLjVabTguMyAzLjd2LjNjMCAxIDAgMS44LS4zIDIuNmE2LjMgNi4zIDAgMCAxLTEgMiA0LjQgNC40IDAgMCAxLTEuNSAxLjRjLS42LjMtMS4zLjQtMiAuNC0uOSAwLTEuNi0uMS0yLjEtLjRhNC4zIDQuMyAwIDAgMS0xLjUtMS4zIDYuOCA2LjggMCAwIDEtMS0xLjkgMTIuMiAxMi4yIDAgMCAxLS41LTIuNHYtMWMwLS44LjItMS43LjUtMi40LjItLjguNi0xLjUgMS0yYTQuMiA0LjIgMCAwIDEgMS41LTEuM2MuNS0uMyAxLjItLjQgMi0uNHMxLjUuMSAyLjEuNWE0LjMgNC4zIDAgMCAxIDEuNiAxLjNjLjQuNS43IDEuMiAxIDJsLjIgMi42Wm0tMi44LjN2LS4zbC0uMS0xLjZhNC4zIDQuMyAwIDAgMC0uNi0xLjMgMi41IDIuNSAwIDAgMC0uOS0xbC0xLjMtLjJjLS41IDAtLjkgMC0xLjIuMmwtMSAuN2EzLjMgMy4zIDAgMCAwLS41IDFsLS4zIDEuM1Y1MGwuNSAxLjVjLjIuNS41LjkgMSAxLjIuNC4yIDEgLjQgMS42LjQuNSAwIC45LS4xIDEuMy0uM2wuOC0xYy4zLS4zLjQtLjguNi0xLjNhNy43IDcuNyAwIDAgMCAuMS0xLjZabTgtMy45VjU1SDEyOFY0Mi4zaDIuN2wuMiAyLjZabS0uNCAzLjNoLTFjMC0uOS4yLTEuNy40LTIuNGE1LjggNS44IDAgMCAxIDEtMmMuNC0uNS45LTEgMS41LTEuM2E1IDUgMCAwIDEgMi4yLS40Yy42IDAgMS4xIDAgMS42LjJzLjkuNSAxLjIuOGMuNC40LjcuOC45IDEuNC4yLjYuMyAxLjMuMyAyVjU1aC0yLjl2LTguMmMwLS42IDAtMS4xLS4zLTEuNWExLjYgMS42IDAgMCAwLS43LS43Yy0uNC0uMi0uOC0uMi0xLjItLjItLjYgMC0xIDAtMS40LjMtLjQuMi0uNy40LS45LjhhMy44IDMuOCAwIDAgMC0uNSAxLjIgNi40IDYuNCAwIDAgMC0uMiAxLjVabTcuOS0uOC0xLjMuM2MwLS43IDAtMS41LjMtMi4xLjItLjcuNS0xLjMgMS0xLjhhNC41IDQuNSAwIDAgMSAxLjUtMS4zYy42LS4zIDEuMy0uNCAyLjEtLjQuNiAwIDEuMiAwIDEuNy4zYTMuMiAzLjIgMCAwIDEgMS4zLjhjLjQuNC43LjguOSAxLjQuMi42LjIgMS40LjIgMi4yVjU1aC0yLjh2LTguMmMwLS43IDAtMS4yLS4zLTEuNS0uMS0uNC0uNC0uNi0uNy0uNy0uNC0uMi0uOC0uMi0xLjItLjItLjUgMC0uOSAwLTEuMi4yYTIuMyAyLjMgMCAwIDAtLjguNyAzIDMgMCAwIDAtLjYgMWwtLjEgMS4xWiIgLz4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9Ijc2IiB5PSI4NCIgZmlsbD0iI2ZmZiIgcng9IjQiIC8+CiAgPHJlY3Qgd2lkdGg9IjE5OSIgaGVpZ2h0PSI3NSIgeD0iLjUiIHk9Ijg0LjUiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLW9wYWNpdHk9Ii4xIiByeD0iMy41IiAvPgogIDxwYXRoIGZpbGw9IiMwMDAiIGZpbGwtb3BhY2l0eT0iLjkiCiAgICBkPSJNMTAuNyA5OS45SDkuM1Y5OWgxLjRsLjctLjFhLjguOCAwIDAgMCAuMy0uNGwuMS0uNXYtLjVhLjkuOSAwIDAgMC0uNC0uNGwtLjctLjFIOS42djQuOWgtMXYtNS43aDIuMWMuNSAwIC45IDAgMS4yLjJsLjcuNy4yIDFjMCAuMyAwIC42LS4yLjlhMS42IDEuNiAwIDAgMS0uNy42bC0xLjIuMlptMy42LTMuNmgxbDEuNiA0LjQgMS42LTQuNGguOWwtMi4yIDUuN2gtLjdsLTIuMi01LjdabS0uNCAwaC45bC4xIDMuOHYxLjloLTF2LTUuN1ptNSAwaC45djUuN2gtMXYtMS45bC4xLTMuOFptNiA1di43SDIxdi0uNmwxLjktMmE3IDcgMCAwIDAgLjUtLjdsLjItLjRhMS4xIDEuMSAwIDAgMCAwLS45LjcuNyAwIDAgMC0uMy0uMy45LjkgMCAwIDAtLjUtLjEgMSAxIDAgMCAwLS41LjEuOS45IDAgMCAwLS4zLjRsLS4xLjVoLTFsLjMtLjljLjEtLjIuMy0uNS42LS42YTIgMiAwIDAgMSAxLS4zbDEgLjJjLjMuMi41LjMuNi42LjIuMi4yLjUuMi44di41bC0uMy42YTQuNSA0LjUgMCAwIDEtLjQuNWwtLjQuNS0xLjMgMS40SDI1Wm0xIC4yYzAtLjEgMC0uMi4yLS4zYS41LjUgMCAwIDEgLjQtLjJjLjIgMCAuMyAwIC40LjJhLjUuNSAwIDAgMSAuMS4zbC0uMS40YS41LjUgMCAwIDEtLjQuMS41LjUgMCAwIDEtLjQtLjEuNS41IDAgMCAxLS4yLS40Wm0zLjQtMi4xLS44LS4yLjQtMi45aDN2LjhoLTIuM2wtLjEgMS40YTEuOCAxLjggMCAwIDEgLjQtLjFsLjUtLjEuNy4xYy4yIDAgLjQuMi41LjQuMi4xLjMuNC40LjZhMi41IDIuNSAwIDAgMSAwIDEuNWMwIC4yLS4yLjQtLjQuNmExLjYgMS42IDAgMCAxLS41LjQgMi4xIDIuMSAwIDAgMS0uOS4ybC0uNi0uMWExLjkgMS45IDAgMCAxLS42LS4zIDEuNiAxLjYgMCAwIDEtLjQtLjUgMS43IDEuNyAwIDAgMS0uMi0uN2guOWMwIC4xIDAgLjMuMi40IDAgLjIuMS4zLjMuM2wuNC4xYTEgMSAwIDAgMCAuNCAwIC44LjggMCAwIDAgLjMtLjNsLjItLjRhMS44IDEuOCAwIDAgMCAwLS45IDEgMSAwIDAgMC0uMi0uNC44LjggMCAwIDAtLjMtLjIgMSAxIDAgMCAwLS41IDBoLS41YTEuNiAxLjYgMCAwIDAtLjMuM1oiIC8+CiAgPGcgY2xpcC1wYXRoPSJ1cmwoI2EpIj4KICAgIDxwYXRoIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLW9wYWNpdHk9Ii4xIiBzdHJva2Utd2lkdGg9Ii4zIiBkPSJNNy41IDE0MS43SDE5MSIgLz4KICAgIDxwYXRoIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLW9wYWNpdHk9Ii45IiBzdHJva2Utd2lkdGg9IjEuMyIKICAgICAgZD0iTTguNyAxNDQuOGg5LjhsMTMgMi4yIDExLjItOCAyMS42IDUuOCAyNC44LTUuOSA3LjIuMmM0LjggMi4yIDE0LjUgNy45IDE1IDcuOWgxNC41bDEyLjQtN2gxMC40bDkuMi00IDEyLjQgMS4xIDE5LjYgMi44IiAvPgogICAgPHBhdGggZmlsbD0iIzAwMCIgZmlsbC1vcGFjaXR5PSIuNCIgZmlsbC1ydWxlPSJldmVub2RkIgogICAgICBkPSJNOC41IDEzNS41SDh2MmguNVYxMzZIMTB2LS41SDguNVptMCAxMS41di0xLjZIOHYyLjFoMnYtLjVIOC41Wm0xODMtMTEuNWguNXYyaC0uNVYxMzZIMTkwdi0uNWgxLjVabTAgMTEuNXYtMS42aC41djIuMWgtMnYtLjVoMS41WiIKICAgICAgY2xpcC1ydWxlPSJldmVub2RkIiAvPgogIDwvZz4KICA8cGF0aCBmaWxsPSIjRkZBNjAwIgogICAgZD0iTTc4LjEgMTIwLjNoMS43Yy42IDAgMS4yIDAgMS42LS4zLjUtLjIuOC0uNSAxLTEgLjItLjMuMy0uOC4zLTEuMyAwLS42IDAtMS0uMy0xLjRhMiAyIDAgMCAwLS44LS45IDMgMyAwIDAgMC0xLjUtLjMgMyAzIDAgMCAwLTEuNC4zIDIuMyAyLjMgMCAwIDAtMSAuOGMtLjIuNC0uMy44LS4zIDEuM2gtMi44YzAtLjkuMi0xLjcuNy0yLjQuNS0uNyAxLjEtMS4yIDItMS42LjgtLjUgMS43LS43IDIuNy0uN2E3IDcgMCAwIDEgMyAuNmMuOC40IDEuNC45IDEuOSAxLjYuNC43LjYgMS42LjYgMi43IDAgLjUgMCAxLS4zIDEuNS0uMi41LS42IDEtMSAxLjMtLjQuNC0xIC44LTEuNyAxLS42LjMtMS40LjQtMi4zLjRoLTJ2LTEuNlptMCAyLjJWMTIxaDIuMWMxIDAgMS45LjEgMi42LjMuNy4zIDEuMy42IDEuNyAxIC41LjQuOC45IDEgMS40LjIuNS4zIDEgLjMgMS43YTUgNSAwIDAgMS0uNCAyLjEgNC4zIDQuMyAwIDAgMS0xLjIgMS42IDUuNCA1LjQgMCAwIDEtMS45IDEgNy41IDcuNSAwIDAgMS0yLjIuMyA3LjMgNy4zIDAgMCAxLTIuMi0uMyA1LjggNS44IDAgMCAxLTEuOC0xIDQuNSA0LjUgMCAwIDEtMS4yLTEuNGMtLjMtLjctLjUtMS40LS41LTIuMmgyLjhjMCAuNi4yIDEgLjQgMS40YTIuNSAyLjUgMCAwIDAgMSAuOWMuNC4yIDEgLjMgMS41LjMuNiAwIDEuMSAwIDEuNS0uM2EyLjIgMi4yIDAgMCAwIDEtMWMuMy0uMy40LS44LjQtMS40YTMgMyAwIDAgMC0uNC0xLjYgMi4zIDIuMyAwIDAgMC0xLjEtMWMtLjUtLjItMS0uMy0xLjctLjNINzhabTIxLjUtMi4zdjIuOGMwIDEuMy0uMiAyLjQtLjQgMy40YTYgNiAwIDAgMS0xLjIgMi4zIDQuNCA0LjQgMCAwIDEtMS43IDEuMyA2IDYgMCAwIDEtMi4zLjQgNi41IDYuNSAwIDAgMS0xLjktLjMgNC42IDQuNiAwIDAgMS0xLjUtLjggNC44IDQuOCAwIDAgMS0xLjItMS40IDggOCAwIDAgMS0uOC0yLjFjLS4yLS44LS4yLTEuOC0uMi0yLjh2LTIuOGMwLTEuNCAwLTIuNS40LTMuNGE2IDYgMCAwIDEgMS0yLjMgNC4zIDQuMyAwIDAgMSAxLjktMS4zIDYuNSA2LjUgMCAwIDEgNC4yLS4xIDQuNCA0LjQgMCAwIDEgMS41LjhjLjQuMy44LjggMS4xIDEuNC40LjYuNiAxLjMuOCAyIC4yLjkuMyAxLjguMyAyLjlabS0yLjkgMy4yVjExOGwtLjQtMS40YTIuNiAyLjYgMCAwIDAtLjYtLjkgMiAyIDAgMCAwLS44LS41IDIuNyAyLjcgMCAwIDAtMS0uMWMtLjQgMC0uOCAwLTEuMS4yLS40LjItLjcuNS0uOS44bC0uNSAxLjUtLjIgMi4ydjMuNmwuMSAxLjhjMCAuNS4yIDEgLjQgMS4zbC41IDEgLjguNSAxIC4xYy40IDAgLjggMCAxLjItLjIuMy0uMi42LS41LjgtLjkuMy0uMy40LS44LjYtMS40bC4xLTIuMlptMTAuMi40aDF2Ni4zaC0uOWwtLjEtMS40di00LjlabS4yIDMuNGguNGwtLjEgMS4yYTMuMSAzLjEgMCAwIDEtLjQgMSAxLjggMS44IDAgMCAxLS42LjZjLS4zLjItLjYuMy0xIC4zLS4zIDAtLjUgMC0uOC0uMmExLjUgMS41IDAgMCAxLS42LS4zIDIgMiAwIDAgMS0uNC0uN2wtLjItMS43aC43djFjMCAuMi4yLjUuNC42IDAgLjIuMi4yLjQuM2gxLjNsLjYtLjUuMi0uN2E0LjYgNC42IDAgMCAwIDAtMVptLTQuMS0zLjRoMXY4LjhoLTF2LTguOFptMTEuMiAwaDF2Ni4yYzAgLjYtLjEgMS0uMyAxLjRhMi4yIDIuMiAwIDAgMS0xIDEgMyAzIDAgMCAxLTIuMS4xIDMuMSAzLjEgMCAwIDEtMS0uMyAyLjIgMi4yIDAgMCAxLS42LS43bC42LS42LjguNy45LjIuOC0uMi42LS42Yy4yLS4zLjItLjYuMi0xdi00LjhsLjEtMS40Wk0xMTAgMTI3bC4xLTEuNC41LTFhMi4yIDIuMiAwIDAgMSAxLjktMWMuNCAwIC43LjIgMSAuMy4zLjEuNi4zLjguNmwuNSAxYTYgNiAwIDAgMSAuMiAxLjJ2LjVhNS41IDUuNSAwIDAgMS0uMiAxLjMgMyAzIDAgMCAxLS41IDEgMiAyIDAgMCAxLS44LjYgMi41IDIuNSAwIDAgMS0xIC4yYy0uNCAwLS44LS4xLTEtLjNhMi40IDIuNCAwIDAgMS0uOS0uNyAzLjIgMy4yIDAgMCAxLS41LTEgNC42IDQuNiAwIDAgMS0uMS0xLjNabTEgMCAuMSAxIC4zLjcuNi41LjcuMWMuNCAwIC43IDAgMS0uMmwuNS0uNi4zLS44di0xLjRhMi45IDIuOSAwIDAgMC0uMi0uNiAxLjkgMS45IDAgMCAwLS4zLS42IDEuNSAxLjUgMCAwIDAtLjYtLjQgMS43IDEuNyAwIDAgMC0uNy0uMWMtLjMgMC0uNSAwLS43LjJhMS41IDEuNSAwIDAgMC0uNi41IDIuNCAyLjQgMCAwIDAtLjMuNyA0IDQgMCAwIDAgMCAxWm0xMC4xLTUuNC0zLjUgOS4zaC0xbDMuNi05LjNoMVptMi43IDMuNXY1aC0xdi02LjNoMXYxLjNabS0uMiAxLjZoLS41bC4xLTEuMi41LTEgLjgtLjYgMS4xLS4yLjkuMWExLjcgMS43IDAgMCAxIDEgMS4xbC4yIDF2NC4yaC0xVjEyNmMwLS40LS4xLS42LS4zLS44YTEgMSAwIDAgMC0uNC0uNSAxLjcgMS43IDAgMCAwLS43LS4xYy0uNCAwLS42IDAtLjguMmExLjQgMS40IDAgMCAwLS42LjRsLS4yLjdhMy44IDMuOCAwIDAgMC0uMS44Wm00LS42LS42LjNhMy4yIDMuMiAwIDAgMSAuMS0xIDIuNyAyLjcgMCAwIDEgLjUtLjljLjItLjMuNS0uNC44LS42bDEtLjIgMSAuMWMuMi4xLjUuMi42LjRsLjUuNy4xIDF2NC4yaC0xVjEyNmMwLS40LS4xLS43LS4zLS45YS45LjkgMCAwIDAtLjQtLjQgMS45IDEuOSAwIDAgMC0uNy0uMWwtLjcuMWExLjMgMS4zIDAgMCAwLS40LjRjLS4yLjEtLjMuMy0uMy40YTEuOCAxLjggMCAwIDAtLjEuNlptNi44LTIuNmguOWEuNi42IDAgMCAwIC4zLS4zLjYuNiAwIDAgMCAuMS0uMy42LjYgMCAwIDAtLjItLjRsLS42LS4yYTEgMSAwIDAgMC0uNS4xLjUuNSAwIDAgMC0uMi40aC0xYzAtLjIuMS0uNS4zLS42LjEtLjIuMy0uNC42LS41bC44LS4yIDEgLjJjLjIgMCAuNC4yLjUuNC4yLjIuMi41LjIuOGExIDEgMCAwIDEtLjEuNWwtLjYuNC0uOS4yaC0uNnYtLjVabTAgLjd2LS40aC42bDEgLjFjLjIgMCAuNC4yLjUuNC4yLjIuMi40LjIuNiAwIC4zIDAgLjYtLjIuOGExLjUgMS41IDAgMCAxLS43LjRsLS45LjItLjgtLjFhMS41IDEuNSAwIDAgMS0uNy0uNWwtLjItLjhoMWwuMS41LjcuMS42LS4xYS42LjYgMCAwIDAgLjItLjUuNi42IDAgMCAwLS4xLS40LjYuNiAwIDAgMC0uMy0uMmgtMVoiIC8+CiAgPHBhdGggZmlsbD0idXJsKCNiKSIgZmlsbC1vcGFjaXR5PSIuMyIgZD0iTTE1OCAxMjAuN2g1LjR2Ni43bC0yLjcgMi0yLjctMnYtNi43WiIgLz4KICA8cGF0aCBmaWxsPSIjMDAwIiBmaWxsLW9wYWNpdHk9Ii45IiBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICBkPSJNMTYwLjUgMTI5LjhhLjMuMyAwIDAgMCAuNSAwbDIuNC0yLjQtLjUtLjUtMS4zIDEuM2EuMy4zIDAgMCAxLS41LS4ydi0zLjNoLS43djMuM2EuMy4zIDAgMCAxLS41LjJsLTEuNC0xLjMtLjUuNSAyLjUgMi40WiIKICAgIGNsaXAtcnVsZT0iZXZlbm9kZCIgLz4KICA8cGF0aCBmaWxsPSIjMDAwIiBmaWxsLW9wYWNpdHk9Ii45IgogICAgZD0iTTE3MC4zIDEyNS4ydjFoLTMuMXYtMWgzWm01LTQuNHY4LjZIMTc0di03bC0yIC44VjEyMmwzLjMtMS4yaC4yWm03LjQgMGguMXYxLjFjLS42IDAtMSAuMS0xLjQuM2EyLjQgMi40IDAgMCAwLS45LjZsLS41IDEtLjIgMS4xdjEuNGwuMS45LjQuNi40LjQuNi4yYy4yIDAgLjQgMCAuNi0uMmwuNC0uMy4zLS42YTIuNSAyLjUgMCAwIDAgMC0xLjQgMS43IDEuNyAwIDAgMC0uMy0uNSAxLjIgMS4yIDAgMCAwLS40LS40IDEuMiAxLjIgMCAwIDAtLjYtLjJsLS44LjJhMS42IDEuNiAwIDAgMC0uNS42IDEuMyAxLjMgMCAwIDAtLjMuNmwtLjUtLjJhMyAzIDAgMCAxIC4yLTFsLjYtLjcuNy0uNCAxLS4yYy40IDAgLjcgMCAxIC4ybC44LjdjLjIuMi4zLjUuNC45YTMuNyAzLjcgMCAwIDEgMCAyLjJjLS4xLjQtLjMuNy0uNiAxYTIuNSAyLjUgMCAwIDEtLjguNmwtMS4yLjJjLS40IDAtLjktLjEtMS4yLS4zYTIuNyAyLjcgMCAwIDEtMS0uNyAzLjMgMy4zIDAgMCAxLS41LTEgNC4yIDQuMiAwIDAgMS0uMi0xLjR2LS41YzAtLjcuMS0xLjMuMy0xLjlhNC41IDQuNSAwIDAgMSAuNy0xLjUgMy41IDMuNSAwIDAgMSAxLjMtMWMuNi0uMyAxLjItLjQgMi0uNFptMi44IDMuN3YtLjNsLjEtLjcuNS0uNS43LS4xLjcuMS40LjUuMi43di4zYzAgLjMgMCAuNS0uMi43YTEuMiAxLjIgMCAwIDEtLjQuNWwtLjcuMS0uNy0uMWExLjIgMS4yIDAgMCAxLS41LS41IDEuNCAxLjQgMCAwIDEtLjEtLjdabS42LS4zdi43YS43LjcgMCAwIDAgLjMuMy43LjcgMCAwIDAgLjQuMS43LjcgMCAwIDAgLjQgMCAuNy43IDAgMCAwIC4yLS40IDEgMSAwIDAgMCAwLS40di0uM2ExIDEgMCAwIDAgMC0uNC42LjYgMCAwIDAtLjYtLjQuNy43IDAgMCAwLS40LjEuNy43IDAgMCAwLS4zLjMgMSAxIDAgMCAwIDAgLjRabTIuNSA0di0uNGwuMS0uNy41LS40LjctLjJjLjMgMCAuNSAwIC43LjIuMiAwIC4zLjIuNC40bC4yLjd2LjNjMCAuMyAwIC41LS4yLjdhMS4yIDEuMiAwIDAgMS0uNC41IDEuMyAxLjMgMCAwIDEtLjcuMmwtLjctLjJhMS4zIDEuMyAwIDAgMS0uNS0uNSAxLjQgMS40IDAgMCAxLS4xLS43Wm0uNi0uNHYuN2wuMy4zYS43LjcgMCAwIDAgLjQuMS43LjcgMCAwIDAgLjQgMCAuNy43IDAgMCAwIC4yLS40di0uN2ExIDEgMCAwIDAgMC0uNC42LjYgMCAwIDAtLjYtLjQuNi42IDAgMCAwLS42LjQgMSAxIDAgMCAwLS4xLjRabS45LTQtMy4xIDUtLjUtLjIgMy4xLTUgLjUuM1oiIC8+CiAgPGRlZnM+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9ImIiIHgxPSIxNjAuNSIgeDI9IjE2MC41IiB5MT0iMTI5LjkiIHkyPSIxMjAuNyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICAgICA8c3RvcCBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiNmZmY7c3RvcC1vcGFjaXR5OjEiIC8+CiAgICAgIDxzdG9wIG9mZnNldD0iMSIgc3R5bGU9InN0b3AtY29sb3I6I2ZmZjtzdG9wLW9wYWNpdHk6MCIgLz4KICAgIDwvbGluZWFyR3JhZGllbnQ+CiAgICA8Y2xpcFBhdGggaWQ9ImEiPgogICAgICA8cGF0aCBmaWxsPSIjZmZmIiBkPSJNOCAxMzUuNWgxODR2MTJIOHoiIC8+CiAgICA8L2NsaXBQYXRoPgogIDwvZGVmcz4KPC9zdmc+", - "description": "Contains widgets displaying air quality telemetry.", - "order": 12600, - "externalId": null, - "name": "Air quality" - }, - "widgetTypeFqns": [ - "air_quality_card", - "air_quality_card_with_background", - "horizontal_air_quality_card", - "horizontal_air_quality_card_with_background", - "air_quality_chart_card", - "air_quality_chart_card_with_background", - "simple_air_quality_chart_card", - "simple_air_quality_chart_card_with_background", - "humidity_card", - "humidity_card_with_background", - "horizontal_humidity_card", - "horizontal_humidity_card_with_background", - "humidity_chart_card", - "humidity_chart_card_with_background", - "simple_humidity_chart_card", - "simple_humidity_chart_card_with_background", - "humidity_progress_bar", - "humidity_progress_bar_with_background", - "co2_card", - "co2_card_with_background", - "horizontal_co2_card", - "horizontal_co2_card_with_background", - "co2_chart_card", - "co2_chart_card_with_background", - "simple_co2_chart_card", - "simple_co2_chart_card_with_background", - "pm2_5_card", - "pm2_5_card_with_background", - "horizontal_pm2_5_card", - "horizontal_pm2_5_card_with_background", - "pm2_5_chart_card", - "pm2_5_chart_card_with_background", - "simple_pm2_5_chart_card", - "simple_pm2_5_chart_card_with_background", - "pm10_card", - "pm10_card_with_background", - "horizontal_pm10_card", - "horizontal_pm10_card_with_background", - "pm10_chart_card", - "pm10_chart_card_with_background", - "simple_pm10_chart_card", - "simple_pm10_chart_card_with_background", - "radon_level_card", - "radon_level_card_with_background", - "horizontal_radon_level_card", - "horizontal_radon_level_card_with_background", - "radon_level_chart_card", - "radon_level_chart_card_with_background", - "simple_radon_level_chart_card", - "simple_radon_level_chart_card_with_background", - "volatile_organic_compounds_card", - "volatile_organic_compounds_card_with_background", - "horizontal_volatile_organic_compounds_card", - "horizontal_volatile_organic_compounds_card_with_background", - "volatile_organic_compounds_chart_card", - "volatile_organic_compounds_chart_card_with_background", - "simple_volatile_organic_compounds_chart_card", - "simple_volatile_organic_compounds_chart_card_with_background", - "individual_allergy_index_card", - "individual_allergy_index_card_with_background", - "horizontal_individual_allergy_index_iai_card", - "horizontal_individual_allergy_index_iai_card_with_background", - "individual_allergy_index_iai_chart_card", - "individual_allergy_index_iai_chart_card_with_background", - "simple_individual_allergy_index_iai_chart_card", - "simple_individual_allergy_index_iai_chart_card_with_background2", - "ozone_o3_card", - "ozone_o3_card_with_background", - "horizontal_ozone_o3_card", - "horizontal_ozone_o3_card_with_background", - "ozone_o3_chart_card", - "ozone_o3_chart_card_with_background", - "simple_ozone_o3_chart_card", - "simple_ozone_o3_chart_card_with_background", - "nitrogen_dioxide_no2_card", - "nitrogen_dioxide_no2_card_with_background", - "horizontal_nitrogen_dioxide_no2_card", - "horizontal_nitrogen_dioxide_no2_card_with_background", - "nitrogen_dioxide_no2_chart_card", - "nitrogen_dioxide_no2_chart_card_with_background", - "simple_nitrogen_dioxide_no2_chart_card", - "simple_nitrogen_dioxide_no2_chart_card_with_background", - "sulfur_dioxide_so2_card", - "sulfur_dioxide_so2_card_with_background", - "horizontal_sulfur_dioxide_so2_card", - "horizontal_sulfur_dioxide_so2_card_with_background", - "sulfur_dioxide_so2_chart_card", - "sulfur_dioxide_so2_chart_card_with_background", - "simple_sulfur_dioxide_so2_chart_card", - "simple_sulfur_dioxide_so2_chart_card_with_background", - "carbon_monoxide_co_card", - "carbon_monoxide_co_card_with_background", - "horizontal_carbon_monoxide_co_card", - "horizontal_carbon_monoxide_co_card_with_background", - "carbon_monoxide_co_chart_card", - "carbon_monoxide_co_chart_card_with_background", - "simple_carbon_monoxide_co_chart_card", - "simple_carbon_monoxide_co_chart_card_with_background" - ] -} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json index a30d6ee76f..0d541eebac 100644 --- a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json @@ -13,6 +13,7 @@ "home_page_widgets.quick_links", "home_page_widgets.documentation_links", "home_page_widgets.dashboards", - "home_page_widgets.usage_info" + "home_page_widgets.usage_info", + "home_page_widgets.iot_hub" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/indoor_environment.json b/application/src/main/data/json/system/widget_bundles/indoor_environment.json deleted file mode 100644 index d13fc5ba66..0000000000 --- a/application/src/main/data/json/system/widget_bundles/indoor_environment.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "widgetsBundle": { - "alias": "indoor_environment", - "title": "Indoor Environment", - "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMkAAAChCAMAAABav2sAAAABcVBMVEUAAAAAAADf39/09PTw8PD9/f3AwMDy8vL39/fy8vL19fXw8PDw8PD////39/fz8/MjTMf6+vrj4+P29vbx8fHn5+fHx8dYWFg/pxra2tp0dHSQkJDt7e3h4eFmZmbV1dWsrKz/pgBaedW5ubmCgoKf04z/3aDdNVGenp71xc3T09PCwsL64ubI0vHOzs4+Ys7wqLTIyMirq6uRpePYGDjk6fisvOr/9N91j9zri5v/6b9KSkr/+u8wV8u33qniUmnn9OODmt//78//46/z+vHW3fXgQ13/sSDnb4L/vkXx9PufsOa7u7v/2JD/04D/zW//x2DaJkXy9Pv98fPc3NxYszi6x+6esefPz8//6L/umqiHyHA9PT3/rBD309rP6ca2trYsLCzb79TLy8ur2ZrpfY91wFpLrCiDm99MbdHzt8HD47jtmaiTzX7aJkQxV8rtmqj/2Y+JiYmTzn7lYHZUVFQ8PDz/tzBohNnNzc2Pj4/UlCD8AAAADXRSTlMAByDvv/Eqr8W577Cvv6N3egAACkpJREFUeNrsms9v0zAUgBEgceHhxNiOnNhJ10RVlGqp0qpd1a2HtqqqwdjQDoNJTIIbnBBI/PzreQ5ts0FBK91GOvVbE7t2V/ub/ZzW2Z07d+/fe3RtPH549w5yI63cvQfXyj1s5GZaeQDXzH0UuZFWHsM1cw9FbqSVR3DNPEKRG2hlY7IxmXNbTFqD9jWaxCwIMWGeF0BOJYAZERREq5u87TfeH8MMIQS9SpPwTei9YQDUDxwQIWMVV7AKZaJCARwIQiYgxApwVjd51xpY2zCFVV7FAaPADIAZwIRSoIz5DJP86TImwotDMxrUqTgQx7GLJo7YpU4zQhPmiCiibsWLxBWYNFpjNJnjUOpEses5nsM+xw71feFEzcgPXgUOVu6GkecvFSeVCoAx8Tzq4+8yDzvtBy51sa1g12sKv+n53tWYPL1oIowJNT/YZOxHTQfCeBefGBMXPi9nInZ3cxMX8C18fOddEbke/WkCThTCGxo4UXAdY8KcMPxp8iZ0hF8RDrjeK+oLP3xFXZRrlnTtalQH503MnBYsAPPjCAbUZJgIgFIamBACwcpqYlnWCSykuVbXk5aFNI5hKZY3IVrj2bbBxkRh7ueDED7N29Ikq5g0LMPTeYvwK6oowo6A/jcTiV2VicrkAQ90CjqVGp9rqQIJGZeJraR8KckKJmMrpwtT6gBa2ZK8VqBsxbXNM641r2MRqNeEaHyBxEQRSZYx4djvTIKUkhOeAOdEa805PiTolzIBLH3JYQWTJ1bOoDCxk9c6ldjhlKR1ldQlT1+TNMvykkzhC9J6RrIDlZUsTr5aSL8NUw64TvHI6gQymR4kso4T4jWpp1rnbjgmWh6kPMt42UyedC3rXbEKc25zDhxDEQgndQWE2FyBwqI8iFR+qDQDXra1C2nDn1CwmNxrKZNiYbLzlGC6nt9PuOSJUpxwzBFC8BRwpYlS62eiucQ1imuCAtLAucQivnYmhCiOvVfERhWjQRRmuA7WzuQP8PWbXXM2OxIbk43JxmRjAr+wMdmY3DKTvT087RhMZu/0dG89TU6PzmoAvS3DPsAzTCY9WIj9+DGeqQdBDAErmcneBHuOJqOto1qtNoKdrcmwd3a2eFSYpDZQ5oHrsaZXMpOd571naIJDcQoIpiOAQzwtgn9PbCbAA0+ENBblMkFqxuRo67Q2wpF4vjU0Jc9gEckrFxABtAKMBeU02d9CJjtosmOC5hAWQlmZI/6nyehoCB+29guTK127jlstgE51xklxE68zrW/0+9XO8VWY5Ey2LmFCALHNcUmT4xdVy6qafe8ZJzORQTe/OTHuWzmN9hWYnA7BTDE4msfJcAeQvfyYY0sSaM0J15JfMk5OsJu5SXs756llTTvc6AL0B1jRtxov2mOsqF6BydkZwM7ZBJ8cTsP+WW2vNuz1Rr3RcFjsZnASSIRznpDLmbSs6ot35/rYtbrz+0Uo8RRLpm44aNsrm2CMTI6+YAavL8/3zRUSjo6eQa3X26nVcHTmEDshPFCEEHXJOBk/AagWJm3LGk+zT61+HyfUE2vqhpnBSiaj/Z7x2Z/s1/ILzJfJoZlPh7Ve7XA03BuNVo34CybdIgvjQQs1t+e3Wra3x9f1CbIHhqs0edKwOnCRDk6qtfksXJh0rAbcDpOG1b0dJtjr9gKTk+VMNNFKK0D+n0n19yGB8TziW63O5Uy41JwD8t9M2gsnUr+oHVzOREt8AHAN/0rAijxJljfpWo2F106rk9cue2VU8K/ErutGIZ2pLG3SzrtcUKzMVveF+XQ2uKGIDyI8sdB1xT/OroH1/hgW0EYVBEVWMFnwlYNh0SKoP8s0nQpdyqTT6hTJIk4G3e6LbVjJxHUoXEC4XuSIX+UoDc6/UODAlOybFnVpVIEC5sXm7DA4T+QhF41p2Uw8iod37m9N5yFRUInLud9lk4IgIsi3TwEx0I8xmfLxgMyhn8hC/rvJeTwxDeZwOiBzfFbMLVr+PUjmzpW8PEIKRARTaFTa3VTmuq5fAaQiYEbTp3CBaFbn0NKa5DSNiwt/hjmQEzdLucOtOQCfr1POXzsp3IrBgz/A/6sJ5wDFXoKAvyJyGCzG/s8mGi5yu+4EbUw2JhuTWRsq//9sk+Rok7UBEtskiL02Jhx4XaUqBc6llEEiX2Yqk5BKDkkqM635uphoLusqUQnIVGI+SV5KniqQAYc0SOpaq3UxOYeGX1FyjeLkPOsd8QUbkx/t3G9r2kAAx3HcZtdu/DYWjzu5lDOQhCQzD4L4RATNAxGfivWJDmXt1sG6lsH2/tklzLZZmfSW5NzGfQpXEfH8kvgX7kyJKfl1lqeoWUuGaJnlGWr2UoZomKX+vSpePJEhemZ58rz1qjZPn+UhGmYxjP9Co0badgySjo6bqM/JkbZ9iY6aqFXzSNe+RCeo2bGmfYkaqFuzoeGznZYSmJJHMyWK/s8SasHBLYcAnuUQgtKKJQQQBEDHhUWJ7RA4XgclFUtsCgYqg6hruXBJx2UW9TglnXI1xRLCbdsTAIgAp6LTsym3BKEuSimWMP4ODMzhHvccbjE6pHJWNxQWyiiWgIbwXEhyBjCAhQ5AmEtRUvGY5CU5TjkYtR2HCIFyiiUQtsdIlsQ5hrDkfHBIT1RdMrR3JZaw5TGhAl4P5RRLCIcgyLzvIBTCJczjRPCys+Qle5GSJ9ff8ypsUZTy95SUZUoUmRIFpkSRKVFgShSZEgWmRFWxJPXPgZHv+x9QmcOUrNsjYLm8vFyjMgcpGS2zkvYVqqW/ZLIcyZJJ+5s/QpX0l1yvIUvST5frT9fAyN+mqID+EpmQZiUZvz3B9dWHEaqgv2TdzvjpFhjJksm5f56iAvpLJmmatj9Mtu2ryeUSMmdbzYux/hIpP7v8dnt5DmA7+eajPL0lRZN0dwEVqL6E2ExwgqJDfFpZbADECyBKIiAfizbfsRenQIehSH/JZjwfIBn3IyTBx2CFZJ6NBVGAfSwOyaYo0F/yOZnKkkiWjFf4MsV4gM8BgC/RIMFiIEdZkgxi3EdcK/8nGLOI+HkZBfpLkD12QJYE2WPGVI59AMH0azCI+7Ov8yjqz2Y3C9xxmSN6ALwQlhAucqGN+8tGD18S7EoSeYTifozZTF6L6Qp3GAGGFijHfT3BhlwwB7niO+Pv+a0/8ErpmCRIgqxkIEvyG93KCzpsKIMesuzwQcmnPSXLVuu18l9rf8n3GT6O8XGG1TgrWWE2jvsbTAfRTbwIPuMWt7DXg5LU/730zR/A/pI4CII4G+eb/HkSzOO4P58HcXQznY9xy/bwCIf99hvfG4NkAciza3fdDmUhHueu5LR7ikqcAfKuLi6U3uODBHkJikJB8AjFki5cVMEKcXHqnp11Wyol8SIfoe5hiVtRSX5HsuTitFBSAw0l3ZZrvVU4JiXU/Dxpdc/w9i1y5pc7RaZEgSlRZEoUmBJFpkSBKVFkShSYEmUNPesZG03UraFpPeMxanaiYaWhppU02lYaHp2gPs1jGbLPv7Xi7AcB+P0LpI8HcAAAAABJRU5ErkJggg==", - "description": "Contains widgets displaying indoor environment telemetry.", - "order": 10000, - "externalId": null, - "name": "Indoor Environment" - }, - "widgetTypeFqns": [ - "indoor_temperature_card", - "indoor_temperature_card_with_background", - "indoor_horizontal_temperature_card", - "indoor_horizontal_temperature_card_with_background", - "indoor_temperature_chart_card", - "indoor_temperature_chart_card_with_background", - "indoor_simple_temperature_chart_card", - "indoor_simple_temperature_chart_card_with_background", - "indoor_temperature_progress_bar", - "indoor_temperature_progress_bar_with_background", - "indoor_temperature_range_chart", - "indoor_temperature_range_chart_with_background", - "indoor_temperature_gauge", - "indoor_humidity_card", - "indoor_humidity_card_with_background", - "indoor_horizontal_humidity_card", - "indoor_horizontal_humidity_card_with_background", - "indoor_humidity_chart_card", - "indoor_humidity_chart_card_with_background", - "indoor_simple_humidity_chart_card", - "indoor_simple_humidity_chart_card_with_background", - "indoor_humidity_progress_bar", - "indoor_humidity_progress_bar_with_background", - "indoor_co2_card", - "indoor_co2_card_with_background", - "indoor_horizontal_co2_card", - "indoor_horizontal_co2_card_with_background", - "indoor_co2_chart_card", - "indoor_co2_chart_card_with_background", - "indoor_simple_co2_chart_card", - "indoor_simple_co2_chart_card_with_background", - "indoor_illuminance_card", - "indoor_illuminance_card_with_background", - "indoor_horizontal_illuminance_card", - "indoor_horizontal_illuminance_card_with_background", - "indoor_illuminance_chart_card", - "indoor_illuminance_chart_card_with_background", - "indoor_simple_illuminance_chart_card", - "indoor_simple_illuminance_chart_card_with_background", - "indoor_illuminance_progress_bar", - "indoor_illuminance_progress_bar_with_background", - "noise_level_card", - "noise_level_card_with_background", - "horizontal_noise_level_card", - "horizontal_noise_level_card_with_background", - "noise_level_chart_card", - "noise_level_chart_card_with_background", - "simple_noise_level_chart_card", - "simple_noise_level_chart_card_with_background", - "indoor_pm2_5_card", - "indoor_pm2_5_card_with_background", - "indoor_horizontal_pm2_5_card", - "indoor_horizontal_pm2_5_card_with_background", - "indoor_pm2_5_chart_card", - "indoor_pm2_5_chart_card_with_background", - "indoor_simple_pm2_5_chart_card", - "indoor_simple_pm2_5_chart_card_with_background", - "indoor_pm10_card", - "indoor_pm10_card_with_background", - "indoor_horizontal_pm10_card", - "indoor_horizontal_pm10_card_with_background", - "indoor_pm10_chart_card", - "indoor_pm10_chart_card_with_background", - "indoor_simple_pm10_chart_card", - "indoor_simple_pm10_chart_card_with_background", - "radon_level_card", - "radon_level_card_with_background", - "horizontal_radon_level_card", - "horizontal_radon_level_card_with_background", - "radon_level_chart_card", - "radon_level_chart_card_with_background", - "simple_radon_level_chart_card", - "simple_radon_level_chart_card_with_background", - "volatile_organic_compounds_card", - "volatile_organic_compounds_card_with_background", - "horizontal_volatile_organic_compounds_card", - "horizontal_volatile_organic_compounds_card_with_background", - "volatile_organic_compounds_chart_card", - "volatile_organic_compounds_chart_card_with_background", - "simple_volatile_organic_compounds_chart_card", - "simple_volatile_organic_compounds_chart_card_with_background" - ] -} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/industial_widgets.json b/application/src/main/data/json/system/widget_bundles/industial_widgets.json deleted file mode 100644 index 940c4e0d31..0000000000 --- a/application/src/main/data/json/system/widget_bundles/industial_widgets.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "widgetsBundle": { - "alias": "industrial_widgets", - "title": "Industrial widgets", - "image": "tb-image:aW5kdXN0cmlhbC13aWRnZXRzLWJ1bmxkZS5zdmc=:aW5kdXN0cmlhbC13aWRnZXRzLWJ1bmxkZS5zdmc=;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iNzYiIHJ4PSI0IiBmaWxsPSJ3aGl0ZSIvPgo8cmVjdCB4PSIwLjUiIHk9IjAuNSIgd2lkdGg9IjE5OSIgaGVpZ2h0PSI3NSIgcng9IjMuNSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiLz4KPHBhdGggZD0iTTEyLjMwMDggMTcuMjIyN1YxOEg5LjI4MTI1VjE3LjIyMjdIMTIuMzAwOFpNOS41NTg1OSAxMi4zMTI1VjE4SDguNTc4MTJWMTIuMzEyNUg5LjU1ODU5Wk0xMS45MDYyIDE0LjY4NzVWMTUuNDUzMUg5LjI4MTI1VjE0LjY4NzVIMTEuOTA2MlpNMTIuMjgxMiAxMi4zMTI1VjEzLjA5MzhIOS4yODEyNVYxMi4zMTI1SDEyLjI4MTJaTTE0LjUzMTIgMThIMTMuNTg5OFYxMy4zNjcyQzEzLjU4OTggMTMuMDUyMSAxMy42NDg0IDEyLjc4NzggMTMuNzY1NiAxMi41NzQyQzEzLjg4NTQgMTIuMzU4MSAxNC4wNTYgMTIuMTk1MyAxNC4yNzczIDEyLjA4NTlDMTQuNDk4NyAxMS45NzQgMTQuNzYwNCAxMS45MTggMTUuMDYyNSAxMS45MThDMTUuMTU2MiAxMS45MTggMTUuMjQ4NyAxMS45MjQ1IDE1LjMzOTggMTEuOTM3NUMxNS40MzEgMTEuOTQ3OSAxNS41MTk1IDExLjk2NDggMTUuNjA1NSAxMS45ODgzTDE1LjU4MiAxMi43MTQ4QzE1LjUyOTkgMTIuNzAxOCAxNS40NzI3IDEyLjY5MjcgMTUuNDEwMiAxMi42ODc1QzE1LjM1MDMgMTIuNjgyMyAxNS4yODUyIDEyLjY3OTcgMTUuMjE0OCAxMi42Nzk3QzE1LjA3MTYgMTIuNjc5NyAxNC45NDc5IDEyLjcwNyAxNC44NDM4IDEyLjc2MTdDMTQuNzQyMiAxMi44MTM4IDE0LjY2NDEgMTIuODkwNiAxNC42MDk0IDEyLjk5MjJDMTQuNTU3MyAxMy4wOTM4IDE0LjUzMTIgMTMuMjE4OCAxNC41MzEyIDEzLjM2NzJWMThaTTE1LjQyNTggMTMuNzczNFYxNC40NjA5SDEyLjk0MTRWMTMuNzczNEgxNS40MjU4Wk0xNi45NTMxIDE4SDE2LjAxMTdWMTMuNUMxNi4wMTE3IDEzLjE1ODkgMTYuMDc5NCAxMi44NzExIDE2LjIxNDggMTIuNjM2N0MxNi4zNTAzIDEyLjQwMjMgMTYuNTQ1NiAxMi4yMjQgMTYuODAwOCAxMi4xMDE2QzE3LjA1ODYgMTEuOTc5MiAxNy4zNjQ2IDExLjkxOCAxNy43MTg4IDExLjkxOEMxNy45MjcxIDExLjkxOCAxOC4xMjg5IDExLjk0MDEgMTguMzI0MiAxMS45ODQ0QzE4LjUxOTUgMTIuMDI4NiAxOC43MjAxIDEyLjA4NDYgMTguOTI1OCAxMi4xNTIzTDE4Ljc4MTIgMTIuOTE4QzE4LjY1MSAxMi44NzM3IDE4LjUwMzkgMTIuODMyIDE4LjMzOTggMTIuNzkzQzE4LjE3ODQgMTIuNzUxMyAxNy45OTM1IDEyLjczMDUgMTcuNzg1MiAxMi43MzA1QzE3LjUwMTMgMTIuNzMwNSAxNy4yOTE3IDEyLjc5NTYgMTcuMTU2MiAxMi45MjU4QzE3LjAyMDggMTMuMDUzNCAxNi45NTMxIDEzLjI0NDggMTYuOTUzMSAxMy41VjE4Wk0xNy44MDQ3IDEzLjc3MzRWMTQuNDYwOUgxNS4zNjMzVjEzLjc3MzRIMTcuODA0N1pNMTkuMzc1IDEzLjc3MzRWMThIMTguNDI5N1YxMy43NzM0SDE5LjM3NVpNMjIuMzkwNiAxNy4zMjgxQzIyLjU0NDMgMTcuMzI4MSAyMi42ODIzIDE3LjI5ODIgMjIuODA0NyAxNy4yMzgzQzIyLjkyOTcgMTcuMTc1OCAyMy4wMjk5IDE3LjA4OTggMjMuMTA1NSAxNi45ODA1QzIzLjE4MzYgMTYuODcxMSAyMy4yMjY2IDE2Ljc0NDggMjMuMjM0NCAxNi42MDE2SDI0LjEyMTFDMjQuMTE1OSAxNi44NzUgMjQuMDM1MiAxNy4xMjM3IDIzLjg3ODkgMTcuMzQ3N0MyMy43MjI3IDE3LjU3MTYgMjMuNTE1NiAxNy43NSAyMy4yNTc4IDE3Ljg4MjhDMjMgMTguMDEzIDIyLjcxNDggMTguMDc4MSAyMi40MDIzIDE4LjA3ODFDMjIuMDc5NCAxOC4wNzgxIDIxLjc5ODIgMTguMDIzNCAyMS41NTg2IDE3LjkxNDFDMjEuMzE5IDE3LjgwMjEgMjEuMTE5OCAxNy42NDg0IDIwLjk2MDkgMTcuNDUzMUMyMC44MDIxIDE3LjI1NzggMjAuNjgyMyAxNy4wMzI2IDIwLjYwMTYgMTYuNzc3M0MyMC41MjM0IDE2LjUyMjEgMjAuNDg0NCAxNi4yNDg3IDIwLjQ4NDQgMTUuOTU3VjE1LjgyMDNDMjAuNDg0NCAxNS41Mjg2IDIwLjUyMzQgMTUuMjU1MiAyMC42MDE2IDE1QzIwLjY4MjMgMTQuNzQyMiAyMC44MDIxIDE0LjUxNTYgMjAuOTYwOSAxNC4zMjAzQzIxLjExOTggMTQuMTI1IDIxLjMxOSAxMy45NzI3IDIxLjU1ODYgMTMuODYzM0MyMS43OTgyIDEzLjc1MTMgMjIuMDc4MSAxMy42OTUzIDIyLjM5ODQgMTMuNjk1M0MyMi43MzcgMTMuNjk1MyAyMy4wMzM5IDEzLjc2MyAyMy4yODkxIDEzLjg5ODRDMjMuNTQ0MyAxNC4wMzEyIDIzLjc0NDggMTQuMjE3NCAyMy44OTA2IDE0LjQ1N0MyNC4wMzkxIDE0LjY5NCAyNC4xMTU5IDE0Ljk3MDEgMjQuMTIxMSAxNS4yODUySDIzLjIzNDRDMjMuMjI2NiAxNS4xMjg5IDIzLjE4NzUgMTQuOTg4MyAyMy4xMTcyIDE0Ljg2MzNDMjMuMDQ5NSAxNC43MzU3IDIyLjk1MzEgMTQuNjM0MSAyMi44MjgxIDE0LjU1ODZDMjIuNzA1NyAxNC40ODMxIDIyLjU1ODYgMTQuNDQ1MyAyMi4zODY3IDE0LjQ0NTNDMjIuMTk2NiAxNC40NDUzIDIyLjAzOTEgMTQuNDg0NCAyMS45MTQxIDE0LjU2MjVDMjEuNzg5MSAxNC42MzggMjEuNjkxNCAxNC43NDIyIDIxLjYyMTEgMTQuODc1QzIxLjU1MDggMTUuMDA1MiAyMS41IDE1LjE1MjMgMjEuNDY4OCAxNS4zMTY0QzIxLjQ0MDEgMTUuNDc3OSAyMS40MjU4IDE1LjY0NTggMjEuNDI1OCAxNS44MjAzVjE1Ljk1N0MyMS40MjU4IDE2LjEzMTUgMjEuNDQwMSAxNi4zMDA4IDIxLjQ2ODggMTYuNDY0OEMyMS40OTc0IDE2LjYyODkgMjEuNTQ2OSAxNi43NzYgMjEuNjE3MiAxNi45MDYyQzIxLjY5MDEgMTcuMDMzOSAyMS43ODkxIDE3LjEzNjcgMjEuOTE0MSAxNy4yMTQ4QzIyLjAzOTEgMTcuMjkwNCAyMi4xOTc5IDE3LjMyODEgMjIuMzkwNiAxNy4zMjgxWk0yNi4xMDE2IDEzLjc3MzRWMThIMjUuMTU2MlYxMy43NzM0SDI2LjEwMTZaTTI1LjA5MzggMTIuNjY0MUMyNS4wOTM4IDEyLjUyMDggMjUuMTQwNiAxMi40MDIzIDI1LjIzNDQgMTIuMzA4NkMyNS4zMzA3IDEyLjIxMjIgMjUuNDYzNSAxMi4xNjQxIDI1LjYzMjggMTIuMTY0MUMyNS43OTk1IDEyLjE2NDEgMjUuOTMxIDEyLjIxMjIgMjYuMDI3MyAxMi4zMDg2QzI2LjEyMzcgMTIuNDAyMyAyNi4xNzE5IDEyLjUyMDggMjYuMTcxOSAxMi42NjQxQzI2LjE3MTkgMTIuODA0NyAyNi4xMjM3IDEyLjkyMTkgMjYuMDI3MyAxMy4wMTU2QzI1LjkzMSAxMy4xMDk0IDI1Ljc5OTUgMTMuMTU2MiAyNS42MzI4IDEzLjE1NjJDMjUuNDYzNSAxMy4xNTYyIDI1LjMzMDcgMTMuMTA5NCAyNS4yMzQ0IDEzLjAxNTZDMjUuMTQwNiAxMi45MjE5IDI1LjA5MzggMTIuODA0NyAyNS4wOTM4IDEyLjY2NDFaTTI5LjI0NjEgMTguMDc4MUMyOC45MzM2IDE4LjA3ODEgMjguNjUxIDE4LjAyNzMgMjguMzk4NCAxNy45MjU4QzI4LjE0ODQgMTcuODIxNiAyNy45MzQ5IDE3LjY3NzEgMjcuNzU3OCAxNy40OTIyQzI3LjU4MzMgMTcuMzA3MyAyNy40NDkyIDE3LjA4OTggMjcuMzU1NSAxNi44Mzk4QzI3LjI2MTcgMTYuNTg5OCAyNy4yMTQ4IDE2LjMyMDMgMjcuMjE0OCAxNi4wMzEyVjE1Ljg3NUMyNy4yMTQ4IDE1LjU0NDMgMjcuMjYzIDE1LjI0NDggMjcuMzU5NCAxNC45NzY2QzI3LjQ1NTcgMTQuNzA4MyAyNy41ODk4IDE0LjQ3OTIgMjcuNzYxNyAxNC4yODkxQzI3LjkzMzYgMTQuMDk2NCAyOC4xMzY3IDEzLjk0OTIgMjguMzcxMSAxMy44NDc3QzI4LjYwNTUgMTMuNzQ2MSAyOC44NTk0IDEzLjY5NTMgMjkuMTMyOCAxMy42OTUzQzI5LjQzNDkgMTMuNjk1MyAyOS42OTkyIDEzLjc0NjEgMjkuOTI1OCAxMy44NDc3QzMwLjE1MjMgMTMuOTQ5MiAzMC4zMzk4IDE0LjA5MjQgMzAuNDg4MyAxNC4yNzczQzMwLjYzOTMgMTQuNDU5NiAzMC43NTEzIDE0LjY3NzEgMzAuODI0MiAxNC45Mjk3QzMwLjg5OTcgMTUuMTgyMyAzMC45Mzc1IDE1LjQ2MDkgMzAuOTM3NSAxNS43NjU2VjE2LjE2OEgyNy42NzE5VjE1LjQ5MjJIMzAuMDA3OFYxNS40MThDMzAuMDAyNiAxNS4yNDg3IDI5Ljk2ODggMTUuMDg5OCAyOS45MDYyIDE0Ljk0MTRDMjkuODQ2NCAxNC43OTMgMjkuNzUzOSAxNC42NzMyIDI5LjYyODkgMTQuNTgyQzI5LjUwMzkgMTQuNDkwOSAyOS4zMzcyIDE0LjQ0NTMgMjkuMTI4OSAxNC40NDUzQzI4Ljk3MjcgMTQuNDQ1MyAyOC44MzMzIDE0LjQ3OTIgMjguNzEwOSAxNC41NDY5QzI4LjU5MTEgMTQuNjEyIDI4LjQ5MDkgMTQuNzA3IDI4LjQxMDIgMTQuODMyQzI4LjMyOTQgMTQuOTU3IDI4LjI2NjkgMTUuMTA4MSAyOC4yMjI3IDE1LjI4NTJDMjguMTgxIDE1LjQ1OTYgMjguMTYwMiAxNS42NTYyIDI4LjE2MDIgMTUuODc1VjE2LjAzMTJDMjguMTYwMiAxNi4yMTYxIDI4LjE4NDkgMTYuMzg4IDI4LjIzNDQgMTYuNTQ2OUMyOC4yODY1IDE2LjcwMzEgMjguMzYyIDE2LjgzOTggMjguNDYwOSAxNi45NTdDMjguNTU5OSAxNy4wNzQyIDI4LjY3OTcgMTcuMTY2NyAyOC44MjAzIDE3LjIzNDRDMjguOTYwOSAxNy4yOTk1IDI5LjEyMTEgMTcuMzMyIDI5LjMwMDggMTcuMzMyQzI5LjUyNzMgMTcuMzMyIDI5LjcyOTIgMTcuMjg2NSAyOS45MDYyIDE3LjE5NTNDMzAuMDgzMyAxNy4xMDQyIDMwLjIzNyAxNi45NzUzIDMwLjM2NzIgMTYuODA4NkwzMC44NjMzIDE3LjI4OTFDMzAuNzcyMSAxNy40MjE5IDMwLjY1MzYgMTcuNTQ5NSAzMC41MDc4IDE3LjY3MTlDMzAuMzYyIDE3Ljc5MTcgMzAuMTgzNiAxNy44ODkzIDI5Ljk3MjcgMTcuOTY0OEMyOS43NjQzIDE4LjA0MDQgMjkuNTIyMSAxOC4wNzgxIDI5LjI0NjEgMTguMDc4MVpNMzIuODU1NSAxNC42NzU4VjE4SDMxLjkxNDFWMTMuNzczNEgzMi44MDA4TDMyLjg1NTUgMTQuNjc1OFpNMzIuNjg3NSAxNS43MzA1TDMyLjM4MjggMTUuNzI2NkMzMi4zODU0IDE1LjQyNzEgMzIuNDI3MSAxNS4xNTIzIDMyLjUwNzggMTQuOTAyM0MzMi41OTExIDE0LjY1MjMgMzIuNzA1NyAxNC40Mzc1IDMyLjg1MTYgMTQuMjU3OEMzMyAxNC4wNzgxIDMzLjE3NzEgMTMuOTQwMSAzMy4zODI4IDEzLjg0MzhDMzMuNTg4NSAxMy43NDQ4IDMzLjgxNzcgMTMuNjk1MyAzNC4wNzAzIDEzLjY5NTNDMzQuMjczNCAxMy42OTUzIDM0LjQ1NyAxMy43MjQgMzQuNjIxMSAxMy43ODEyQzM0Ljc4NzggMTMuODM1OSAzNC45Mjk3IDEzLjkyNTggMzUuMDQ2OSAxNC4wNTA4QzM1LjE2NjcgMTQuMTc1OCAzNS4yNTc4IDE0LjMzODUgMzUuMzIwMyAxNC41MzkxQzM1LjM4MjggMTQuNzM3IDM1LjQxNDEgMTQuOTgwNSAzNS40MTQxIDE1LjI2OTVWMThIMzQuNDY4OFYxNS4yNjU2QzM0LjQ2ODggMTUuMDYyNSAzNC40Mzg4IDE0LjkwMjMgMzQuMzc4OSAxNC43ODUyQzM0LjMyMTYgMTQuNjY1NCAzNC4yMzcgMTQuNTgwNyAzNC4xMjUgMTQuNTMxMkMzNC4wMTU2IDE0LjQ3OTIgMzMuODc4OSAxNC40NTMxIDMzLjcxNDggMTQuNDUzMUMzMy41NTM0IDE0LjQ1MzEgMzMuNDA4OSAxNC40ODcgMzMuMjgxMiAxNC41NTQ3QzMzLjE1MzYgMTQuNjIyNCAzMy4wNDU2IDE0LjcxNDggMzIuOTU3IDE0LjgzMkMzMi44NzExIDE0Ljk0OTIgMzIuODA0NyAxNS4wODQ2IDMyLjc1NzggMTUuMjM4M0MzMi43MTA5IDE1LjM5MTkgMzIuNjg3NSAxNS41NTYgMzIuNjg3NSAxNS43MzA1Wk0zOC4zNTE2IDE3LjMyODFDMzguNTA1MiAxNy4zMjgxIDM4LjY0MzIgMTcuMjk4MiAzOC43NjU2IDE3LjIzODNDMzguODkwNiAxNy4xNzU4IDM4Ljk5MDkgMTcuMDg5OCAzOS4wNjY0IDE2Ljk4MDVDMzkuMTQ0NSAxNi44NzExIDM5LjE4NzUgMTYuNzQ0OCAzOS4xOTUzIDE2LjYwMTZINDAuMDgyQzQwLjA3NjggMTYuODc1IDM5Ljk5NjEgMTcuMTIzNyAzOS44Mzk4IDE3LjM0NzdDMzkuNjgzNiAxNy41NzE2IDM5LjQ3NjYgMTcuNzUgMzkuMjE4OCAxNy44ODI4QzM4Ljk2MDkgMTguMDEzIDM4LjY3NTggMTguMDc4MSAzOC4zNjMzIDE4LjA3ODFDMzguMDQwNCAxOC4wNzgxIDM3Ljc1OTEgMTguMDIzNCAzNy41MTk1IDE3LjkxNDFDMzcuMjc5OSAxNy44MDIxIDM3LjA4MDcgMTcuNjQ4NCAzNi45MjE5IDE3LjQ1MzFDMzYuNzYzIDE3LjI1NzggMzYuNjQzMiAxNy4wMzI2IDM2LjU2MjUgMTYuNzc3M0MzNi40ODQ0IDE2LjUyMjEgMzYuNDQ1MyAxNi4yNDg3IDM2LjQ0NTMgMTUuOTU3VjE1LjgyMDNDMzYuNDQ1MyAxNS41Mjg2IDM2LjQ4NDQgMTUuMjU1MiAzNi41NjI1IDE1QzM2LjY0MzIgMTQuNzQyMiAzNi43NjMgMTQuNTE1NiAzNi45MjE5IDE0LjMyMDNDMzcuMDgwNyAxNC4xMjUgMzcuMjc5OSAxMy45NzI3IDM3LjUxOTUgMTMuODYzM0MzNy43NTkxIDEzLjc1MTMgMzguMDM5MSAxMy42OTUzIDM4LjM1OTQgMTMuNjk1M0MzOC42OTc5IDEzLjY5NTMgMzguOTk0OCAxMy43NjMgMzkuMjUgMTMuODk4NEMzOS41MDUyIDE0LjAzMTIgMzkuNzA1NyAxNC4yMTc0IDM5Ljg1MTYgMTQuNDU3QzQwIDE0LjY5NCA0MC4wNzY4IDE0Ljk3MDEgNDAuMDgyIDE1LjI4NTJIMzkuMTk1M0MzOS4xODc1IDE1LjEyODkgMzkuMTQ4NCAxNC45ODgzIDM5LjA3ODEgMTQuODYzM0MzOS4wMTA0IDE0LjczNTcgMzguOTE0MSAxNC42MzQxIDM4Ljc4OTEgMTQuNTU4NkMzOC42NjY3IDE0LjQ4MzEgMzguNTE5NSAxNC40NDUzIDM4LjM0NzcgMTQuNDQ1M0MzOC4xNTc2IDE0LjQ0NTMgMzggMTQuNDg0NCAzNy44NzUgMTQuNTYyNUMzNy43NSAxNC42MzggMzcuNjUyMyAxNC43NDIyIDM3LjU4MiAxNC44NzVDMzcuNTExNyAxNS4wMDUyIDM3LjQ2MDkgMTUuMTUyMyAzNy40Mjk3IDE1LjMxNjRDMzcuNDAxIDE1LjQ3NzkgMzcuMzg2NyAxNS42NDU4IDM3LjM4NjcgMTUuODIwM1YxNS45NTdDMzcuMzg2NyAxNi4xMzE1IDM3LjQwMSAxNi4zMDA4IDM3LjQyOTcgMTYuNDY0OEMzNy40NTgzIDE2LjYyODkgMzcuNTA3OCAxNi43NzYgMzcuNTc4MSAxNi45MDYyQzM3LjY1MSAxNy4wMzM5IDM3Ljc1IDE3LjEzNjcgMzcuODc1IDE3LjIxNDhDMzggMTcuMjkwNCAzOC4xNTg5IDE3LjMyODEgMzguMzUxNiAxNy4zMjgxWk00Mi4yODEyIDE3LjUzOTFMNDMuNDI5NyAxMy43NzM0SDQ0LjQzNzVMNDIuNzQyMiAxOC42NDQ1QzQyLjcwMzEgMTguNzQ4NyA0Mi42NTIzIDE4Ljg2MiA0Mi41ODk4IDE4Ljk4NDRDNDIuNTI3MyAxOS4xMDY4IDQyLjQ0NTMgMTkuMjIyNyA0Mi4zNDM4IDE5LjMzMkM0Mi4yNDQ4IDE5LjQ0NCA0Mi4xMjExIDE5LjUzMzkgNDEuOTcyNyAxOS42MDE2QzQxLjgyNDIgMTkuNjcxOSA0MS42NDQ1IDE5LjcwNyA0MS40MzM2IDE5LjcwN0M0MS4zNTAzIDE5LjcwNyA0MS4yNjk1IDE5LjY5OTIgNDEuMTkxNCAxOS42ODM2QzQxLjExNTkgMTkuNjcwNiA0MS4wNDQzIDE5LjY1NjIgNDAuOTc2NiAxOS42NDA2TDQwLjk3MjcgMTguOTIxOUM0MC45OTg3IDE4LjkyNDUgNDEuMDI5OSAxOC45MjcxIDQxLjA2NjQgMTguOTI5N0M0MS4xMDU1IDE4LjkzMjMgNDEuMTM2NyAxOC45MzM2IDQxLjE2MDIgMTguOTMzNkM0MS4zMTY0IDE4LjkzMzYgNDEuNDQ2NiAxOC45MTQxIDQxLjU1MDggMTguODc1QzQxLjY1NDkgMTguODM4NSA0MS43Mzk2IDE4Ljc3ODYgNDEuODA0NyAxOC42OTUzQzQxLjg3MjQgMTguNjEyIDQxLjkyOTcgMTguNSA0MS45NzY2IDE4LjM1OTRMNDIuMjgxMiAxNy41MzkxWk00MS42MzI4IDEzLjc3MzRMNDIuNjM2NyAxNi45Mzc1TDQyLjgwNDcgMTcuOTI5N0w0Mi4xNTIzIDE4LjA5NzdMNDAuNjE3MiAxMy43NzM0SDQxLjYzMjhaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik0yMC42MjExIDQ4LjkzNzVWNTAuNDg0NEwxMy44MDA4IDY2SDEwLjgyNDJMMTcuNjMyOCA1MS4xODc1SDguNzk2ODhWNDguOTM3NUgyMC42MjExWk0zNC4wNTA4IDU2LjAzOTFWNTguODI4MUMzNC4wNTA4IDYwLjE2NDEgMzMuOTE4IDYxLjMwNDcgMzMuNjUyMyA2Mi4yNUMzMy4zOTQ1IDYzLjE4NzUgMzMuMDE5NSA2My45NDkyIDMyLjUyNzMgNjQuNTM1MkMzMi4wMzUyIDY1LjEyMTEgMzEuNDQ1MyA2NS41NTA4IDMwLjc1NzggNjUuODI0MkMzMC4wNzgxIDY2LjA5NzcgMjkuMzE2NCA2Ni4yMzQ0IDI4LjQ3MjcgNjYuMjM0NEMyNy44MDA4IDY2LjIzNDQgMjcuMTc1OCA2Ni4xNDg0IDI2LjU5NzcgNjUuOTc2NkMyNi4wMjczIDY1LjgwNDcgMjUuNTExNyA2NS41MzUyIDI1LjA1MDggNjUuMTY4QzI0LjU4OTggNjQuODAwOCAyNC4xOTUzIDY0LjMyODEgMjMuODY3MiA2My43NUMyMy41NDY5IDYzLjE2NDEgMjMuMjk2OSA2Mi40NjQ4IDIzLjExNzIgNjEuNjUyM0MyMi45NDUzIDYwLjgzOTggMjIuODU5NCA1OS44OTg0IDIyLjg1OTQgNTguODI4MVY1Ni4wMzkxQzIyLjg1OTQgNTQuNjk1MyAyMi45OTIyIDUzLjU2MjUgMjMuMjU3OCA1Mi42NDA2QzIzLjUyMzQgNTEuNzEwOSAyMy45MDIzIDUwLjk1NyAyNC4zOTQ1IDUwLjM3ODlDMjQuODg2NyA0OS43OTMgMjUuNDcyNyA0OS4zNjcyIDI2LjE1MjMgNDkuMTAxNkMyNi44Mzk4IDQ4LjgzNTkgMjcuNjA1NSA0OC43MDMxIDI4LjQ0OTIgNDguNzAzMUMyOS4xMjg5IDQ4LjcwMzEgMjkuNzUzOSA0OC43ODkxIDMwLjMyNDIgNDguOTYwOUMzMC45MDIzIDQ5LjEyNSAzMS40MTggNDkuMzg2NyAzMS44NzExIDQ5Ljc0NjFDMzIuMzMyIDUwLjEwNTUgMzIuNzIyNyA1MC41NzQyIDMzLjA0MyA1MS4xNTIzQzMzLjM3MTEgNTEuNzIyNyAzMy42MjExIDUyLjQxNDEgMzMuNzkzIDUzLjIyNjZDMzMuOTY0OCA1NC4wMzEyIDM0LjA1MDggNTQuOTY4OCAzNC4wNTA4IDU2LjAzOTFaTTMxLjIyNjYgNTkuMjI2NlY1NS42MTcyQzMxLjIyNjYgNTQuOTM3NSAzMS4xODc1IDU0LjMzOTggMzEuMTA5NCA1My44MjQyQzMxLjAzMTIgNTMuMzAwOCAzMC45MTQxIDUyLjg1OTQgMzAuNzU3OCA1Mi41QzMwLjYwOTQgNTIuMTMyOCAzMC40MjE5IDUxLjgzNTkgMzAuMTk1MyA1MS42MDk0QzI5Ljk2ODggNTEuMzc1IDI5LjcxMDkgNTEuMjA3IDI5LjQyMTkgNTEuMTA1NUMyOS4xMzI4IDUwLjk5NjEgMjguODA4NiA1MC45NDE0IDI4LjQ0OTIgNTAuOTQxNEMyOC4wMTE3IDUwLjk0MTQgMjcuNjIxMSA1MS4wMjczIDI3LjI3NzMgNTEuMTk5MkMyNi45MzM2IDUxLjM2MzMgMjYuNjQ0NSA1MS42Mjg5IDI2LjQxMDIgNTEuOTk2MUMyNi4xNzU4IDUyLjM2MzMgMjUuOTk2MSA1Mi44NDc3IDI1Ljg3MTEgNTMuNDQ5MkMyNS43NTM5IDU0LjA0MyAyNS42OTUzIDU0Ljc2NTYgMjUuNjk1MyA1NS42MTcyVjU5LjIyNjZDMjUuNjk1MyA1OS45MTQxIDI1LjczNDQgNjAuNTE5NSAyNS44MTI1IDYxLjA0M0MyNS44OTA2IDYxLjU2NjQgMjYuMDA3OCA2Mi4wMTU2IDI2LjE2NDEgNjIuMzkwNkMyNi4zMjAzIDYyLjc1NzggMjYuNTA3OCA2My4wNjI1IDI2LjcyNjYgNjMuMzA0N0MyNi45NTMxIDYzLjUzOTEgMjcuMjEwOSA2My43MTA5IDI3LjUgNjMuODIwM0MyNy43OTY5IDYzLjkyOTcgMjguMTIxMSA2My45ODQ0IDI4LjQ3MjcgNjMuOTg0NEMyOC45MTggNjMuOTg0NCAyOS4zMTI1IDYzLjg5ODQgMjkuNjU2MiA2My43MjY2QzMwIDYzLjU1NDcgMzAuMjg5MSA2My4yODEyIDMwLjUyMzQgNjIuOTA2MkMzMC43NTc4IDYyLjUyMzQgMzAuOTMzNiA2Mi4wMjczIDMxLjA1MDggNjEuNDE4QzMxLjE2OCA2MC44MDg2IDMxLjIyNjYgNjAuMDc4MSAzMS4yMjY2IDU5LjIyNjZaIiBmaWxsPSIjM0ZBNzFBIi8+CjxwYXRoIGQ9Ik0zOS4xNTcyIDU4LjQ4NzNWNTcuOTYwOUMzOS4xNTcyIDU3LjU4MjcgMzkuMjM5MyA1Ny4yMzg2IDM5LjQwMzMgNTYuOTI4N0MzOS41Njc0IDU2LjYxODggMzkuODA2NiA1Ni4zNzA0IDQwLjEyMTEgNTYuMTgzNkM0MC40MzU1IDU1Ljk5NjcgNDAuODEzOCA1NS45MDMzIDQxLjI1NTkgNTUuOTAzM0M0MS43MTE2IDU1LjkwMzMgNDIuMDk0NCA1NS45OTY3IDQyLjQwNDMgNTYuMTgzNkM0Mi43MTg4IDU2LjM3MDQgNDIuOTU4IDU2LjYxODggNDMuMTIyMSA1Ni45Mjg3QzQzLjI4NjEgNTcuMjM4NiA0My4zNjgyIDU3LjU4MjcgNDMuMzY4MiA1Ny45NjA5VjU4LjQ4NzNDNDMuMzY4MiA1OC44NTY0IDQzLjI4NjEgNTkuMTk2IDQzLjEyMjEgNTkuNTA1OUM0Mi45NjI2IDU5LjgxNTggNDIuNzI1NiA2MC4wNjQxIDQyLjQxMTEgNjAuMjUxQzQyLjEwMTIgNjAuNDM3OCA0MS43MjA3IDYwLjUzMTIgNDEuMjY5NSA2MC41MzEyQzQwLjgyMjkgNjAuNTMxMiA0MC40NDAxIDYwLjQzNzggNDAuMTIxMSA2MC4yNTFDMzkuODA2NiA2MC4wNjQxIDM5LjU2NzQgNTkuODE1OCAzOS40MDMzIDU5LjUwNTlDMzkuMjM5MyA1OS4xOTYgMzkuMTU3MiA1OC44NTY0IDM5LjE1NzIgNTguNDg3M1pNNDAuMjk4OCA1Ny45NjA5VjU4LjQ4NzNDNDAuMjk4OCA1OC42Njk2IDQwLjMzMyA1OC44NDI4IDQwLjQwMTQgNTkuMDA2OEM0MC40NzQzIDU5LjE3MDkgNDAuNTgzNyA1OS4zMDMxIDQwLjcyOTUgNTkuNDAzM0M0MC44NzUzIDU5LjUwMzYgNDEuMDU1MyA1OS41NTM3IDQxLjI2OTUgNTkuNTUzN0M0MS40ODgzIDU5LjU1MzcgNDEuNjY2IDU5LjUwMzYgNDEuODAyNyA1OS40MDMzQzQxLjk0NCA1OS4zMDMxIDQyLjA0ODggNTkuMTcwOSA0Mi4xMTcyIDU5LjAwNjhDNDIuMTg1NSA1OC44NDI4IDQyLjIxOTcgNTguNjY5NiA0Mi4yMTk3IDU4LjQ4NzNWNTcuOTYwOUM0Mi4yMTk3IDU3Ljc3NDEgNDIuMTgzMyA1Ny41OTg2IDQyLjExMDQgNTcuNDM0NkM0Mi4wNDIgNTcuMjY2IDQxLjkzNzIgNTcuMTMxNSA0MS43OTU5IDU3LjAzMTJDNDEuNjU0NiA1Ni45MzEgNDEuNDc0NiA1Ni44ODA5IDQxLjI1NTkgNTYuODgwOUM0MS4wNDYyIDU2Ljg4MDkgNDAuODY4NSA1Ni45MzEgNDAuNzIyNyA1Ny4wMzEyQzQwLjU4MTQgNTcuMTMxNSA0MC40NzQzIDU3LjI2NiA0MC40MDE0IDU3LjQzNDZDNDAuMzMzIDU3LjU5ODYgNDAuMjk4OCA1Ny43NzQxIDQwLjI5ODggNTcuOTYwOVpNNDMuOTU2MSA2NC4wOTI4VjYzLjU1OTZDNDMuOTU2MSA2My4xODU5IDQ0LjAzODEgNjIuODQ0MSA0NC4yMDIxIDYyLjUzNDJDNDQuMzcwOCA2Mi4yMjQzIDQ0LjYxMjMgNjEuOTc1OSA0NC45MjY4IDYxLjc4OTFDNDUuMjQxMiA2MS42MDIyIDQ1LjYxOTUgNjEuNTA4OCA0Ni4wNjE1IDYxLjUwODhDNDYuNTE3MyA2MS41MDg4IDQ2LjkwMDEgNjEuNjAyMiA0Ny4yMSA2MS43ODkxQzQ3LjUyNDQgNjEuOTc1OSA0Ny43NjE0IDYyLjIyNDMgNDcuOTIwOSA2Mi41MzQyQzQ4LjA4NSA2Mi44NDQxIDQ4LjE2NyA2My4xODU5IDQ4LjE2NyA2My41NTk2VjY0LjA5MjhDNDguMTY3IDY0LjQ2NjUgNDguMDg1IDY0LjgwODMgNDcuOTIwOSA2NS4xMTgyQzQ3Ljc2MTQgNjUuNDI4MSA0Ny41MjY3IDY1LjY3NjQgNDcuMjE2OCA2NS44NjMzQzQ2LjkwNjkgNjYuMDUwMSA0Ni41Mjg2IDY2LjE0MzYgNDYuMDgyIDY2LjE0MzZDNDUuNjMwOSA2Ni4xNDM2IDQ1LjI0NTggNjYuMDUwMSA0NC45MjY4IDY1Ljg2MzNDNDQuNjEyMyA2NS42NzY0IDQ0LjM3MDggNjUuNDI4MSA0NC4yMDIxIDY1LjExODJDNDQuMDM4MSA2NC44MDgzIDQzLjk1NjEgNjQuNDY2NSA0My45NTYxIDY0LjA5MjhaTTQ1LjEwNDUgNjMuNTU5NlY2NC4wOTI4QzQ1LjEwNDUgNjQuMjc1MSA0NS4xNDEgNjQuNDQ4MiA0NS4yMTM5IDY0LjYxMjNDNDUuMjkxMyA2NC43NzY0IDQ1LjQwMyA2NC45MTA4IDQ1LjU0ODggNjUuMDE1NkM0NS42OTQ3IDY1LjExNTkgNDUuODcwMSA2NS4xNjYgNDYuMDc1MiA2NS4xNjZDNDYuMzA3NiA2NS4xNjYgNDYuNDk0NSA2NS4xMTU5IDQ2LjYzNTcgNjUuMDE1NkM0Ni43NzcgNjQuOTEwOCA0Ni44NzczIDY0Ljc3ODYgNDYuOTM2NSA2NC42MTkxQzQ3LjAwMDMgNjQuNDU1MSA0Ny4wMzIyIDY0LjI3OTYgNDcuMDMyMiA2NC4wOTI4VjYzLjU1OTZDNDcuMDMyMiA2My4zNzI3IDQ2Ljk5NTggNjMuMTk3MyA0Ni45MjI5IDYzLjAzMzJDNDYuODU0NSA2Mi44NjkxIDQ2Ljc0NzQgNjIuNzM3IDQ2LjYwMTYgNjIuNjM2N0M0Ni40NjAzIDYyLjUzNjUgNDYuMjgwMyA2Mi40ODYzIDQ2LjA2MTUgNjIuNDg2M0M0NS44NDczIDYyLjQ4NjMgNDUuNjY5NiA2Mi41MzY1IDQ1LjUyODMgNjIuNjM2N0M0NS4zODcgNjIuNzM3IDQ1LjI3OTkgNjIuODY5MSA0NS4yMDcgNjMuMDMzMkM0NS4xMzg3IDYzLjE5NzMgNDUuMTA0NSA2My4zNzI3IDQ1LjEwNDUgNjMuNTU5NlpNNDYuNDcxNyA1Ny40Njg4TDQxLjYxMTMgNjUuMjQ4TDQwLjc3MDUgNjQuNzYyN0w0NS42MzA5IDU2Ljk4MzRMNDYuNDcxNyA1Ny40Njg4WiIgZmlsbD0iIzNGQTcxQSIvPgo8cGF0aCBkPSJNMTg2LjM2OCAyN0MxNzAuNDM4IDI3IDE3Mi4zMjUgNTguMDk3OSAxNTUuNzU3IDU4LjA5NzlDMTQxLjczOCA1OC4wOTc5IDEzOS40OSA0NC44NzIzIDEyOC42NTcgNDQuODcyM0MxMTcuODI0IDQ0Ljg3MjMgMTEzLjA2MyA2MyA5OS4wNDM4IDYzQzg3LjU3MzYgNjMgODEuMjAxMyA1OS4zMjM0IDcwLjM2ODQgNTkuMzIzNCIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuODciIHN0cm9rZS13aWR0aD0iMS4zIi8+CjxyZWN0IHk9Ijg0IiB3aWR0aD0iMjAwIiBoZWlnaHQ9Ijc2IiByeD0iNCIgZmlsbD0id2hpdGUiLz4KPHJlY3QgeD0iMC41IiB5PSI4NC41IiB3aWR0aD0iMTk5IiBoZWlnaHQ9Ijc1IiByeD0iMy41IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC4xMiIvPgo8cGF0aCBkPSJNMTAuNTI3MyA5OC44MTY0TDEyLjAyMzQgOTQuMTY4SDEzLjEwNTVMMTEuMDY2NCA5OS44NTU1SDEwLjMzNTlMMTAuNTI3MyA5OC44MTY0Wk05LjE0NDUzIDk0LjE2OEwxMC42Mjg5IDk4LjgxNjRMMTAuODMyIDk5Ljg1NTVIMTAuMDk3N0w4LjA2NjQxIDk0LjE2OEg5LjE0NDUzWk0xNC45MDYyIDk1LjYyODlWOTkuODU1NUgxMy45NjA5Vjk1LjYyODlIMTQuOTA2MlpNMTMuODk4NCA5NC41MTk1QzEzLjg5ODQgOTQuMzc2MyAxMy45NDUzIDk0LjI1NzggMTQuMDM5MSA5NC4xNjQxQzE0LjEzNTQgOTQuMDY3NyAxNC4yNjgyIDk0LjAxOTUgMTQuNDM3NSA5NC4wMTk1QzE0LjYwNDIgOTQuMDE5NSAxNC43MzU3IDk0LjA2NzcgMTQuODMyIDk0LjE2NDFDMTQuOTI4NCA5NC4yNTc4IDE0Ljk3NjYgOTQuMzc2MyAxNC45NzY2IDk0LjUxOTVDMTQuOTc2NiA5NC42NjAyIDE0LjkyODQgOTQuNzc3MyAxNC44MzIgOTQuODcxMUMxNC43MzU3IDk0Ljk2NDggMTQuNjA0MiA5NS4wMTE3IDE0LjQzNzUgOTUuMDExN0MxNC4yNjgyIDk1LjAxMTcgMTQuMTM1NCA5NC45NjQ4IDE0LjAzOTEgOTQuODcxMUMxMy45NDUzIDk0Ljc3NzMgMTMuODk4NCA5NC42NjAyIDEzLjg5ODQgOTQuNTE5NVpNMTYuMTkxNCA5My44NTU1SDE3LjEzMjhWOTguOTUzMUwxNy4wNDMgOTkuODU1NUgxNi4xOTE0VjkzLjg1NTVaTTE5Ljg5MDYgOTcuNzAzMVY5Ny43ODUyQzE5Ljg5MDYgOTguMDk3NyAxOS44NTU1IDk4LjM4NTQgMTkuNzg1MiA5OC42NDg0QzE5LjcxNzQgOTguOTA4OSAxOS42MTMzIDk5LjEzNTQgMTkuNDcyNyA5OS4zMjgxQzE5LjMzNDYgOTkuNTIwOCAxOS4xNjI4IDk5LjY3MDYgMTguOTU3IDk5Ljc3NzNDMTguNzUzOSA5OS44ODE1IDE4LjUxODIgOTkuOTMzNiAxOC4yNSA5OS45MzM2QzE3Ljk4NyA5OS45MzM2IDE3Ljc1NzggOTkuODg0MSAxNy41NjI1IDk5Ljc4NTJDMTcuMzY3MiA5OS42ODYyIDE3LjIwMzEgOTkuNTQ1NiAxNy4wNzAzIDk5LjM2MzNDMTYuOTQwMSA5OS4xODEgMTYuODM0NiA5OC45NjM1IDE2Ljc1MzkgOTguNzEwOUMxNi42NzMyIDk4LjQ1ODMgMTYuNjE1OSA5OC4xNzk3IDE2LjU4MiA5Ny44NzVWOTcuNjEzM0MxNi42MTU5IDk3LjMwNiAxNi42NzMyIDk3LjAyNzMgMTYuNzUzOSA5Ni43NzczQzE2LjgzNDYgOTYuNTI0NyAxNi45NDAxIDk2LjMwNzMgMTcuMDcwMyA5Ni4xMjVDMTcuMjAzMSA5NS45NDAxIDE3LjM2NTkgOTUuNzk4MiAxNy41NTg2IDk1LjY5OTJDMTcuNzUzOSA5NS42MDAzIDE3Ljk4MTggOTUuNTUwOCAxOC4yNDIyIDk1LjU1MDhDMTguNTEzIDk1LjU1MDggMTguNzUxMyA5NS42MDI5IDE4Ljk1NyA5NS43MDdDMTkuMTY1NCA5NS44MTEyIDE5LjMzODUgOTUuOTU5NiAxOS40NzY2IDk2LjE1MjNDMTkuNjE0NiA5Ni4zNDI0IDE5LjcxNzQgOTYuNTY5IDE5Ljc4NTIgOTYuODMyQzE5Ljg1NTUgOTcuMDk1MSAxOS44OTA2IDk3LjM4NTQgMTkuODkwNiA5Ny43MDMxWk0xOC45NDkyIDk3Ljc4NTJWOTcuNzAzMUMxOC45NDkyIDk3LjUxMyAxOC45MzM2IDk3LjMzNDYgMTguOTAyMyA5Ny4xNjhDMTguODcxMSA5Ni45OTg3IDE4LjgxOSA5Ni44NTAzIDE4Ljc0NjEgOTYuNzIyN0MxOC42NzU4IDk2LjU5NTEgMTguNTc5NCA5Ni40OTQ4IDE4LjQ1NyA5Ni40MjE5QzE4LjMzNzIgOTYuMzQ2NCAxOC4xODc1IDk2LjMwODYgMTguMDA3OCA5Ni4zMDg2QzE3Ljg0MTEgOTYuMzA4NiAxNy42OTc5IDk2LjMzNzIgMTcuNTc4MSA5Ni4zOTQ1QzE3LjQ1ODMgOTYuNDUxOCAxNy4zNTgxIDk2LjUyOTkgMTcuMjc3MyA5Ni42Mjg5QzE3LjE5NjYgOTYuNzI3OSAxNy4xMzI4IDk2Ljg0MjQgMTcuMDg1OSA5Ni45NzI3QzE3LjA0MTcgOTcuMTAyOSAxNy4wMTE3IDk3LjI0MzUgMTYuOTk2MSA5Ny4zOTQ1Vjk4LjEwMTZDMTcuMDE5NSA5OC4yOTY5IDE3LjA2OSA5OC40NzY2IDE3LjE0NDUgOTguNjQwNkMxNy4yMjI3IDk4LjgwMjEgMTcuMzMyIDk4LjkzMjMgMTcuNDcyNyA5OS4wMzEyQzE3LjYxMzMgOTkuMTI3NiAxNy43OTQzIDk5LjE3NTggMTguMDE1NiA5OS4xNzU4QzE4LjE5MDEgOTkuMTc1OCAxOC4zMzcyIDk5LjE0MDYgMTguNDU3IDk5LjA3MDNDMTguNTc2OCA5OSAxOC42NzE5IDk4LjkwMjMgMTguNzQyMiA5OC43NzczQzE4LjgxNTEgOTguNjQ5NyAxOC44NjcyIDk4LjUwMTMgMTguODk4NCA5OC4zMzJDMTguOTMyMyA5OC4xNjI4IDE4Ljk0OTIgOTcuOTgwNSAxOC45NDkyIDk3Ljc4NTJaTTIxLjg4MjggOTYuNDMzNlY5OS44NTU1SDIwLjk0MTRWOTUuNjI4OUgyMS44Mzk4TDIxLjg4MjggOTYuNDMzNlpNMjMuMTc1OCA5NS42MDE2TDIzLjE2OCA5Ni40NzY2QzIzLjExMDcgOTYuNDY2MSAyMy4wNDgyIDk2LjQ1ODMgMjIuOTgwNSA5Ni40NTMxQzIyLjkxNTQgOTYuNDQ3OSAyMi44NTAzIDk2LjQ0NTMgMjIuNzg1MiA5Ni40NDUzQzIyLjYyMzcgOTYuNDQ1MyAyMi40ODE4IDk2LjQ2ODggMjIuMzU5NCA5Ni41MTU2QzIyLjIzNyA5Ni41NTk5IDIyLjEzNDEgOTYuNjI1IDIyLjA1MDggOTYuNzEwOUMyMS45NzAxIDk2Ljc5NDMgMjEuOTA3NiA5Ni44OTU4IDIxLjg2MzMgOTcuMDE1NkMyMS44MTkgOTcuMTM1NCAyMS43OTMgOTcuMjY5NSAyMS43ODUyIDk3LjQxOEwyMS41NzAzIDk3LjQzMzZDMjEuNTcwMyA5Ny4xNjggMjEuNTk2NCA5Ni45MjE5IDIxLjY0ODQgOTYuNjk1M0MyMS43MDA1IDk2LjQ2ODggMjEuNzc4NiA5Ni4yNjk1IDIxLjg4MjggOTYuMDk3N0MyMS45ODk2IDk1LjkyNTggMjIuMTIyNCA5NS43OTE3IDIyLjI4MTIgOTUuNjk1M0MyMi40NDI3IDk1LjU5OSAyMi42Mjg5IDk1LjU1MDggMjIuODM5OCA5NS41NTA4QzIyLjg5NzEgOTUuNTUwOCAyMi45NTgzIDk1LjU1NiAyMy4wMjM0IDk1LjU2NjRDMjMuMDkxMSA5NS41NzY4IDIzLjE0MTkgOTUuNTg4NSAyMy4xNzU4IDk1LjYwMTZaTTI2LjI1NzggOTkuMDA3OFY5Ni45OTIyQzI2LjI1NzggOTYuODQxMSAyNi4yMzA1IDk2LjcxMDkgMjYuMTc1OCA5Ni42MDE2QzI2LjEyMTEgOTYuNDkyMiAyNi4wMzc4IDk2LjQwNzYgMjUuOTI1OCA5Ni4zNDc3QzI1LjgxNjQgOTYuMjg3OCAyNS42Nzg0IDk2LjI1NzggMjUuNTExNyA5Ni4yNTc4QzI1LjM1ODEgOTYuMjU3OCAyNS4yMjUzIDk2LjI4MzkgMjUuMTEzMyA5Ni4zMzU5QzI1LjAwMTMgOTYuMzg4IDI0LjkxNDEgOTYuNDU4MyAyNC44NTE2IDk2LjU0NjlDMjQuNzg5MSA5Ni42MzU0IDI0Ljc1NzggOTYuNzM1NyAyNC43NTc4IDk2Ljg0NzdIMjMuODIwM0MyMy44MjAzIDk2LjY4MSAyMy44NjA3IDk2LjUxOTUgMjMuOTQxNCA5Ni4zNjMzQzI0LjAyMjEgOTYuMjA3IDI0LjEzOTMgOTYuMDY3NyAyNC4yOTMgOTUuOTQ1M0MyNC40NDY2IDk1LjgyMjkgMjQuNjMwMiA5NS43MjY2IDI0Ljg0MzggOTUuNjU2MkMyNS4wNTczIDk1LjU4NTkgMjUuMjk2OSA5NS41NTA4IDI1LjU2MjUgOTUuNTUwOEMyNS44ODAyIDk1LjU1MDggMjYuMTYxNSA5NS42MDQyIDI2LjQwNjIgOTUuNzEwOUMyNi42NTM2IDk1LjgxNzcgMjYuODQ3NyA5NS45NzkyIDI2Ljk4ODMgOTYuMTk1M0MyNy4xMzE1IDk2LjQwODkgMjcuMjAzMSA5Ni42NzcxIDI3LjIwMzEgOTdWOTguODc4OUMyNy4yMDMxIDk5LjA3MTYgMjcuMjE2MSA5OS4yNDQ4IDI3LjI0MjIgOTkuMzk4NEMyNy4yNzA4IDk5LjU0OTUgMjcuMzExMiA5OS42ODEgMjcuMzYzMyA5OS43OTNWOTkuODU1NUgyNi4zOTg0QzI2LjM1NDIgOTkuNzUzOSAyNi4zMTkgOTkuNjI1IDI2LjI5MyA5OS40Njg4QzI2LjI2OTUgOTkuMzA5OSAyNi4yNTc4IDk5LjE1NjIgMjYuMjU3OCA5OS4wMDc4Wk0yNi4zOTQ1IDk3LjI4NTJMMjYuNDAyMyA5Ny44NjcySDI1LjcyNjZDMjUuNTUyMSA5Ny44NjcyIDI1LjM5ODQgOTcuODg0MSAyNS4yNjU2IDk3LjkxOEMyNS4xMzI4IDk3Ljk0OTIgMjUuMDIyMSA5Ny45OTYxIDI0LjkzMzYgOTguMDU4NkMyNC44NDUxIDk4LjEyMTEgMjQuNzc4NiA5OC4xOTY2IDI0LjczNDQgOTguMjg1MkMyNC42OTAxIDk4LjM3MzcgMjQuNjY4IDk4LjQ3NCAyNC42NjggOTguNTg1OUMyNC42NjggOTguNjk3OSAyNC42OTQgOTguODAwOCAyNC43NDYxIDk4Ljg5NDVDMjQuNzk4MiA5OC45ODU3IDI0Ljg3MzcgOTkuMDU3MyAyNC45NzI3IDk5LjEwOTRDMjUuMDc0MiA5OS4xNjE1IDI1LjE5NjYgOTkuMTg3NSAyNS4zMzk4IDk5LjE4NzVDMjUuNTMyNiA5OS4xODc1IDI1LjcwMDUgOTkuMTQ4NCAyNS44NDM4IDk5LjA3MDNDMjUuOTg5NiA5OC45ODk2IDI2LjEwNDIgOTguODkxOSAyNi4xODc1IDk4Ljc3NzNDMjYuMjcwOCA5OC42NjAyIDI2LjMxNTEgOTguNTQ5NSAyNi4zMjAzIDk4LjQ0NTNMMjYuNjI1IDk4Ljg2MzNDMjYuNTkzOCA5OC45NzAxIDI2LjU0MDQgOTkuMDg0NiAyNi40NjQ4IDk5LjIwN0MyNi4zODkzIDk5LjMyOTQgMjYuMjkwNCA5OS40NDY2IDI2LjE2OCA5OS41NTg2QzI2LjA0ODIgOTkuNjY4IDI1LjkwMzYgOTkuNzU3OCAyNS43MzQ0IDk5LjgyODFDMjUuNTY3NyA5OS44OTg0IDI1LjM3NSA5OS45MzM2IDI1LjE1NjIgOTkuOTMzNkMyNC44ODAyIDk5LjkzMzYgMjQuNjM0MSA5OS44Nzg5IDI0LjQxOCA5OS43Njk1QzI0LjIwMTggOTkuNjU3NiAyNC4wMzI2IDk5LjUwNzggMjMuOTEwMiA5OS4zMjAzQzIzLjc4NzggOTkuMTMwMiAyMy43MjY2IDk4LjkxNTQgMjMuNzI2NiA5OC42NzU4QzIzLjcyNjYgOTguNDUxOCAyMy43NjgyIDk4LjI1MzkgMjMuODUxNiA5OC4wODJDMjMuOTM3NSA5Ny45MDc2IDI0LjA2MjUgOTcuNzYxNyAyNC4yMjY2IDk3LjY0NDVDMjQuMzkzMiA5Ny41MjczIDI0LjU5NjQgOTcuNDM4OCAyNC44MzU5IDk3LjM3ODlDMjUuMDc1NSA5Ny4zMTY0IDI1LjM0OSA5Ny4yODUyIDI1LjY1NjIgOTcuMjg1MkgyNi4zOTQ1Wk0zMC4zNzUgOTUuNjI4OVY5Ni4zMTY0SDI3Ljk5MjJWOTUuNjI4OUgzMC4zNzVaTTI4LjY3OTcgOTQuNTkzOEgyOS42MjExVjk4LjY4NzVDMjkuNjIxMSA5OC44MTc3IDI5LjYzOTMgOTguOTE4IDI5LjY3NTggOTguOTg4M0MyOS43MTQ4IDk5LjA1NiAyOS43NjgyIDk5LjEwMTYgMjkuODM1OSA5OS4xMjVDMjkuOTAzNiA5OS4xNDg0IDI5Ljk4MzEgOTkuMTYwMiAzMC4wNzQyIDk5LjE2MDJDMzAuMTM5MyA5OS4xNjAyIDMwLjIwMTggOTkuMTU2MiAzMC4yNjE3IDk5LjE0ODRDMzAuMzIxNiA5OS4xNDA2IDMwLjM2OTggOTkuMTMyOCAzMC40MDYyIDk5LjEyNUwzMC40MTAyIDk5Ljg0MzhDMzAuMzMyIDk5Ljg2NzIgMzAuMjQwOSA5OS44ODggMzAuMTM2NyA5OS45MDYyQzMwLjAzNTIgOTkuOTI0NSAyOS45MTggOTkuOTMzNiAyOS43ODUyIDk5LjkzMzZDMjkuNTY5IDk5LjkzMzYgMjkuMzc3NiA5OS44OTU4IDI5LjIxMDkgOTkuODIwM0MyOS4wNDQzIDk5Ljc0MjIgMjguOTE0MSA5OS42MTU5IDI4LjgyMDMgOTkuNDQxNEMyOC43MjY2IDk5LjI2NjkgMjguNjc5NyA5OS4wMzUyIDI4LjY3OTcgOTguNzQ2MVY5NC41OTM4Wk0zMi4zNTk0IDk1LjYyODlWOTkuODU1NUgzMS40MTQxVjk1LjYyODlIMzIuMzU5NFpNMzEuMzUxNiA5NC41MTk1QzMxLjM1MTYgOTQuMzc2MyAzMS4zOTg0IDk0LjI1NzggMzEuNDkyMiA5NC4xNjQxQzMxLjU4ODUgOTQuMDY3NyAzMS43MjE0IDk0LjAxOTUgMzEuODkwNiA5NC4wMTk1QzMyLjA1NzMgOTQuMDE5NSAzMi4xODg4IDk0LjA2NzcgMzIuMjg1MiA5NC4xNjQxQzMyLjM4MTUgOTQuMjU3OCAzMi40Mjk3IDk0LjM3NjMgMzIuNDI5NyA5NC41MTk1QzMyLjQyOTcgOTQuNjYwMiAzMi4zODE1IDk0Ljc3NzMgMzIuMjg1MiA5NC44NzExQzMyLjE4ODggOTQuOTY0OCAzMi4wNTczIDk1LjAxMTcgMzEuODkwNiA5NS4wMTE3QzMxLjcyMTQgOTUuMDExNyAzMS41ODg1IDk0Ljk2NDggMzEuNDkyMiA5NC44NzExQzMxLjM5ODQgOTQuNzc3MyAzMS4zNTE2IDk0LjY2MDIgMzEuMzUxNiA5NC41MTk1Wk0zMy40NjA5IDk3Ljc4OTFWOTcuNjk5MkMzMy40NjA5IDk3LjM5NDUgMzMuNTA1MiA5Ny4xMTIgMzMuNTkzOCA5Ni44NTE2QzMzLjY4MjMgOTYuNTg4NSAzMy44MDk5IDk2LjM2MDcgMzMuOTc2NiA5Ni4xNjhDMzQuMTQ1OCA5NS45NzI3IDM0LjM1MTYgOTUuODIxNiAzNC41OTM4IDk1LjcxNDhDMzQuODM4NSA5NS42MDU1IDM1LjExNDYgOTUuNTUwOCAzNS40MjE5IDk1LjU1MDhDMzUuNzMxOCA5NS41NTA4IDM2LjAwNzggOTUuNjA1NSAzNi4yNSA5NS43MTQ4QzM2LjQ5NDggOTUuODIxNiAzNi43MDE4IDk1Ljk3MjcgMzYuODcxMSA5Ni4xNjhDMzcuMDQwNCA5Ni4zNjA3IDM3LjE2OTMgOTYuNTg4NSAzNy4yNTc4IDk2Ljg1MTZDMzcuMzQ2NCA5Ny4xMTIgMzcuMzkwNiA5Ny4zOTQ1IDM3LjM5MDYgOTcuNjk5MlY5Ny43ODkxQzM3LjM5MDYgOTguMDkzOCAzNy4zNDY0IDk4LjM3NjMgMzcuMjU3OCA5OC42MzY3QzM3LjE2OTMgOTguODk3MSAzNy4wNDA0IDk5LjEyNSAzNi44NzExIDk5LjMyMDNDMzYuNzAxOCA5OS41MTMgMzYuNDk2MSA5OS42NjQxIDM2LjI1MzkgOTkuNzczNEMzNi4wMTE3IDk5Ljg4MDIgMzUuNzM3IDk5LjkzMzYgMzUuNDI5NyA5OS45MzM2QzM1LjExOTggOTkuOTMzNiAzNC44NDI0IDk5Ljg4MDIgMzQuNTk3NyA5OS43NzM0QzM0LjM1NTUgOTkuNjY0MSAzNC4xNDk3IDk5LjUxMyAzMy45ODA1IDk5LjMyMDNDMzMuODExMiA5OS4xMjUgMzMuNjgyMyA5OC44OTcxIDMzLjU5MzggOTguNjM2N0MzMy41MDUyIDk4LjM3NjMgMzMuNDYwOSA5OC4wOTM4IDMzLjQ2MDkgOTcuNzg5MVpNMzQuNDAyMyA5Ny42OTkyVjk3Ljc4OTFDMzQuNDAyMyA5Ny45NzkyIDM0LjQyMTkgOTguMTU4OSAzNC40NjA5IDk4LjMyODFDMzQuNSA5OC40OTc0IDM0LjU2MTIgOTguNjQ1OCAzNC42NDQ1IDk4Ljc3MzRDMzQuNzI3OSA5OC45MDEgMzQuODM0NiA5OS4wMDEzIDM0Ljk2NDggOTkuMDc0MkMzNS4wOTUxIDk5LjE0NzEgMzUuMjUgOTkuMTgzNiAzNS40Mjk3IDk5LjE4MzZDMzUuNjA0MiA5OS4xODM2IDM1Ljc1NTIgOTkuMTQ3MSAzNS44ODI4IDk5LjA3NDJDMzYuMDEzIDk5LjAwMTMgMzYuMTE5OCA5OC45MDEgMzYuMjAzMSA5OC43NzM0QzM2LjI4NjUgOTguNjQ1OCAzNi4zNDc3IDk4LjQ5NzQgMzYuMzg2NyA5OC4zMjgxQzM2LjQyODQgOTguMTU4OSAzNi40NDkyIDk3Ljk3OTIgMzYuNDQ5MiA5Ny43ODkxVjk3LjY5OTJDMzYuNDQ5MiA5Ny41MTE3IDM2LjQyODQgOTcuMzM0NiAzNi4zODY3IDk3LjE2OEMzNi4zNDc3IDk2Ljk5ODcgMzYuMjg1MiA5Ni44NDkgMzYuMTk5MiA5Ni43MTg4QzM2LjExNTkgOTYuNTg4NSAzNi4wMDkxIDk2LjQ4NyAzNS44Nzg5IDk2LjQxNDFDMzUuNzUxMyA5Ni4zMzg1IDM1LjU5OSA5Ni4zMDA4IDM1LjQyMTkgOTYuMzAwOEMzNS4yNDQ4IDk2LjMwMDggMzUuMDkxMSA5Ni4zMzg1IDM0Ljk2MDkgOTYuNDE0MUMzNC44MzMzIDk2LjQ4NyAzNC43Mjc5IDk2LjU4ODUgMzQuNjQ0NSA5Ni43MTg4QzM0LjU2MTIgOTYuODQ5IDM0LjUgOTYuOTk4NyAzNC40NjA5IDk3LjE2OEMzNC40MjE5IDk3LjMzNDYgMzQuNDAyMyA5Ny41MTE3IDM0LjQwMjMgOTcuNjk5MlpNMzkuMzYzMyA5Ni41MzEyVjk5Ljg1NTVIMzguNDIxOVY5NS42Mjg5SDM5LjMwODZMMzkuMzYzMyA5Ni41MzEyWk0zOS4xOTUzIDk3LjU4NTlMMzguODkwNiA5Ny41ODJDMzguODkzMiA5Ny4yODI2IDM4LjkzNDkgOTcuMDA3OCAzOS4wMTU2IDk2Ljc1NzhDMzkuMDk5IDk2LjUwNzggMzkuMjEzNSA5Ni4yOTMgMzkuMzU5NCA5Ni4xMTMzQzM5LjUwNzggOTUuOTMzNiAzOS42ODQ5IDk1Ljc5NTYgMzkuODkwNiA5NS42OTkyQzQwLjA5NjQgOTUuNjAwMyA0MC4zMjU1IDk1LjU1MDggNDAuNTc4MSA5NS41NTA4QzQwLjc4MTIgOTUuNTUwOCA0MC45NjQ4IDk1LjU3OTQgNDEuMTI4OSA5NS42MzY3QzQxLjI5NTYgOTUuNjkxNCA0MS40Mzc1IDk1Ljc4MTIgNDEuNTU0NyA5NS45MDYyQzQxLjY3NDUgOTYuMDMxMiA0MS43NjU2IDk2LjE5NCA0MS44MjgxIDk2LjM5NDVDNDEuODkwNiA5Ni41OTI0IDQxLjkyMTkgOTYuODM1OSA0MS45MjE5IDk3LjEyNVY5OS44NTU1SDQwLjk3NjZWOTcuMTIxMUM0MC45NzY2IDk2LjkxOCA0MC45NDY2IDk2Ljc1NzggNDAuODg2NyA5Ni42NDA2QzQwLjgyOTQgOTYuNTIwOCA0MC43NDQ4IDk2LjQzNjIgNDAuNjMyOCA5Ni4zODY3QzQwLjUyMzQgOTYuMzM0NiA0MC4zODY3IDk2LjMwODYgNDAuMjIyNyA5Ni4zMDg2QzQwLjA2MTIgOTYuMzA4NiAzOS45MTY3IDk2LjM0MjQgMzkuNzg5MSA5Ni40MTAyQzM5LjY2MTUgOTYuNDc3OSAzOS41NTM0IDk2LjU3MDMgMzkuNDY0OCA5Ni42ODc1QzM5LjM3ODkgOTYuODA0NyAzOS4zMTI1IDk2Ljk0MDEgMzkuMjY1NiA5Ny4wOTM4QzM5LjIxODggOTcuMjQ3NCAzOS4xOTUzIDk3LjQxMTUgMzkuMTk1MyA5Ny41ODU5WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8ZyBjbGlwLXBhdGg9InVybCgjY2xpcDBfNDY3M185Nzc0KSI+CjxwYXRoIGQ9Ik00NC44ODgzIDEyMi41NzdMMTcuNDk2MSAxNDIuMzExTDggMTQ4LjM3MVYxNTUuMTQ1SDE5MlYxNDYuMjM3VjEzNi45ODdMMTc4LjM4OSAxNTEuMDI3TDE3MC44MDMgMTQyLjI0NkwxNjcuNTExIDE0NC44MjhMMTUzLjQ0MSAxNTQuNTE1QzE0NC4wNjIgMTQzLjk3OSAxMzYuMjQ2IDE0MC4wNDggMTI2LjAzOCAxMjkuNjk2TDExNS41MDUgMTQ1LjE1NkgxMDEuOTU5TDk5LjM0MjYgMTIwLjAzNEw5NC4wNzI4IDExMC4xNDVMNjguOTk3OSAxMzQuODE4TDYzLjAzNTQgMTM0LjA0M0w0NC44ODgzIDEyMi41NzdaIiBmaWxsPSJ1cmwoI3BhaW50MF9saW5lYXJfNDY3M185Nzc0KSIvPgo8cGF0aCBkPSJNOCAxNDguMDI3TDE3LjU4NTQgMTQyLjAyMkw0NS4yMzUzIDEyMi40NjVMNjMuNTUzMSAxMzMuODI5TDY5LjU3MTcgMTM0LjU5Nkw5NCAxMTFMOTkuNSAxMTkuOTY2QzEwMC41MzEgMTI5LjY4OSAxMDAuOTY5IDEzNS4xMTkgMTAyIDE0NC44NDFIMTE1LjVMMTI2IDEyOS4zOTVMMTUzLjUgMTU0LjE0NUwxNzAuOTUyIDE0MS45OTRMMTc4LjUgMTUwLjYwOUwxOTIgMTM2LjgzOSIgc3Ryb2tlPSJ1cmwoI3BhaW50MV9saW5lYXJfNDY3M185Nzc0KSIgc3Ryb2tlLXdpZHRoPSIwLjkwMTMxOCIvPgo8L2c+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMV80NjczXzk3NzQpIj4KPHBhdGggZD0iTTggMTEwLjY0NUwxOTIgMTEwLjY0NSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiIHN0cm9rZS13aWR0aD0iMC4yOTQ5OTUiLz4KPHBhdGggZD0iTTggMTIwLjM5NUwxOTIgMTIwLjM5NSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiIHN0cm9rZS13aWR0aD0iMC4yOTQ5OTUiLz4KPHBhdGggZD0iTTggMTMwLjE0NUwxOTIgMTMwLjE0NSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiIHN0cm9rZS13aWR0aD0iMC4yOTQ5OTUiLz4KPHBhdGggZD0iTTggMTM5Ljg5NUwxOTIgMTM5Ljg5NSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiIHN0cm9rZS13aWR0aD0iMC4yOTQ5OTUiLz4KPHBhdGggZD0iTTggMTQ5LjY0NUwxOTIgMTQ5LjY0NSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiIHN0cm9rZS13aWR0aD0iMC4yOTQ5OTUiLz4KPC9nPgo8ZGVmcz4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyXzQ2NzNfOTc3NCIgeDE9Ijk1LjU4ODkiIHkxPSIxNjAuMzI1IiB4Mj0iOTUuNTg4OSIgeTI9Ijk4LjMzODciIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KPHN0b3Agb2Zmc2V0PSIwLjE3MzM4OSIgc3RvcC1jb2xvcj0iIzZGQ0M0QyIvPgo8c3RvcCBvZmZzZXQ9IjAuMzI3MDc0IiBzdG9wLWNvbG9yPSIjNzBDQzRDIi8+CjxzdG9wIG9mZnNldD0iMC4zMzE2NTYiIHN0b3AtY29sb3I9IiNGRUJBM0QiLz4KPHN0b3Agb2Zmc2V0PSIwLjQ4MDg3NiIgc3RvcC1jb2xvcj0iI0ZFQkEzRCIvPgo8c3RvcCBvZmZzZXQ9IjAuNDkxMjQyIiBzdG9wLWNvbG9yPSIjRkQ4RjNDIi8+CjxzdG9wIG9mZnNldD0iMC42Mzc0MzEiIHN0b3AtY29sb3I9IiNGRDhGM0MiLz4KPHN0b3Agb2Zmc2V0PSIwLjY0NTY0OSIgc3RvcC1jb2xvcj0iI0Y1NjE0OCIvPgo8L2xpbmVhckdyYWRpZW50Pgo8bGluZWFyR3JhZGllbnQgaWQ9InBhaW50MV9saW5lYXJfNDY3M185Nzc0IiB4MT0iOTQuNzg2MiIgeTE9IjE1NC41MzciIHgyPSI5NC43NzY0IiB5Mj0iOTguNDQ0OSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBvZmZzZXQ9IjAuMjU4NDUyIiBzdG9wLWNvbG9yPSIjM0ZBNzFBIi8+CjxzdG9wIG9mZnNldD0iMC4yNjk2MDEiIHN0b3AtY29sb3I9IiNGRkE2MDAiLz4KPHN0b3Agb2Zmc2V0PSIwLjQyNzI1OSIgc3RvcC1jb2xvcj0iI0ZGQTYwMCIvPgo8c3RvcCBvZmZzZXQ9IjAuNDQzODQ3IiBzdG9wLWNvbG9yPSIjRjU2RTA4Ii8+CjxzdG9wIG9mZnNldD0iMC42MDM1NDciIHN0b3AtY29sb3I9IiNGNTZFMDgiLz4KPHN0b3Agb2Zmc2V0PSIwLjYwNjQ2NyIgc3RvcC1jb2xvcj0iI0ZGQTYwMCIvPgo8c3RvcCBvZmZzZXQ9IjAuNjExNTkxIiBzdG9wLWNvbG9yPSIjRjA0MDIyIi8+CjwvbGluZWFyR3JhZGllbnQ+CjxjbGlwUGF0aCBpZD0iY2xpcDBfNDY3M185Nzc0Ij4KPHJlY3Qgd2lkdGg9IjE4NCIgaGVpZ2h0PSI0NSIgZmlsbD0id2hpdGUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDggMTEwLjE0NSkiLz4KPC9jbGlwUGF0aD4KPGNsaXBQYXRoIGlkPSJjbGlwMV80NjczXzk3NzQiPgo8cmVjdCB3aWR0aD0iMTg0IiBoZWlnaHQ9IjQ1IiBmaWxsPSJ3aGl0ZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoOCAxMTAuMTQ1KSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo=", - "description": "Contains widgets displaying industrial telemetry.", - "order": 9500, - "externalId": null, - "name": "Industrial widgets" - }, - "widgetTypeFqns": [ - "flow_rate_card", - "flow_rate_card_with_background", - "horizontal_flow_rate_card", - "horizontal_flow_rate_card_with_background", - "flow_rate_chart_card", - "flow_rate_chart_card_with_background", - "simple_flow_rate_chart_card", - "simple_flow_rate_chart_card_with_background", - "flow_rate_progress_bar", - "flow_rate_progress_bar_with_background", - "flow_rate_range_chart", - "flow_rate_range_chart_with_background", - "flow_rate_gauge", - "fluid_pressure_card", - "fluid_pressure_card_with_background", - "horizontal_fluid_pressure_card", - "horizontal_fluid_pressure_card_with_background", - "fluid_pressure_chart_card", - "fluid_pressure_chart_card_with_background", - "simple_fluid_pressure_chart_card", - "simple_fluid_pressure_chart_card_with_background", - "fluid_pressure_progress_bar", - "fluid_pressure_progress_bar_with_background", - "fluid_pressure_range_chart", - "fluid_pressure_range_chart_with_background", - "fluid_pressure_gauge", - "pump_vibration_card", - "pump_vibration_card_with_background", - "horizontal_pump_vibration_card", - "horizontal_pump_vibration_card_with_background", - "pump_vibration_chart_card", - "pump_vibration_chart_card_with_background", - "simple_pump_vibration_chart_card", - "simple_pump_vibration_chart_card_with_background", - "pump_vibration_range_chart", - "pump_vibration_range_chart_with_background", - "power_consumption_card", - "power_consumption_card_with_background", - "horizontal_power_consumption_card", - "horizontal_power_consumption_card_with_background", - "power_consumption_chart_card", - "power_consumption_chart_card_with_background", - "simple_power_consumption_chart_card", - "simple_power_consumption_chart_card_with_background", - "power_consumption_range_chart", - "power_consumption_range_chart_with_background", - "rotational_speed_card", - "rotational_speed_card_with_background", - "horizontal_rotational_speed_card", - "horizontal_rotational_speed_card_with_background", - "rotational_speed_chart_card", - "rotational_speed_chart_card_with_background", - "simple_rotational_speed_chart_card", - "simple_rotational_speed_chart_card_with_background", - "rotational_speed_progress_bar", - "rotational_speed_progress_bar_with_background", - "rotational_speed_range_chart", - "rotational_speed_range_chart_with_background", - "rotational_speed_gauge", - "efficiency_card", - "efficiency_card_with_background", - "horizontal_efficiency_card", - "horizontal_efficiency_card_with_background", - "efficiency_chart_card", - "efficiency_chart_card_with_background", - "simple_efficiency_chart_card", - "simple_efficiency_chart_card_with_background", - "efficiency_progress_bar", - "efficiency_progress_bar_with_background", - "efficiency_range_chart", - "efficiency_range_chart_with_background" - ] -} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/outdoor_environment.json b/application/src/main/data/json/system/widget_bundles/outdoor_environment.json deleted file mode 100644 index c7705c5e91..0000000000 --- a/application/src/main/data/json/system/widget_bundles/outdoor_environment.json +++ /dev/null @@ -1,190 +0,0 @@ -{ - "widgetsBundle": { - "alias": "outdoor_environment", - "title": "Outdoor Environment", - "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMkAAAChCAMAAABav2sAAAABa1BMVEUAAAAAAADf39/v7+/19fXw8PD9/f3y8vLAwMAAAAD5+fn39/f////39/fz8/MjTMf5+fnj4+OQkJDHx8esrKzb29t0dHTn5+f29vbx8fH9/f3f8Mqv2XuCgoKAwyzV1dVYWFjU1NTk6fju7u65ubnIycjh4eH4+fjOzs78/PxmZmb7+/s+Ys7/pgDy9PvI0vGenp4wV8utvOrCwsL/vEDg4OCRpeP/9N/c3Nw/Ys7l6vrv9+V1j9yxwfCDmt+dnZ3W3fW6x+6su+rI5aP/zXD3+/Kcr+n/6b+7u7vP6K+/4ZVaedX/78/P6a9KSkqQykb/sSD/qxDy9f3L1vX/+u9mg91aeNVMbdH/5K//x2A8PDzY4Pekt+6RpuP/0oCg0mG+zPPp6en/2JCYzlNLS0v/+e/r6+umpqb/tzDs8OuLoul3k+Xn9NjX7L3/3aDA4Za33Yio1m7/wk8vLy8gICDn9NfY7L3/15CIxzpPyZjhAAAADHRSTlMACyC+76/x7yoI78V/Zk47AAAKpUlEQVR42uybW2/TMBiGJxjazTsnduKYXERpjkSl564dSNXYEGKTNqFN4jCQQAKBgBsuAIm/z+ekXelYUena0Y4+7WLHdWc/sj8n9bq1tRs3N9jccG/dWCN0K+ubc+N23sqNDcyVjRu5yDrmyjq1cgtz5iaJXEkrLubMhja5jTmzvrbGMGeYNtnEnNlcmaxMhszbpFavXQ+TsGJUzlRsIRq4EPcSJjEIN4bvYwCnMtsXcGdostesGw/Rx33Q4K7NuRX7sWtZMbjNfYtOURa+y3kcg55/aSK+ewC4A9u2XHrYvm7BtR1q5ZVPBTMyaVXC+2cm1BhKcclyvLLXcOwSL/ll4fkNz3ZsYdl2KbYbnvV3Ju6TxjcX4K+cku1wh7+yXlllSzhkUuLveTm2Z2ZSb46YvBLUZ0tQW1zY70VDWLysPXITOJ7w/9Kk5DxxizHJTUqghyXKEJbOemU+M5Pa6JgIZ2DilVxPkINVKjXsslUuk4nlee7fmdBbLPRNHjhPBiaUJZMnFh1mZdLs0ZhchMOvYu0S9qxMuoZhdHERPq7CxMaMTEJDs3sNric7hmYHBQHAwEwJ0wQ9MjCmKMsyeuZlxLQmWUbvZ/oXMLNDB53RWRWh31xiElOb9EZMMkQH6YFqI1MySZK2OpBZckDlZpKgE8s0SLIpTZiChEwClcgslSaJZR3VyWTQSSWC1E8ZAtVW6dQmdUPTQkEi/SRIZAKZybafHARJmrWz3KSNpC39IEumNEEQKCWlNFMmlWSgrEolQefoZCqgIr+t2OwjnqkLyny2uHEChNflDlJzJSYMEQAzz+mMDvflNDlAm6UskhGylJmRUmmimGJpunQm7SRQFPRKQke8lKpDP5TKpTNRqYrSiEAqTakUIy9mqiBZOpMxqOi6mCxhxAMrk5XJyuS/N9nfOv708ilwtKV5tLwmX6rHj15Wt4DT6qflNtmqbheHd9V9/BnXQ2yBuwtq8pRE8LV6hDdkM5Y77A58V8BxYD3gi2miXfb17KIpVj0ZN7k+J5/ByUR4LkQ8ucluq1VHQa3V2qvhjHor7JfvtVq7MzGhwah+2gYeVR+9O66+wYUk5QSAC9fVyaQm4aFBVPZAvB5mi9OmUZQ38xfCWZhs7787OX4KzYeTE1yMNU3EV4zKTrdiNGtAyzB63Rdn22E14zF2KsXW0vOdZ1QnnIEJ9HjsD+Iff8A0EdFxUhPqfQiETeo1Xhg9AIeDTaTQqONxM6/SLf7y8uzSJi+38gl2iq2XAI5PgNPTcaEfpWZgRj6b0KRnPAe0xA6NAXUdeGxUUHBY6TV3KCmqoEsDd0kTGo43R/vHJ9s0HO+2H1VJ5+jH0zEinQ6TEVI5oUktrBVz7DHqNDzFKKEg7D3vAhhsK+12u5c2+fC1Wq2enALbx5TZ2qaS0y9jZxdjACYdk+EcG5hQUsPoVvJDFMwm4o+KQTjaP+qXzPAaTyHQxS8m4XmThb6DDA3NQOQ5ltjkvgZErWK8qI2Y4BImkamYYiCuymTIi/41b7cw2TtngmZ/Wd7t9SaIeMbyTS7iyk16fRG9Cu/li+2L8xWaw3QiEz8COpga33IxpDOpyevhpfvQOMyDZgcj0HR7rZOm0fubfeHpRUTseY7NUWBOaEJzqXk/J9TTq/Ks+fv9VZfqHN6nF2tXEfHcK3wcx4Zm8ruVASFQr1D6PPy9ki5vdmuY2sTmv5WIRjxWpMDyHGvqz4zh7sXdrdXDy6zCfKRPhFW2uP+gca6WIMouhri24y/WZ0YHEB7OcD0BjRjVK7kL+Tl+8xc+fqSDLN3rnz5wZD/nvN0cIqj0Au5igcakhBwh+hMLA3gZw7xY/L0V20YBL/F8Yg2JG+jjlhZ2l0g4juNxEM4vAeLwc7UG5x5fRJOUoYA7woLtYzxuf62KxULu3KkOEA3W0bLAn/AdoXEwDvYvTfwUkOaE36l0uWZ8JfnP4+SMa7pXvzJZmVzCJAXyL6fB1BkzoLwyTSjZAVheuCwmEipRSRRAMkkkUaaC4AAqI6dASl+m0ZKY+EoGKkgDpCl1O2unqcpSBcUOEAUy8FPJlsTkF1iE86RyeeJkhKWO+CErk//BZANzxtUm65gzt9fWbmLO3CKRK2jlOv237NqNWy6bGxs3cxHdyu3NubFOraxY8bOdu31tGgjgOL4hlM39ruQuMU2ujUlIqjV5UyhCaSl7USo40EGHb9zAyWSCD6j4gH++l05Bsx6X25pym/1At3TrMr67XtJu690Ku9u12V3bagLCzl4D9bmzU4TcRa3uLlJ2GqhVY2ddaxbcQc321rOawNYu6tbYXstju61t1G5TUtmmRNPtLNlH6odhHwtOCNhBir91U1xBqcRmjOJa1CUuzfrMTn3bocUKAvAzPyiuIE1BxTuHObiCUglLw+xi3QP/Ytc2bJra1NHoU5U4/SyIENHICRgTJQhcKq64xboEUZg5LFhJSd9xWVDsTuzYDvbDCJGd2eICTfIS+jzNMlHiUtelzAYNaRS6iNyApW9YP6DOSkpY6jPbjlhKXQYnWpQwvGR9aJKX4CvdDy5KnCyy4UdiUN64/WJdgoDt21m2khK7uIh92/tBZAduGAXPRUkgstSUJXLUxXUZchT2Ka7LkJIV2JRo+r9LWkigweCSwYMeNBhc0otvS0li3ZZ7l6ZNiSbdEs5zy39sEYtzVGRkiSUSct57S0QQITkqMbCkaeUoxL0mBO4RjgqMK7EsjhJOPKgZVsKJhWU8CyqGlVgcS/E8h4JRJfyx7Db0/LFqVIwq8fwWlkvdb+eKFJNKLAsy1HVxc0q4/AY2AwNXpJRKZp+AD50pMBaXttiQqKFEftpgfcUNlpS0O/eBs3HnY7tzCLyaddqQWHlJ7kGC2VjgHqTKJcJ9jF/h2QFw+HvjO2btMXTpl3gcywUUC+VjtHbJpx/P2p/H0xnU9Euks+ToFMJ8JLtNpZLZIaaLktkZDqZ4+vTZwccP06fQoF1S/nmP3p88ETkvJqVxwxLyEhx8ORsXJfgsNoqS4k0tJdL5Pn/x7gTApFRCcpTISxZmpQ0t+iVJs2nh38+O3j9ZVpLnxeqUy5lwPul146RUcvru6MVodKmEW90mJEwoieP00mSenMyPRxAx/xAlCSRMKGk2W+CDAdQGg1bVe9e0o2X6UCWvOON5fA9q90IA1Up+3tdy6ClVPnbhsvml3rxyybitZfxIhV+x5Hhyenx0NCnNE4tXLiFJa0iGuD7ikYSIXV11TI4wmZyOnhyXSqqPyWCAbhzj+pJhHA+7w/iKZ0bMMRodz09KY6Jxju/Gifj+WIHhMO7GhLZkJfIpwGxI6JQkQw8JxQqQhCbUG2o8FsaCH9iKWgkTzieFP7OZZhRSBJAzp2QxU0LXh4zi6a9JJZbvMMjlHgfkDCoBcQNIqQ7BMKmEv6YcEjnym/NbIiB/3YJE61z15UaV4LUHCdKCglkl0ue3FuFQMK2EN3OOstzLoWRaCZATXvqAR1CBeSXFuFiLgRkU26TqH+dMLAF4TojX7BOBV+0ws2ShFzahweCSVmxBg8Elmv9KZHCJpk2Jpk2Jhk2Jpk2JhvWVrGU1ga3dBuq2u6Y1C/ZQszu36dWya1pNYKfOsW/siRC5G/Sq8u0t4ReWmHw+zwnmiQAAAABJRU5ErkJggg==", - "description": "Contains widgets displaying outdoor environment telemetry.", - "order": 11000, - "externalId": null, - "name": "Outdoor Environment" - }, - "widgetTypeFqns": [ - "temperature_card", - "temperature_card_with_background", - "horizontal_temperature_card", - "horizontal_temperature_card_with_background", - "temperature_chart_card", - "temperature_chart_card_with_background", - "simple_temperature_chart_card", - "simple_temperature_chart_card_with_background", - "temperature_range_chart", - "temperature_range_chart_with_background", - "temperature_gauge", - "humidity_card", - "humidity_card_with_background", - "horizontal_humidity_card", - "horizontal_humidity_card_with_background", - "humidity_chart_card", - "humidity_chart_card_with_background", - "simple_humidity_chart_card", - "simple_humidity_chart_card_with_background", - "humidity_progress_bar", - "humidity_progress_bar_with_background", - "pressure_card", - "pressure_card_with_background", - "horizontal_pressure_card", - "horizontal_pressure_card_with_background", - "pressure_chart_card", - "pressure_chart_card_with_background", - "simple_pressure_chart_card", - "simple_pressure_chart_card_with_background", - "pressure_progress_bar", - "pressure_progress_bar_with_background", - "wind_speed_card", - "wind_speed_card_with_background", - "horizontal_wind_speed_card", - "horizontal_wind_speed_card_with_background", - "wind_speed_chart_card", - "wind_speed_chart_card_with_background", - "simple_wind_speed_chart_card", - "simple_wind_speed_chart_card_with_background", - "wind_speed_and_direction", - "wind_speed_and_direction_with_background", - "rainfall_card", - "rainfall_card_with_background", - "horizontal_rainfall_card", - "horizontal_rainfall_card_with_background", - "rainfall_chart_card", - "rainfall_chart_card_with_background", - "simple_rainfall_chart_card", - "simple_rainfall_chart_card_with_background", - "solar_radiation_card", - "solar_radiation_card_with_background", - "horizontal_solar_radiation_card", - "horizontal_solar_radiation_card_with_background", - "solar_radiation_chart_card", - "solar_radiation_chart_card_with_background", - "simple_solar_radiation_chart_card", - "simple_solar_radiation_chart_card_with_background", - "uv_index_card", - "uv_index_card_with_background", - "horizontal_uv_index_card", - "horizontal_uv_index_card_with_background", - "uv_index_chart_card", - "uv_index_chart_card_with_background", - "simple_uv_index_chart_card", - "simple_uv_index_chart_card_with_background", - "air_quality_card", - "air_quality_card_with_background", - "horizontal_air_quality_card", - "horizontal_air_quality_card_with_background", - "air_quality_chart_card", - "air_quality_chart_card_with_background", - "simple_air_quality_chart_card", - "simple_air_quality_chart_card_with_background", - "visibility_card", - "visibility_card_with_background", - "horizontal_visibility_card", - "horizontal_visibility_card_with_background", - "visibility_chart_card", - "visibility_chart_card_with_background", - "simple_visibility_chart_card", - "simple_visibility_chart_card_with_background", - "ground_temperature_card", - "ground_temperature_card_with_background", - "horizontal_ground_temperature_card", - "horizontal_ground_temperature_card_with_background", - "ground_temperature_chart_card", - "ground_temperature_chart_card_with_background", - "simple_ground_temperature_chart_card", - "simple_ground_temperature_chart_card_with_background", - "soil_moisture_card", - "soil_moisture_card_with_background", - "horizontal_soil_moisture_card", - "horizontal_soil_moisture_card_with_background", - "soil_moisture_chart_card", - "soil_moisture_chart_card_with_background", - "simple_soil_moisture_chart_card", - "simple_soil_moisture_chart_card_with_background", - "soil_moisture_progress_bar", - "soil_moisture_progress_bar_with_background", - "noise_level_card", - "noise_level_card_with_background", - "horizontal_noise_level_card", - "horizontal_noise_level_card_with_background", - "noise_level_chart_card", - "noise_level_chart_card_with_background", - "simple_noise_level_chart_card", - "simple_noise_level_chart_card_with_background", - "vibration_card", - "vibration_card_with_background", - "horizontal_vibration_card", - "horizontal_vibration_card_with_background", - "vibration_chart_card", - "vibration_chart_card_with_background", - "simple_vibration_chart_card", - "simple_vibration_chart_card_with_background", - "leaf_wetness_card", - "leaf_wetness_card_with_background", - "horizontal_leaf_wetness_card", - "horizontal_leaf_wetness_card_with_background", - "leaf_wetness_chart_card", - "leaf_wetness_chart_card_with_background", - "simple_leaf_wetness_chart_card", - "simple_leaf_wetness_chart_card_with_background", - "leaf_wetness_progress_bar", - "leaf_wetness_progress_bar_with_background", - "snow_depth_card", - "snow_depth_card_with_background", - "horizontal_snow_depth_card", - "horizontal_snow_depth_card_with_background", - "snow_depth_chart_card", - "snow_depth_chart_card_with_background", - "simple_snow_depth_chart_card", - "simple_snow_depth_chart_card_with_background", - "co2_card", - "co2_card_with_background", - "horizontal_co2_card", - "horizontal_co2_card_with_background", - "co2_chart_card", - "co2_chart_card_with_background", - "simple_co2_chart_card", - "simple_co2_chart_card_with_background", - "flooding_level_card", - "flooding_level_card_with_background", - "horizontal_flooding_level_card", - "horizontal_flooding_level_card_with_background", - "flooding_level_chart_card", - "flooding_level_chart_card_with_background", - "simple_flooding_level_chart_card", - "simple_flooding_level_chart_card_with_background", - "flooding_level_progress_bar", - "flooding_level_progress_bar_with_background", - "illuminance_card", - "illuminance_card_with_background", - "horizontal_illuminance_card", - "horizontal_illuminance_card_with_background", - "illuminance_chart_card", - "illuminance_chart_card_with_background", - "simple_illuminance_chart_card", - "simple_illuminance_chart_card_with_background", - "illuminance_progress_bar", - "illuminance_progress_bar_with_background", - "pm2_5_card", - "pm2_5_card_with_background", - "horizontal_pm2_5_card", - "horizontal_pm2_5_card_with_background", - "pm2_5_chart_card", - "pm2_5_chart_card_with_background", - "simple_pm2_5_chart_card", - "simple_pm2_5_chart_card_with_background", - "pm10_card", - "pm10_card_with_background", - "horizontal_pm10_card", - "horizontal_pm10_card_with_background", - "pm10_chart_card", - "pm10_chart_card_with_background", - "simple_pm10_chart_card", - "simple_pm10_chart_card_with_background" - ] -} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/air_quality_index_card.json b/application/src/main/data/json/system/widget_types/air_quality_index_card.json index 9dc9a99194..11690f4387 100644 --- a/application/src/main/data/json/system/widget_types/air_quality_index_card.json +++ b/application/src/main/data/json/system/widget_types/air_quality_index_card.json @@ -1,7 +1,7 @@ { "fqn": "air_quality_card", "name": "Air quality index card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/air_quality_index_card_system_widget_image.png", "description": "Displays the latest air quality index telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/air_quality_index_card_with_background.json b/application/src/main/data/json/system/widget_types/air_quality_index_card_with_background.json index 5bf555d92c..27b8f82118 100644 --- a/application/src/main/data/json/system/widget_types/air_quality_index_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/air_quality_index_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "air_quality_card_with_background", "name": "Air quality index card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/air_quality_index_card_with_background_system_widget_image.png", "description": "Displays the latest air quality index telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/air_quality_index_chart_card.json b/application/src/main/data/json/system/widget_types/air_quality_index_chart_card.json index 58ba002255..95058ecf29 100644 --- a/application/src/main/data/json/system/widget_types/air_quality_index_chart_card.json +++ b/application/src/main/data/json/system/widget_types/air_quality_index_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "air_quality_chart_card", "name": "Air quality index chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/air_quality_index_chart_card_system_widget_image.png", "description": "Displays air quality index data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/air_quality_index_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/air_quality_index_chart_card_with_background.json index 43ddb147ac..50bf9e06ec 100644 --- a/application/src/main/data/json/system/widget_types/air_quality_index_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/air_quality_index_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "air_quality_chart_card_with_background", "name": "Air quality index chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/air_quality_index_chart_card_with_background_system_widget_image.png", "description": "Displays air quality index data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/carbon_monoxide__co__card.json b/application/src/main/data/json/system/widget_types/carbon_monoxide__co__card.json index eb2b8fde5f..d3632989e7 100644 --- a/application/src/main/data/json/system/widget_types/carbon_monoxide__co__card.json +++ b/application/src/main/data/json/system/widget_types/carbon_monoxide__co__card.json @@ -1,7 +1,7 @@ { "fqn": "carbon_monoxide_co_card", "name": "Carbon monoxide (CO) card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/CO-value-card.svg", "description": "A colorless and odorless gas resulting from incomplete combustion, particularly in motor vehicles.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/carbon_monoxide__co__card_with_background.json b/application/src/main/data/json/system/widget_types/carbon_monoxide__co__card_with_background.json index 3c53033b15..ceef3f3ae6 100644 --- a/application/src/main/data/json/system/widget_types/carbon_monoxide__co__card_with_background.json +++ b/application/src/main/data/json/system/widget_types/carbon_monoxide__co__card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "carbon_monoxide_co_card_with_background", "name": "Carbon monoxide (CO) card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/CO-value-card-with-background.svg", "description": "A colorless and odorless gas resulting from incomplete combustion, particularly in motor vehicles.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/carbon_monoxide__co__chart_card.json b/application/src/main/data/json/system/widget_types/carbon_monoxide__co__chart_card.json index f1ac79ea11..dd712fcf75 100644 --- a/application/src/main/data/json/system/widget_types/carbon_monoxide__co__chart_card.json +++ b/application/src/main/data/json/system/widget_types/carbon_monoxide__co__chart_card.json @@ -1,7 +1,7 @@ { "fqn": "carbon_monoxide_co_chart_card", "name": "Carbon monoxide (CO) chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/CO-value-and-chart-card.svg", "description": "A colorless and odorless gas resulting from incomplete combustion, particularly in motor vehicles. Results displayed by combining the latest and aggregated values and optional simplified chart. ", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/carbon_monoxide__co__chart_card_with_background.json b/application/src/main/data/json/system/widget_types/carbon_monoxide__co__chart_card_with_background.json index cddc48b6e4..d3c334f7b2 100644 --- a/application/src/main/data/json/system/widget_types/carbon_monoxide__co__chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/carbon_monoxide__co__chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "carbon_monoxide_co_chart_card_with_background", "name": "Carbon monoxide (CO) chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/CO-value-chart-card-with-background.svg", "description": "A colorless and odorless gas resulting from incomplete combustion, particularly in motor vehicles. Results displayed by combining the latest and aggregated values and optional simplified chart. ", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/co2_card.json b/application/src/main/data/json/system/widget_types/co2_card.json index 3edb6cb025..e7072077da 100644 --- a/application/src/main/data/json/system/widget_types/co2_card.json +++ b/application/src/main/data/json/system/widget_types/co2_card.json @@ -1,7 +1,7 @@ { "fqn": "co2_card", "name": "CO2 card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/co2_card_system_widget_image.png", "description": "Displays the latest CO2 level telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/co2_card_with_background.json b/application/src/main/data/json/system/widget_types/co2_card_with_background.json index 8f7936e919..71670a437c 100644 --- a/application/src/main/data/json/system/widget_types/co2_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/co2_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "co2_card_with_background", "name": "CO2 card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/co2_card_with_background_system_widget_image.png", "description": "Displays the latest CO2 level telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/co2_chart_card.json b/application/src/main/data/json/system/widget_types/co2_chart_card.json index 70d06ffe37..4aff9d8f84 100644 --- a/application/src/main/data/json/system/widget_types/co2_chart_card.json +++ b/application/src/main/data/json/system/widget_types/co2_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "co2_chart_card", "name": "CO2 chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/co2_chart_card_system_widget_image.png", "description": "Displays a CO2 level data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/co2_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/co2_chart_card_with_background.json index 3de7fadf01..4f0b248af9 100644 --- a/application/src/main/data/json/system/widget_types/co2_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/co2_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "co2_chart_card_with_background", "name": "CO2 chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/co2_chart_card_with_background_system_widget_image.png", "description": "Displays a CO2 level data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/efficiency_card.json b/application/src/main/data/json/system/widget_types/efficiency_card.json index 3ee0c4ea87..77f403ffed 100644 --- a/application/src/main/data/json/system/widget_types/efficiency_card.json +++ b/application/src/main/data/json/system/widget_types/efficiency_card.json @@ -1,7 +1,7 @@ { "fqn": "efficiency_card", "name": "Efficiency card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/efficiency_card.svg", "description": "Displays the latest efficiency telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/efficiency_card_with_background.json b/application/src/main/data/json/system/widget_types/efficiency_card_with_background.json index 31712b3b81..4fccc48fe2 100644 --- a/application/src/main/data/json/system/widget_types/efficiency_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/efficiency_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "efficiency_card_with_background", "name": "Efficiency card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/efficiency_card_with_background.svg", "description": "Displays the latest efficiency telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/efficiency_chart_card.json b/application/src/main/data/json/system/widget_types/efficiency_chart_card.json index 298eddb21c..6a546bfd5e 100644 --- a/application/src/main/data/json/system/widget_types/efficiency_chart_card.json +++ b/application/src/main/data/json/system/widget_types/efficiency_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "efficiency_chart_card", "name": "Efficiency chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/efficiency_chart_card.svg", "description": "Displays efficiency data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/efficiency_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/efficiency_chart_card_with_background.json index bd3b02b991..98391712de 100644 --- a/application/src/main/data/json/system/widget_types/efficiency_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/efficiency_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "efficiency_chart_card_with_background", "name": "Efficiency chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/efficiency_chart_card_with_background.svg", "description": "Displays efficiency data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/efficiency_progress_bar.json b/application/src/main/data/json/system/widget_types/efficiency_progress_bar.json index e216dcd9be..594041df15 100644 --- a/application/src/main/data/json/system/widget_types/efficiency_progress_bar.json +++ b/application/src/main/data/json/system/widget_types/efficiency_progress_bar.json @@ -1,7 +1,7 @@ { "fqn": "efficiency_progress_bar", "name": "Efficiency progress bar", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/efficiency_progress_bar.svg", "description": "Displays efficiency reading as a horizontal progress bar. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/efficiency_progress_bar_with_background.json b/application/src/main/data/json/system/widget_types/efficiency_progress_bar_with_background.json index e52de65f59..fc727df8ac 100644 --- a/application/src/main/data/json/system/widget_types/efficiency_progress_bar_with_background.json +++ b/application/src/main/data/json/system/widget_types/efficiency_progress_bar_with_background.json @@ -1,7 +1,7 @@ { "fqn": "efficiency_progress_bar_with_background", "name": "Efficiency progress bar with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/efficiency_progress_bar_with_background.svg", "description": "Displays efficiency reading as a horizontal progress bar with background. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/efficiency_range_chart.json b/application/src/main/data/json/system/widget_types/efficiency_range_chart.json index ce08ee7c5b..f6451ec2bc 100644 --- a/application/src/main/data/json/system/widget_types/efficiency_range_chart.json +++ b/application/src/main/data/json/system/widget_types/efficiency_range_chart.json @@ -1,7 +1,7 @@ { "fqn": "efficiency_range_chart", "name": "Efficiency range chart", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/efficiency_range_chart.svg", "description": "Displays changes to efficiency data over time visualized with color ranges.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/efficiency_range_chart_with_background.json b/application/src/main/data/json/system/widget_types/efficiency_range_chart_with_background.json index df6e41242c..5cf9870734 100644 --- a/application/src/main/data/json/system/widget_types/efficiency_range_chart_with_background.json +++ b/application/src/main/data/json/system/widget_types/efficiency_range_chart_with_background.json @@ -1,7 +1,7 @@ { "fqn": "efficiency_range_chart_with_background", "name": "Efficiency range chart with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/efficiency_range_chart_with_background.svg", "description": "Displays changes to efficiency data over time visualized with color ranges and background.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/flooding_level_card.json b/application/src/main/data/json/system/widget_types/flooding_level_card.json index 9d9215cf0e..d86045112b 100644 --- a/application/src/main/data/json/system/widget_types/flooding_level_card.json +++ b/application/src/main/data/json/system/widget_types/flooding_level_card.json @@ -1,7 +1,7 @@ { "fqn": "flooding_level_card", "name": "Flooding level card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/flooding_level_card_system_widget_image.png", "description": "Displays the latest flooding level telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/flooding_level_card_with_background.json b/application/src/main/data/json/system/widget_types/flooding_level_card_with_background.json index 20fa345a25..16d1b9f213 100644 --- a/application/src/main/data/json/system/widget_types/flooding_level_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/flooding_level_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "flooding_level_card_with_background", "name": "Flooding level card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/flooding_level_card_with_background_system_widget_image.png", "description": "Displays the latest flooding level telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/flooding_level_chart_card.json b/application/src/main/data/json/system/widget_types/flooding_level_chart_card.json index 5592656dc4..5317ceac0a 100644 --- a/application/src/main/data/json/system/widget_types/flooding_level_chart_card.json +++ b/application/src/main/data/json/system/widget_types/flooding_level_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "flooding_level_chart_card", "name": "Flooding level chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/flooding_level_chart_card_system_widget_image.png", "description": "Displays flooding level data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/flooding_level_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/flooding_level_chart_card_with_background.json index 5b7dfda3da..21cc83c7a1 100644 --- a/application/src/main/data/json/system/widget_types/flooding_level_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/flooding_level_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "flooding_level_chart_card_with_background", "name": "Flooding level chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/flooding_level_chart_card_with_background_system_widget_image.png", "description": "Displays a flooding level data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/flooding_level_progress_bar.json b/application/src/main/data/json/system/widget_types/flooding_level_progress_bar.json index b081f8aa99..8ffc0eabbd 100644 --- a/application/src/main/data/json/system/widget_types/flooding_level_progress_bar.json +++ b/application/src/main/data/json/system/widget_types/flooding_level_progress_bar.json @@ -1,7 +1,7 @@ { "fqn": "flooding_level_progress_bar", "name": "Flooding level progress bar", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/flooding_level_progress_bar_system_widget_image.png", "description": "Displays flooding level reading as a horizontal progress bar. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/flooding_level_progress_bar_with_background.json b/application/src/main/data/json/system/widget_types/flooding_level_progress_bar_with_background.json index 2048b17063..b908112abe 100644 --- a/application/src/main/data/json/system/widget_types/flooding_level_progress_bar_with_background.json +++ b/application/src/main/data/json/system/widget_types/flooding_level_progress_bar_with_background.json @@ -1,7 +1,7 @@ { "fqn": "flooding_level_progress_bar_with_background", "name": "Flooding level progress bar with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/flooding_level_progress_bar_with_background_system_widget_image.png", "description": "Displays flooding level reading as a horizontal progress bar with the background image. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/flow_rate_card.json b/application/src/main/data/json/system/widget_types/flow_rate_card.json index bfe1c34f15..8ffb240570 100644 --- a/application/src/main/data/json/system/widget_types/flow_rate_card.json +++ b/application/src/main/data/json/system/widget_types/flow_rate_card.json @@ -1,7 +1,7 @@ { "fqn": "flow_rate_card", "name": "Flow rate card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/flow_rate_card.svg", "description": "Displays the latest flow rate telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/flow_rate_card_with_background.json b/application/src/main/data/json/system/widget_types/flow_rate_card_with_background.json index cf127d5bf7..68e4888388 100644 --- a/application/src/main/data/json/system/widget_types/flow_rate_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/flow_rate_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "flow_rate_card_with_background", "name": "Flow rate card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/flow_rate_card_with_background.svg", "description": "Displays the latest flow rate telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/flow_rate_chart_card.json b/application/src/main/data/json/system/widget_types/flow_rate_chart_card.json index df5c63b14e..4beefb2187 100644 --- a/application/src/main/data/json/system/widget_types/flow_rate_chart_card.json +++ b/application/src/main/data/json/system/widget_types/flow_rate_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "flow_rate_chart_card", "name": "Flow rate chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/flow_rate_chart_card.svg", "description": "Displays flow rate data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/flow_rate_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/flow_rate_chart_card_with_background.json index 0ffb5a7122..5a8ad8064e 100644 --- a/application/src/main/data/json/system/widget_types/flow_rate_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/flow_rate_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "flow_rate_chart_card_with_background", "name": "Flow rate chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/flow_rate_chart_card_with_background.svg", "description": "Displays flow rate data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/flow_rate_gauge.json b/application/src/main/data/json/system/widget_types/flow_rate_gauge.json index 83176686ac..1f1ac5769e 100644 --- a/application/src/main/data/json/system/widget_types/flow_rate_gauge.json +++ b/application/src/main/data/json/system/widget_types/flow_rate_gauge.json @@ -1,7 +1,7 @@ { "fqn": "flow_rate_gauge", "name": "Flow rate gauge", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/flow_rate_gauge.svg", "description": "Flow rate gauge.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/flow_rate_progress_bar.json b/application/src/main/data/json/system/widget_types/flow_rate_progress_bar.json index 8e63ab04e8..70b6c98d68 100644 --- a/application/src/main/data/json/system/widget_types/flow_rate_progress_bar.json +++ b/application/src/main/data/json/system/widget_types/flow_rate_progress_bar.json @@ -1,7 +1,7 @@ { "fqn": "flow_rate_progress_bar", "name": "Flow rate progress bar", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/flow_rate_progress_bar_(1).svg", "description": "Displays flow rate reading as a horizontal progress bar. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/flow_rate_progress_bar_with_background.json b/application/src/main/data/json/system/widget_types/flow_rate_progress_bar_with_background.json index d7ee1333e3..8ce5c56e10 100644 --- a/application/src/main/data/json/system/widget_types/flow_rate_progress_bar_with_background.json +++ b/application/src/main/data/json/system/widget_types/flow_rate_progress_bar_with_background.json @@ -1,7 +1,7 @@ { "fqn": "flow_rate_progress_bar_with_background", "name": "Flow rate progress bar with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/flow_rate_progress_bar_with_background.svg", "description": "Displays flow rate reading as a horizontal progress bar with background. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/flow_rate_range_chart.json b/application/src/main/data/json/system/widget_types/flow_rate_range_chart.json index c19becc363..90b936ad95 100644 --- a/application/src/main/data/json/system/widget_types/flow_rate_range_chart.json +++ b/application/src/main/data/json/system/widget_types/flow_rate_range_chart.json @@ -1,7 +1,7 @@ { "fqn": "flow_rate_range_chart", "name": "Flow rate range chart", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/flow_rate_range_chart.svg", "description": "Displays changes to flow rate data over time visualized with color ranges.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/flow_rate_range_chart_with_background.json b/application/src/main/data/json/system/widget_types/flow_rate_range_chart_with_background.json index f5b7d437b2..7383538dee 100644 --- a/application/src/main/data/json/system/widget_types/flow_rate_range_chart_with_background.json +++ b/application/src/main/data/json/system/widget_types/flow_rate_range_chart_with_background.json @@ -1,7 +1,7 @@ { "fqn": "flow_rate_range_chart_with_background", "name": "Flow rate range chart with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/flow_rate_range_chart_with_background.svg", "description": "Displays changes to flow rate data over time visualized with color ranges and background.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/fluid_pressure_card.json b/application/src/main/data/json/system/widget_types/fluid_pressure_card.json index 2c881da205..5d93544cac 100644 --- a/application/src/main/data/json/system/widget_types/fluid_pressure_card.json +++ b/application/src/main/data/json/system/widget_types/fluid_pressure_card.json @@ -1,7 +1,7 @@ { "fqn": "fluid_pressure_card", "name": "Pressure card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pressure_card.svg", "description": "Displays the latest fluid pressure telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/fluid_pressure_card_with_background.json b/application/src/main/data/json/system/widget_types/fluid_pressure_card_with_background.json index d059d9b026..668e83a251 100644 --- a/application/src/main/data/json/system/widget_types/fluid_pressure_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/fluid_pressure_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "fluid_pressure_card_with_background", "name": "Pressure card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pressure_card_with_background.svg", "description": "Displays the latest fluid pressure telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/fluid_pressure_chart_card.json b/application/src/main/data/json/system/widget_types/fluid_pressure_chart_card.json index ee27a6b376..c6de6acc35 100644 --- a/application/src/main/data/json/system/widget_types/fluid_pressure_chart_card.json +++ b/application/src/main/data/json/system/widget_types/fluid_pressure_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "fluid_pressure_chart_card", "name": "Pressure chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pressure_chart_card.svg", "description": "Displays fluid pressure data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/fluid_pressure_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/fluid_pressure_chart_card_with_background.json index e9e9fede9a..122b553773 100644 --- a/application/src/main/data/json/system/widget_types/fluid_pressure_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/fluid_pressure_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "fluid_pressure_chart_card_with_background", "name": "Pressure chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pressure_chart_card_with_background.svg", "description": "Displays fluid pressure data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/fluid_pressure_gauge.json b/application/src/main/data/json/system/widget_types/fluid_pressure_gauge.json index 98318d30a4..4efbbb4104 100644 --- a/application/src/main/data/json/system/widget_types/fluid_pressure_gauge.json +++ b/application/src/main/data/json/system/widget_types/fluid_pressure_gauge.json @@ -1,7 +1,7 @@ { "fqn": "fluid_pressure_gauge", "name": "Pressure gauge", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pressure_gauge.svg", "description": "Fluid pressure gauge.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/fluid_pressure_progress_bar.json b/application/src/main/data/json/system/widget_types/fluid_pressure_progress_bar.json index 0a4e80dd60..b8d00d058a 100644 --- a/application/src/main/data/json/system/widget_types/fluid_pressure_progress_bar.json +++ b/application/src/main/data/json/system/widget_types/fluid_pressure_progress_bar.json @@ -1,7 +1,7 @@ { "fqn": "fluid_pressure_progress_bar", "name": "Pressure progress bar", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pressure_progress_bar_(1).svg", "description": "Displays fluid pressure reading as a horizontal progress bar. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/fluid_pressure_progress_bar_with_background.json b/application/src/main/data/json/system/widget_types/fluid_pressure_progress_bar_with_background.json index 8000ff00af..63a5ba0560 100644 --- a/application/src/main/data/json/system/widget_types/fluid_pressure_progress_bar_with_background.json +++ b/application/src/main/data/json/system/widget_types/fluid_pressure_progress_bar_with_background.json @@ -1,7 +1,7 @@ { "fqn": "fluid_pressure_progress_bar_with_background", "name": "Pressure progress bar with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/progress_bar_with_background.svg", "description": "Displays fluid pressure reading as a horizontal progress bar with background. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/fluid_pressure_range_chart.json b/application/src/main/data/json/system/widget_types/fluid_pressure_range_chart.json index fd948984c9..2f4c231e92 100644 --- a/application/src/main/data/json/system/widget_types/fluid_pressure_range_chart.json +++ b/application/src/main/data/json/system/widget_types/fluid_pressure_range_chart.json @@ -1,7 +1,7 @@ { "fqn": "fluid_pressure_range_chart", "name": "Pressure range chart", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pressure_range_chart_(1).svg", "description": "Displays changes to fluid pressure data over time visualized with color ranges.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/fluid_pressure_range_chart_with_background.json b/application/src/main/data/json/system/widget_types/fluid_pressure_range_chart_with_background.json index a8e3c8df2c..efb0aea62e 100644 --- a/application/src/main/data/json/system/widget_types/fluid_pressure_range_chart_with_background.json +++ b/application/src/main/data/json/system/widget_types/fluid_pressure_range_chart_with_background.json @@ -1,7 +1,7 @@ { "fqn": "fluid_pressure_range_chart_with_background", "name": "Pressure range chart with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pressure_range_chart_with_background.svg", "description": "Displays changes to fluid pressure data over time visualized with color ranges and background.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/ground_temperature_card.json b/application/src/main/data/json/system/widget_types/ground_temperature_card.json index 77c2c0ccfb..b5f63e8999 100644 --- a/application/src/main/data/json/system/widget_types/ground_temperature_card.json +++ b/application/src/main/data/json/system/widget_types/ground_temperature_card.json @@ -1,7 +1,7 @@ { "fqn": "ground_temperature_card", "name": "Ground temperature card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/ground_temperature_card_system_widget_image.png", "description": "Displays the latest ground temperature telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/ground_temperature_card_with_background.json b/application/src/main/data/json/system/widget_types/ground_temperature_card_with_background.json index 3263548bb2..85ec331c3b 100644 --- a/application/src/main/data/json/system/widget_types/ground_temperature_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/ground_temperature_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "ground_temperature_card_with_background", "name": "Ground temperature card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/ground_temperature_card_with_background_system_widget_image.png", "description": "Displays the latest ground temperature telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/ground_temperature_chart_card.json b/application/src/main/data/json/system/widget_types/ground_temperature_chart_card.json index 949e48f9de..bee76a8789 100644 --- a/application/src/main/data/json/system/widget_types/ground_temperature_chart_card.json +++ b/application/src/main/data/json/system/widget_types/ground_temperature_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "ground_temperature_chart_card", "name": "Ground temperature chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/ground_temperature_chart_card_system_widget_image.png", "description": "Displays a ground temperature data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/ground_temperature_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/ground_temperature_chart_card_with_background.json index 06bf06bd98..acef304a20 100644 --- a/application/src/main/data/json/system/widget_types/ground_temperature_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/ground_temperature_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "ground_temperature_chart_card_with_background", "name": "Ground temperature chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/ground_temperature_chart_card_with_background_system_widget_image.png", "description": "Displays a ground temperature data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_air_quality_index_card.json b/application/src/main/data/json/system/widget_types/horizontal_air_quality_index_card.json index 8190e9fcaf..3edfcd5f0d 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_air_quality_index_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_air_quality_index_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_air_quality_card", "name": "Horizontal air quality index card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_air_quality_index_card_system_widget_image.png", "description": "Displays the latest air quality index telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_air_quality_index_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_air_quality_index_card_with_background.json index 2bf3dcbe23..4067793511 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_air_quality_index_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_air_quality_index_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_air_quality_card_with_background", "name": "Horizontal air quality index card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_air_quality_index_card_with_background_system_widget_image.png", "description": "Displays the latest air quality index telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_carbon_monoxide__co__card.json b/application/src/main/data/json/system/widget_types/horizontal_carbon_monoxide__co__card.json index 02482fb5c9..12ec735556 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_carbon_monoxide__co__card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_carbon_monoxide__co__card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_carbon_monoxide_co_card", "name": "Horizontal carbon monoxide (CO) card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/CO-value-card-horizontal.svg", "description": "A colorless and odorless gas resulting from incomplete combustion, particularly in motor vehicles.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_carbon_monoxide__co__card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_carbon_monoxide__co__card_with_background.json index 38391e23d6..bc47432672 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_carbon_monoxide__co__card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_carbon_monoxide__co__card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_carbon_monoxide_co_card_with_background", "name": "Horizontal carbon monoxide (CO) card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/CO-horizontal-value-card-with-background.svg", "description": "A colorless and odorless gas resulting from incomplete combustion, particularly in motor vehicles.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_co2_card.json b/application/src/main/data/json/system/widget_types/horizontal_co2_card.json index e6c8e1eb6d..26096b638d 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_co2_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_co2_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_co2_card", "name": "Horizontal CO2 card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_co2_card_system_widget_image.png", "description": "Displays the latest CO2 level telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_co2_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_co2_card_with_background.json index 34a4e1aba4..5e634879fe 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_co2_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_co2_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_co2_card_with_background", "name": "Horizontal CO2 card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_co2_card_with_background_system_widget_image.png", "description": "Displays the latest CO2 level telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_efficiency_card.json b/application/src/main/data/json/system/widget_types/horizontal_efficiency_card.json index a955d68869..7f2b9ec764 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_efficiency_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_efficiency_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_efficiency_card", "name": "Horizontal efficiency card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_efficiency_card.svg", "description": "Displays the latest efficiency telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_efficiency_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_efficiency_card_with_background.json index 78f8863965..3b6368de9e 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_efficiency_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_efficiency_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_efficiency_card_with_background", "name": "Horizontal efficiency card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_efficiency_card_with_background.svg", "description": "Displays the latest efficiency telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_flooding_level_card.json b/application/src/main/data/json/system/widget_types/horizontal_flooding_level_card.json index 333d983be9..b66bdeee76 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_flooding_level_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_flooding_level_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_flooding_level_card", "name": "Horizontal flooding level card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_flooding_level_card_system_widget_image.png", "description": "Displays the latest flooding level telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_flooding_level_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_flooding_level_card_with_background.json index 7c6794b4f7..883bc4b617 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_flooding_level_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_flooding_level_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_flooding_level_card_with_background", "name": "Horizontal flooding level card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_flooding_level_card_with_background_system_widget_image.png", "description": "Displays the latest flooding level telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_flow_rate_card.json b/application/src/main/data/json/system/widget_types/horizontal_flow_rate_card.json index 15aca34b02..78e31e13c0 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_flow_rate_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_flow_rate_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_flow_rate_card", "name": "Horizontal flow rate card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_flow_rate_card.svg", "description": "Displays the latest flow rate telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_flow_rate_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_flow_rate_card_with_background.json index 4f6de487cd..aac479c826 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_flow_rate_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_flow_rate_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_flow_rate_card_with_background", "name": "Horizontal flow rate card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_flow_rate_card_with_background.svg", "description": "Displays the latest flow rate telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_fluid_pressure_card.json b/application/src/main/data/json/system/widget_types/horizontal_fluid_pressure_card.json index b9432be3ca..d7b5896e2d 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_fluid_pressure_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_fluid_pressure_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_fluid_pressure_card", "name": "Horizontal pressure card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_pressure_card.svg", "description": "Displays the latest fluid pressure telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_fluid_pressure_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_fluid_pressure_card_with_background.json index 5598b8ed1c..4c23275ccb 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_fluid_pressure_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_fluid_pressure_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_fluid_pressure_card_with_background", "name": "Horizontal pressure card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_pressure_card_with_background.svg", "description": "Displays the latest fluid pressure telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_ground_temperature_card.json b/application/src/main/data/json/system/widget_types/horizontal_ground_temperature_card.json index 6b0f064707..fbde48637c 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_ground_temperature_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_ground_temperature_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_ground_temperature_card", "name": "Horizontal ground temperature card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_ground_temperature_card_system_widget_image.png", "description": "Displays the latest ground temperature telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_ground_temperature_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_ground_temperature_card_with_background.json index bb03947926..0ef886521f 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_ground_temperature_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_ground_temperature_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_ground_temperature_card_with_background", "name": "Horizontal ground temperature card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_ground_temperature_card_with_background_system_widget_image.png", "description": "Displays the latest ground temperature telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_humidity_card.json b/application/src/main/data/json/system/widget_types/horizontal_humidity_card.json index fa576d2ee0..cb6fe5e5ef 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_humidity_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_humidity_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_humidity_card", "name": "Horizontal humidity card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_humidity_card_system_widget_image.png", "description": "Displays the latest humidity telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_humidity_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_humidity_card_with_background.json index 450d4046f3..8abeddf248 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_humidity_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_humidity_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_humidity_card_with_background", "name": "Horizontal humidity card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_humidity_card_with_background_system_widget_image.png", "description": "Displays the latest humidity telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_illuminance_card.json b/application/src/main/data/json/system/widget_types/horizontal_illuminance_card.json index 456268a8fa..b985882e79 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_illuminance_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_illuminance_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_illuminance_card", "name": "Horizontal illuminance card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_illuminance_card_system_widget_image.png", "description": "Displays the latest illuminance telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_illuminance_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_illuminance_card_with_background.json index a3ff36784f..1cf61643b7 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_illuminance_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_illuminance_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_illuminance_card_with_background", "name": "Horizontal illuminance card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_illuminance_card_with_background_system_widget_image.png", "description": "Displays the latest illuminance telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_individual_allergy_index__iai__card.json b/application/src/main/data/json/system/widget_types/horizontal_individual_allergy_index__iai__card.json index 8cd5159aab..22724cee64 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_individual_allergy_index__iai__card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_individual_allergy_index__iai__card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_individual_allergy_index_iai_card", "name": "Horizontal individual allergy index (IAI) card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/IAI-value-card-horizontal.svg", "description": "Indicates the concentration of airborne allergens, including pollen and mold spores, which can trigger allergic reactions in sensitive individuals.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_individual_allergy_index__iai__card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_individual_allergy_index__iai__card_with_background.json index 8cc3664ad4..1e9d4d0cd1 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_individual_allergy_index__iai__card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_individual_allergy_index__iai__card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_individual_allergy_index_iai_card_with_background", "name": "Horizontal individual allergy index (IAI) card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/IAI-value-card-horizontal-with-background.svg", "description": "Indicates the concentration of airborne allergens, including pollen and mold spores, which can trigger allergic reactions in sensitive individuals.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_leaf_wetness_card.json b/application/src/main/data/json/system/widget_types/horizontal_leaf_wetness_card.json index f31a0a7bb3..8badf0af91 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_leaf_wetness_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_leaf_wetness_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_leaf_wetness_card", "name": "Horizontal leaf wetness card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_leaf_wetness_card_system_widget_image.png", "description": "Displays the latest leaf wetness telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_leaf_wetness_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_leaf_wetness_card_with_background.json index 637c6182cc..1d663cc004 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_leaf_wetness_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_leaf_wetness_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_leaf_wetness_card_with_background", "name": "Horizontal leaf wetness card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_leaf_wetness_card_with_background_system_widget_image.png", "description": "Displays the latest leaf wetness telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_nitrogen_dioxide__no2__card.json b/application/src/main/data/json/system/widget_types/horizontal_nitrogen_dioxide__no2__card.json index ce820784bd..f4898088a8 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_nitrogen_dioxide__no2__card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_nitrogen_dioxide__no2__card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_nitrogen_dioxide_no2_card", "name": "Horizontal nitrogen dioxide (NO2) card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/NO2-value-card-horizontal.svg", "description": "A gas produced from vehicle emissions and the burning of fossil fuels.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_nitrogen_dioxide__no2__card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_nitrogen_dioxide__no2__card_with_background.json index d36eb56e95..668b865160 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_nitrogen_dioxide__no2__card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_nitrogen_dioxide__no2__card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_nitrogen_dioxide_no2_card_with_background", "name": "Horizontal nitrogen dioxide (NO2) card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/NO2-horizontal-value-card-with-background.svg", "description": "A gas produced from vehicle emissions and the burning of fossil fuels.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_noise_level_card.json b/application/src/main/data/json/system/widget_types/horizontal_noise_level_card.json index a6a5cc1004..c124103ec9 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_noise_level_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_noise_level_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_noise_level_card", "name": "Horizontal noise level card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_noise_level_card_system_widget_image.png", "description": "Displays the latest noise level telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_noise_level_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_noise_level_card_with_background.json index 02d041e1d6..f8cd8498ab 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_noise_level_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_noise_level_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_noise_level_card_with_background", "name": "Horizontal noise level card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_noise_level_card_with_background_system_widget_image.png", "description": "Displays the latest noise level telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_ozone__o3__card.json b/application/src/main/data/json/system/widget_types/horizontal_ozone__o3__card.json index 61a63dd7b6..b6a04e0e3f 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_ozone__o3__card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_ozone__o3__card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_ozone_o3_card", "name": "Horizontal ozone (O3) card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/ozone-value-card-horizontal.svg", "description": "A beneficial layer in the upper atmosphere, but harmful when present near ground level. Results mainly from vehicle exhaust and industrial emissions.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_ozone__o3__card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_ozone__o3__card_with_background.json index 0a0886ca7b..14a0bebdfb 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_ozone__o3__card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_ozone__o3__card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_ozone_o3_card_with_background", "name": "Horizontal ozone (O3) card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/ozone-value-card-horizontal-with-background.svg", "description": "A beneficial layer in the upper atmosphere, but harmful when present near ground level. Results mainly from vehicle exhaust and industrial emissions.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_pm10_card.json b/application/src/main/data/json/system/widget_types/horizontal_pm10_card.json index 5f9eb7d645..263c451c0a 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_pm10_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_pm10_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_pm10_card", "name": "Horizontal PM10 card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_pm10_card_system_widget_image.png", "description": "Displays the latest fine and coarse particulate matter (PM10) telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_pm10_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_pm10_card_with_background.json index ca3b39eac9..4a38b9685b 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_pm10_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_pm10_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_pm10_card_with_background", "name": "Horizontal PM10 card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_pm10_card_with_background_system_widget_image.png", "description": "Displays the latest fine and coarse particulate matter (PM10) telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_pm2_5_card.json b/application/src/main/data/json/system/widget_types/horizontal_pm2_5_card.json index 617031cc7e..97fa858610 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_pm2_5_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_pm2_5_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_pm2_5_card", "name": "Horizontal PM2.5 card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_pm2_5_card_system_widget_image.png", "description": "Displays the latest fine particulate matter (PM2.5) telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_pm2_5_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_pm2_5_card_with_background.json index f85406bd75..c50422d915 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_pm2_5_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_pm2_5_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_pm2_5_card_with_background", "name": "Horizontal PM2.5 card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_pm2_5_card_with_background_system_widget_image.png", "description": "Displays the latest fine particulate matter (PM2.5) telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_power_consumption_card.json b/application/src/main/data/json/system/widget_types/horizontal_power_consumption_card.json index b7d93c0cbc..b8d213bd17 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_power_consumption_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_power_consumption_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_power_consumption_card", "name": "Horizontal power consumption card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_power_consumption_card.svg", "description": "Displays the latest power consumption telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_power_consumption_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_power_consumption_card_with_background.json index d74452d787..a7f40e0d8a 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_power_consumption_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_power_consumption_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_power_consumption_card_with_background", "name": "Horizontal power consumption card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_power_consumption_card_with_background.svg", "description": "Displays the latest power consumption telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_pressure_card.json b/application/src/main/data/json/system/widget_types/horizontal_pressure_card.json index 64ab997988..cdbdde4f54 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_pressure_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_pressure_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_pressure_card", "name": "Horizontal pressure card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_pressure_card_system_widget_image.png", "description": "Displays the latest pressure telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_pressure_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_pressure_card_with_background.json index 0b4d9dbf35..9a70aa0a70 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_pressure_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_pressure_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_pressure_card_with_background", "name": "Horizontal pressure card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_pressure_card_with_background_system_widget_image.png", "description": "Displays the latest pressure telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_pump_vibration_card.json b/application/src/main/data/json/system/widget_types/horizontal_pump_vibration_card.json index de91fdde12..03b9dc4a07 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_pump_vibration_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_pump_vibration_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_pump_vibration_card", "name": "Horizontal vibration card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_vibration_card.svg", "description": "Displays the latest vibration telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_pump_vibration_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_pump_vibration_card_with_background.json index c787de5a2c..97eaf6e5f6 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_pump_vibration_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_pump_vibration_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_pump_vibration_card_with_background", "name": "Horizontal vibration card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_vibration_card_with_background.svg", "description": "Displays the latest vibration telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_radon_level_card.json b/application/src/main/data/json/system/widget_types/horizontal_radon_level_card.json index e5edbd240c..46d233d5b2 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_radon_level_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_radon_level_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_radon_level_card", "name": "Horizontal radon level card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_radon_level_card_system_widget_image.png", "description": "Displays the latest radon level telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_radon_level_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_radon_level_card_with_background.json index f15bce1cb9..a76fea03ad 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_radon_level_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_radon_level_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_radon_level_card_with_background", "name": "Horizontal radon level card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_radon_level_card_with_background_system_widget_image.png", "description": "Displays the latest radon level telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_rainfall_card.json b/application/src/main/data/json/system/widget_types/horizontal_rainfall_card.json index 22a7cf3675..8be07dd505 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_rainfall_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_rainfall_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_rainfall_card", "name": "Horizontal rainfall card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_rainfall_card_system_widget_image.png", "description": "Displays the latest rainfall telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_rainfall_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_rainfall_card_with_background.json index 19f0333ac8..9283ed79d0 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_rainfall_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_rainfall_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_rainfall_card_with_background", "name": "Horizontal rainfall card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_rainfall_card_with_background_system_widget_image.png", "description": "Displays the latest rainfall telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_rotational_speed_card.json b/application/src/main/data/json/system/widget_types/horizontal_rotational_speed_card.json index 6e041e0fd8..d229baa2f4 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_rotational_speed_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_rotational_speed_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_rotational_speed_card", "name": "Horizontal rotational speed card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_rotational_speed_card.svg", "description": "Displays the latest rotational speed telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_rotational_speed_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_rotational_speed_card_with_background.json index 90cc5dbb51..63f8b4c7e3 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_rotational_speed_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_rotational_speed_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_rotational_speed_card_with_background", "name": "Horizontal rotational speed card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_rotational_speed_card_with_background.svg", "description": "Displays the latest rotational speed telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_snow_depth_card.json b/application/src/main/data/json/system/widget_types/horizontal_snow_depth_card.json index d94b6c218a..8eced60303 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_snow_depth_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_snow_depth_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_snow_depth_card", "name": "Horizontal snow depth card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_snow_depth_card_system_widget_image.png", "description": "Displays the latest snow depth telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_snow_depth_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_snow_depth_card_with_background.json index da6e3f16cb..6a5f14f880 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_snow_depth_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_snow_depth_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_snow_depth_card_with_background", "name": "Horizontal snow depth card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_snow_depth_card_with_background_system_widget_image.png", "description": "Displays the latest snow depth telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_soil_moisture_card.json b/application/src/main/data/json/system/widget_types/horizontal_soil_moisture_card.json index 7c8094006c..5f4d34bf96 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_soil_moisture_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_soil_moisture_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_soil_moisture_card", "name": "Horizontal soil moisture card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_soil_moisture_card_system_widget_image.png", "description": "Displays the latest soil moisture telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_soil_moisture_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_soil_moisture_card_with_background.json index 970acb6bc6..22a6736053 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_soil_moisture_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_soil_moisture_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_soil_moisture_card_with_background", "name": "Horizontal soil moisture card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_soil_moisture_card_with_background_system_widget_image.png", "description": "Displays the latest soil moisture telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_solar_radiation_card.json b/application/src/main/data/json/system/widget_types/horizontal_solar_radiation_card.json index c9b3cbfa93..3cda84d737 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_solar_radiation_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_solar_radiation_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_solar_radiation_card", "name": "Horizontal solar radiation card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_solar_radiation_card_system_widget_image.png", "description": "Displays the latest solar radiation telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_solar_radiation_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_solar_radiation_card_with_background.json index 7220911d6d..41238026ff 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_solar_radiation_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_solar_radiation_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_solar_radiation_card_with_background", "name": "Horizontal solar radiation card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_solar_radiation_card_with_background_system_widget_image.png", "description": "Displays the latest solar radiation telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_sulfur_dioxide__so2__card.json b/application/src/main/data/json/system/widget_types/horizontal_sulfur_dioxide__so2__card.json index ad8d850963..1376a9568a 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_sulfur_dioxide__so2__card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_sulfur_dioxide__so2__card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_sulfur_dioxide_so2_card", "name": "Horizontal sulfur dioxide (SO2) card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/SO2-value-card-horizontal.svg", "description": "Produced by volcanoes and industrial processes, particularly the burning of coal and oil.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_sulfur_dioxide__so2__card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_sulfur_dioxide__so2__card_with_background.json index f8c2601a28..e037518ed2 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_sulfur_dioxide__so2__card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_sulfur_dioxide__so2__card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_sulfur_dioxide_so2_card_with_background", "name": "Horizontal sulfur dioxide (SO2) card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/SO2-horizontal-value-card-with-background.svg", "description": "Produced by volcanoes and industrial processes, particularly the burning of coal and oil.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_temperature_card.json b/application/src/main/data/json/system/widget_types/horizontal_temperature_card.json index d38d5412bd..7d9fcb7a84 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_temperature_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_temperature_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_temperature_card", "name": "Horizontal temperature card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_temperature_card_system_widget_image.png", "description": "Displays the latest temperature telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_temperature_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_temperature_card_with_background.json index 6ed35afab5..392eb560cf 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_temperature_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_temperature_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_temperature_card_with_background", "name": "Horizontal temperature card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_temperature_card_with_background_system_widget_image.png", "description": "Displays the latest temperature telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_uv_index_card.json b/application/src/main/data/json/system/widget_types/horizontal_uv_index_card.json index 57eed149c4..63abd84c1e 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_uv_index_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_uv_index_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_uv_index_card", "name": "Horizontal UV Index card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_uv_index_card_system_widget_image.png", "description": "Displays the latest UV index telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_uv_index_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_uv_index_card_with_background.json index 2a542e7c62..7f27b72bd0 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_uv_index_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_uv_index_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_uv_index_card_with_background", "name": "Horizontal UV Index card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_uv_index_card_with_background_system_widget_image.png", "description": "Displays the latest UV index telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_vibration_card.json b/application/src/main/data/json/system/widget_types/horizontal_vibration_card.json index df72fa560f..cd932353ec 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_vibration_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_vibration_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_vibration_card", "name": "Horizontal vibration card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_vibration_card_system_widget_image.png", "description": "Displays the latest vibration telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_vibration_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_vibration_card_with_background.json index c7137ba291..7aa076dfca 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_vibration_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_vibration_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_vibration_card_with_background", "name": "Horizontal vibration card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_vibration_card_with_background_system_widget_image.png", "description": "Displays the latest vibration telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_visibility_card.json b/application/src/main/data/json/system/widget_types/horizontal_visibility_card.json index f36ba0adca..4b8c4d4388 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_visibility_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_visibility_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_visibility_card", "name": "Horizontal visibility card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_visibility_card_system_widget_image.png", "description": "Displays the latest visibility telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_visibility_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_visibility_card_with_background.json index e278d5c9a6..a30af605a5 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_visibility_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_visibility_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_visibility_card_with_background", "name": "Horizontal visibility card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_visibility_card_with_background_system_widget_image.png", "description": "Displays the latest visibility telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_volatile_organic_compounds_card.json b/application/src/main/data/json/system/widget_types/horizontal_volatile_organic_compounds_card.json index cccfe7c18b..67ea3e5493 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_volatile_organic_compounds_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_volatile_organic_compounds_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_volatile_organic_compounds_card", "name": "Horizontal volatile organic compounds card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_volatile_organic_compounds_card_system_widget_image.png", "description": "Displays the latest volatile organic compounds (VOCs) telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_volatile_organic_compounds_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_volatile_organic_compounds_card_with_background.json index a97f139a51..3bc9184668 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_volatile_organic_compounds_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_volatile_organic_compounds_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_volatile_organic_compounds_card_with_background", "name": "Horizontal volatile organic compounds card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_volatile_organic_compounds_card_with_background_system_widget_image.png", "description": "Displays the latest volatile organic compounds (VOCs) telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_wind_speed_card.json b/application/src/main/data/json/system/widget_types/horizontal_wind_speed_card.json index 9883066d90..8840fd39f7 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_wind_speed_card.json +++ b/application/src/main/data/json/system/widget_types/horizontal_wind_speed_card.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_wind_speed_card", "name": "Horizontal wind speed card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_wind_speed_card_system_widget_image.png", "description": "Displays the latest wind speed telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/horizontal_wind_speed_card_with_background.json b/application/src/main/data/json/system/widget_types/horizontal_wind_speed_card_with_background.json index 0b6700ebc8..d5f073b5cf 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_wind_speed_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/horizontal_wind_speed_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "horizontal_wind_speed_card_with_background", "name": "Horizontal wind speed card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/horizontal_wind_speed_card_with_background_system_widget_image.png", "description": "Displays the latest wind speed telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/html_container.json b/application/src/main/data/json/system/widget_types/html_container.json index 25b9e5ac5d..a27996593b 100644 --- a/application/src/main/data/json/system/widget_types/html_container.json +++ b/application/src/main/data/json/system/widget_types/html_container.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n", + "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n\nself.actionSources = function() {\n return {\n 'javaScript': {\n name: 'JavaScript',\n multiple: true\n }\n };\n}", "settingsDirective": "tb-html-container-widget-settings", "hasBasicMode": true, "basicModeDirective": "tb-html-container-basic-config", diff --git a/application/src/main/data/json/system/widget_types/humidity_card.json b/application/src/main/data/json/system/widget_types/humidity_card.json index 28b7c88e7c..7bfcfbfb83 100644 --- a/application/src/main/data/json/system/widget_types/humidity_card.json +++ b/application/src/main/data/json/system/widget_types/humidity_card.json @@ -1,7 +1,7 @@ { "fqn": "humidity_card", "name": "Humidity card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/humidity_card_system_widget_image.png", "description": "Displays the latest humidity telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/humidity_card_with_background.json b/application/src/main/data/json/system/widget_types/humidity_card_with_background.json index 2f33b5ec33..1a95ef1ae4 100644 --- a/application/src/main/data/json/system/widget_types/humidity_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/humidity_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "humidity_card_with_background", "name": "Humidity card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/humidity_card_with_background_system_widget_image.png", "description": "Displays the latest humidity telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/humidity_chart_card.json b/application/src/main/data/json/system/widget_types/humidity_chart_card.json index dad80f9797..2bbfa4c92b 100644 --- a/application/src/main/data/json/system/widget_types/humidity_chart_card.json +++ b/application/src/main/data/json/system/widget_types/humidity_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "humidity_chart_card", "name": "Humidity chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/humidity_chart_card_system_widget_image.png", "description": "Displays a humidity data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/humidity_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/humidity_chart_card_with_background.json index be13ef3285..a625836f1c 100644 --- a/application/src/main/data/json/system/widget_types/humidity_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/humidity_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "humidity_chart_card_with_background", "name": "Humidity chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/humidity_chart_card_with_background_system_widget_image.png", "description": "Displays a humidity data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/humidity_progress_bar.json b/application/src/main/data/json/system/widget_types/humidity_progress_bar.json index b8d80a2040..4f867635fe 100644 --- a/application/src/main/data/json/system/widget_types/humidity_progress_bar.json +++ b/application/src/main/data/json/system/widget_types/humidity_progress_bar.json @@ -1,7 +1,7 @@ { "fqn": "humidity_progress_bar", "name": "Humidity progress bar", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/humidity_progress_bar_system_widget_image.png", "description": "Displays humidity reading as a horizontal progress bar. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/humidity_progress_bar_with_background.json b/application/src/main/data/json/system/widget_types/humidity_progress_bar_with_background.json index 4d3ddc7d21..da3c2e4984 100644 --- a/application/src/main/data/json/system/widget_types/humidity_progress_bar_with_background.json +++ b/application/src/main/data/json/system/widget_types/humidity_progress_bar_with_background.json @@ -1,7 +1,7 @@ { "fqn": "humidity_progress_bar_with_background", "name": "Humidity progress bar with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/humidity_progress_bar_with_background_system_widget_image.png", "description": "Displays humidity reading as a horizontal progress bar with background. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/illuminance_card.json b/application/src/main/data/json/system/widget_types/illuminance_card.json index 6e24dcaa4a..5194b4fb54 100644 --- a/application/src/main/data/json/system/widget_types/illuminance_card.json +++ b/application/src/main/data/json/system/widget_types/illuminance_card.json @@ -1,7 +1,7 @@ { "fqn": "illuminance_card", "name": "Illuminance card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/illuminance_card_system_widget_image.png", "description": "Displays the latest illuminance telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/illuminance_card_with_background.json b/application/src/main/data/json/system/widget_types/illuminance_card_with_background.json index 0c990bb1b9..fd61f306dc 100644 --- a/application/src/main/data/json/system/widget_types/illuminance_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/illuminance_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "illuminance_card_with_background", "name": "Illuminance card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/illuminance_card_with_background_system_widget_image.png", "description": "Displays the latest illuminance telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/illuminance_chart_card.json b/application/src/main/data/json/system/widget_types/illuminance_chart_card.json index f664cc0d27..68d4da8c02 100644 --- a/application/src/main/data/json/system/widget_types/illuminance_chart_card.json +++ b/application/src/main/data/json/system/widget_types/illuminance_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "illuminance_chart_card", "name": "Illuminance chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/illuminance_chart_card_system_widget_image.png", "description": "Displays a illuminance data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/illuminance_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/illuminance_chart_card_with_background.json index 7849cad5e5..1e3e0a6545 100644 --- a/application/src/main/data/json/system/widget_types/illuminance_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/illuminance_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "illuminance_chart_card_with_background", "name": "Illuminance chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/illuminance_chart_card_with_background_system_widget_image.png", "description": "Displays a illuminance data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/illuminance_progress_bar.json b/application/src/main/data/json/system/widget_types/illuminance_progress_bar.json index de3b03cf95..f85547d66a 100644 --- a/application/src/main/data/json/system/widget_types/illuminance_progress_bar.json +++ b/application/src/main/data/json/system/widget_types/illuminance_progress_bar.json @@ -1,7 +1,7 @@ { "fqn": "illuminance_progress_bar", "name": "Illuminance progress bar", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/illuminance_progress_bar_system_widget_image.png", "description": "Displays illuminance reading as a horizontal progress bar. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/illuminance_progress_bar_with_background.json b/application/src/main/data/json/system/widget_types/illuminance_progress_bar_with_background.json index 181208cb24..941617becb 100644 --- a/application/src/main/data/json/system/widget_types/illuminance_progress_bar_with_background.json +++ b/application/src/main/data/json/system/widget_types/illuminance_progress_bar_with_background.json @@ -1,7 +1,7 @@ { "fqn": "illuminance_progress_bar_with_background", "name": "Illuminance progress bar with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/illuminance_progress_bar_with_background_system_widget_image.png", "description": "Displays illuminance reading as a horizontal progress bar with background. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__card.json b/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__card.json index a578136d2b..b2736d88b7 100644 --- a/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__card.json +++ b/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__card.json @@ -1,7 +1,7 @@ { "fqn": "individual_allergy_index_card", "name": "Individual allergy index (IAI) card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/IAI-value-card.svg", "description": "Indicates the concentration of airborne allergens, including pollen and mold spores, which can trigger allergic reactions in sensitive individuals.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__card_with_background.json b/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__card_with_background.json index 7f6cfe39a5..76dd5bd73a 100644 --- a/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__card_with_background.json +++ b/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "individual_allergy_index_card_with_background", "name": "Individual allergy index (IAI) card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/IAI-value-card-with-background.svg", "description": "Indicates the concentration of airborne allergens, including pollen and mold spores, which can trigger allergic reactions in sensitive individuals.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__chart_card.json b/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__chart_card.json index 13d72efffe..1face8a5e2 100644 --- a/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__chart_card.json +++ b/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__chart_card.json @@ -1,7 +1,7 @@ { "fqn": "individual_allergy_index_iai_chart_card", "name": "Individual allergy index (IAI) chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/IAI-value-chart-card.svg", "description": "Indicates the concentration of airborne allergens, including pollen and mold spores, which can trigger allergic reactions in sensitive individuals by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__chart_card_with_background.json b/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__chart_card_with_background.json index 2fcbe2bed8..39aa442b03 100644 --- a/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/individual_allergy_index__iai__chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "individual_allergy_index_iai_chart_card_with_background", "name": "Individual allergy index (IAI) chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/IAI-value-chart-card-with-background.svg", "description": "Indicates the concentration of airborne allergens, including pollen and mold spores, which can trigger allergic reactions in sensitive individuals by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_co2_card.json b/application/src/main/data/json/system/widget_types/indoor_co2_card.json index 1133460238..a066aa2a12 100644 --- a/application/src/main/data/json/system/widget_types/indoor_co2_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_co2_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_co2_card", "name": "Indoor CO2 card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_co2_card_system_widget_image.png", "description": "Displays the latest indoor CO2 level telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_co2_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_co2_card_with_background.json index e1ddcb3b6b..25019aef1d 100644 --- a/application/src/main/data/json/system/widget_types/indoor_co2_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_co2_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_co2_card_with_background", "name": "Indoor CO2 card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_co2_card_with_background_system_widget_image.png", "description": "Displays the latest indoor CO2 level telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_co2_chart_card.json b/application/src/main/data/json/system/widget_types/indoor_co2_chart_card.json index ba1df65908..f4776e2601 100644 --- a/application/src/main/data/json/system/widget_types/indoor_co2_chart_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_co2_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_co2_chart_card", "name": "Indoor CO2 chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_co2_chart_card_system_widget_image.png", "description": "Displays a indoor CO2 level data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_co2_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_co2_chart_card_with_background.json index c4abd4a0b6..a4adcfbe14 100644 --- a/application/src/main/data/json/system/widget_types/indoor_co2_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_co2_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_co2_chart_card_with_background", "name": "Indoor CO2 chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_co2_chart_card_with_background_system_widget_image.png", "description": "Displays a indoor CO2 level data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_horizontal_co2_card.json b/application/src/main/data/json/system/widget_types/indoor_horizontal_co2_card.json index b453998ddb..6234b2554a 100644 --- a/application/src/main/data/json/system/widget_types/indoor_horizontal_co2_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_horizontal_co2_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_horizontal_co2_card", "name": "Indoor horizontal CO2 card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_horizontal_co2_card_system_widget_image.png", "description": "Displays the latest indoor CO2 level telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_horizontal_co2_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_horizontal_co2_card_with_background.json index 4ca050fd88..eee2ed64a6 100644 --- a/application/src/main/data/json/system/widget_types/indoor_horizontal_co2_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_horizontal_co2_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_horizontal_co2_card_with_background", "name": "Indoor horizontal CO2 card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_horizontal_co2_card_with_background_system_widget_image.png", "description": "Displays the latest indoor CO2 level telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_horizontal_humidity_card.json b/application/src/main/data/json/system/widget_types/indoor_horizontal_humidity_card.json index 0c62fdfe5b..918084de45 100644 --- a/application/src/main/data/json/system/widget_types/indoor_horizontal_humidity_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_horizontal_humidity_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_horizontal_humidity_card", "name": "Indoor horizontal humidity card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_horizontal_humidity_card_system_widget_image.png", "description": "Displays the latest indoor humidity telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_horizontal_humidity_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_horizontal_humidity_card_with_background.json index 04f8faefcb..8f274993e5 100644 --- a/application/src/main/data/json/system/widget_types/indoor_horizontal_humidity_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_horizontal_humidity_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_horizontal_humidity_card_with_background", "name": "Indoor horizontal humidity card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_horizontal_humidity_card_with_background_system_widget_image.png", "description": "Displays the latest indoor humidity telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_horizontal_illuminance_card.json b/application/src/main/data/json/system/widget_types/indoor_horizontal_illuminance_card.json index b921411cd8..b3776fcedd 100644 --- a/application/src/main/data/json/system/widget_types/indoor_horizontal_illuminance_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_horizontal_illuminance_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_horizontal_illuminance_card", "name": "Indoor horizontal illuminance card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_horizontal_illuminance_card_system_widget_image.png", "description": "Displays the latest indoor illuminance telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_horizontal_illuminance_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_horizontal_illuminance_card_with_background.json index d81be63a9c..c68c2775cb 100644 --- a/application/src/main/data/json/system/widget_types/indoor_horizontal_illuminance_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_horizontal_illuminance_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_horizontal_illuminance_card_with_background", "name": "Indoor horizontal illuminance card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_horizontal_illuminance_card_with_background_system_widget_image.png", "description": "Displays the latest indoor illuminance telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_horizontal_pm10_card.json b/application/src/main/data/json/system/widget_types/indoor_horizontal_pm10_card.json index 5d315d0e01..d32018de8e 100644 --- a/application/src/main/data/json/system/widget_types/indoor_horizontal_pm10_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_horizontal_pm10_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_horizontal_pm10_card", "name": "Indoor horizontal PM10 card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_horizontal_pm10_card_system_widget_image.png", "description": "Displays the latest indoor fine and coarse particulate matter (PM10) telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_horizontal_pm10_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_horizontal_pm10_card_with_background.json index ea3833494c..18318d08b6 100644 --- a/application/src/main/data/json/system/widget_types/indoor_horizontal_pm10_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_horizontal_pm10_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_horizontal_pm10_card_with_background", "name": "Indoor horizontal PM10 card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_horizontal_pm10_card_with_background_system_widget_image.png", "description": "Displays the latest indoor fine and coarse particulate matter (PM10) telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_horizontal_pm2_5_card.json b/application/src/main/data/json/system/widget_types/indoor_horizontal_pm2_5_card.json index 5ae0d04108..502882696b 100644 --- a/application/src/main/data/json/system/widget_types/indoor_horizontal_pm2_5_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_horizontal_pm2_5_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_horizontal_pm2_5_card", "name": "Indoor horizontal PM2.5 card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_horizontal_pm2_5_card_system_widget_image.png", "description": "Displays the latest indoor fine particulate matter (PM2.5) telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_horizontal_pm2_5_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_horizontal_pm2_5_card_with_background.json index 6e47de67a8..a691db2695 100644 --- a/application/src/main/data/json/system/widget_types/indoor_horizontal_pm2_5_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_horizontal_pm2_5_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_horizontal_pm2_5_card_with_background", "name": "Indoor horizontal PM2.5 card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_horizontal_pm2_5_card_with_background_system_widget_image.png", "description": "Displays the latest indoor fine particulate matter (PM2.5) telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_horizontal_temperature_card.json b/application/src/main/data/json/system/widget_types/indoor_horizontal_temperature_card.json index 4b5f76317c..47eb89501a 100644 --- a/application/src/main/data/json/system/widget_types/indoor_horizontal_temperature_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_horizontal_temperature_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_horizontal_temperature_card", "name": "Indoor horizontal temperature card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_horizontal_temperature_card_system_widget_image.png", "description": "Displays the latest indoor temperature telemetry in a scalable horizontal layout.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_horizontal_temperature_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_horizontal_temperature_card_with_background.json index 1b1231720f..a49bd7e292 100644 --- a/application/src/main/data/json/system/widget_types/indoor_horizontal_temperature_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_horizontal_temperature_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_horizontal_temperature_card_with_background", "name": "Indoor horizontal temperature card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_horizontal_temperature_card_with_background_system_widget_image.png", "description": "Displays the latest indoor temperature telemetry in a scalable horizontal layout with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_humidity_card.json b/application/src/main/data/json/system/widget_types/indoor_humidity_card.json index 1310fcf9e0..84b45af23d 100644 --- a/application/src/main/data/json/system/widget_types/indoor_humidity_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_humidity_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_humidity_card", "name": "Indoor humidity card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_humidity_card_system_widget_image.png", "description": "Displays the latest indoor humidity telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_humidity_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_humidity_card_with_background.json index 998d0cbe8b..4ebffddbdc 100644 --- a/application/src/main/data/json/system/widget_types/indoor_humidity_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_humidity_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_humidity_card_with_background", "name": "Indoor humidity card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_humidity_card_with_background_system_widget_image.png", "description": "Displays the latest indoor humidity telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_humidity_chart_card.json b/application/src/main/data/json/system/widget_types/indoor_humidity_chart_card.json index 267cc65a5f..b3f0909bc0 100644 --- a/application/src/main/data/json/system/widget_types/indoor_humidity_chart_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_humidity_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_humidity_chart_card", "name": "Indoor humidity chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_humidity_chart_card_system_widget_image.png", "description": "Displays a indoor humidity data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_humidity_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_humidity_chart_card_with_background.json index 82c9bbeaf6..2517b1b1d0 100644 --- a/application/src/main/data/json/system/widget_types/indoor_humidity_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_humidity_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_humidity_chart_card_with_background", "name": "Indoor humidity chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_humidity_chart_card_with_background_system_widget_image.png", "description": "Displays a indoor humidity data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_humidity_progress_bar.json b/application/src/main/data/json/system/widget_types/indoor_humidity_progress_bar.json index bc02349832..1f5e04ed77 100644 --- a/application/src/main/data/json/system/widget_types/indoor_humidity_progress_bar.json +++ b/application/src/main/data/json/system/widget_types/indoor_humidity_progress_bar.json @@ -1,7 +1,7 @@ { "fqn": "indoor_humidity_progress_bar", "name": "Indoor humidity progress bar", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_humidity_progress_bar_system_widget_image.png", "description": "Displays indoor humidity reading as a horizontal progress bar. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_humidity_progress_bar_with_background.json b/application/src/main/data/json/system/widget_types/indoor_humidity_progress_bar_with_background.json index 8f3f721ec5..1cb3a1dd52 100644 --- a/application/src/main/data/json/system/widget_types/indoor_humidity_progress_bar_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_humidity_progress_bar_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_humidity_progress_bar_with_background", "name": "Indoor humidity progress bar with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_humidity_progress_bar_with_background_system_widget_image.png", "description": "Displays indoor humidity reading as a horizontal progress bar with background. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_illuminance_card.json b/application/src/main/data/json/system/widget_types/indoor_illuminance_card.json index 73732e8f4b..648007f40e 100644 --- a/application/src/main/data/json/system/widget_types/indoor_illuminance_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_illuminance_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_illuminance_card", "name": "Indoor illuminance card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_illuminance_card_system_widget_image.png", "description": "Displays the latest indoor illuminance telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_illuminance_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_illuminance_card_with_background.json index b6f143b9f8..e34daef9a7 100644 --- a/application/src/main/data/json/system/widget_types/indoor_illuminance_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_illuminance_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_illuminance_card_with_background", "name": "Indoor illuminance card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_illuminance_card_with_background_system_widget_image.png", "description": "Displays the latest indoor illuminance telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_illuminance_chart_card.json b/application/src/main/data/json/system/widget_types/indoor_illuminance_chart_card.json index 9ea21c112d..bad4adb97d 100644 --- a/application/src/main/data/json/system/widget_types/indoor_illuminance_chart_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_illuminance_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_illuminance_chart_card", "name": "Indoor illuminance chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_illuminance_chart_card_system_widget_image.png", "description": "Displays a indoor illuminance data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_illuminance_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_illuminance_chart_card_with_background.json index 6badbfbfa0..5df2e86c84 100644 --- a/application/src/main/data/json/system/widget_types/indoor_illuminance_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_illuminance_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_illuminance_chart_card_with_background", "name": "Indoor illuminance chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_illuminance_chart_card_with_background_system_widget_image.png", "description": "Displays a indoor illuminance data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_illuminance_progress_bar.json b/application/src/main/data/json/system/widget_types/indoor_illuminance_progress_bar.json index d58ec4f73d..027e771121 100644 --- a/application/src/main/data/json/system/widget_types/indoor_illuminance_progress_bar.json +++ b/application/src/main/data/json/system/widget_types/indoor_illuminance_progress_bar.json @@ -1,7 +1,7 @@ { "fqn": "indoor_illuminance_progress_bar", "name": "Indoor illuminance progress bar", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_illuminance_progress_bar_system_widget_image.png", "description": "Displays indoor illuminance reading as a horizontal progress bar. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_illuminance_progress_bar_with_background.json b/application/src/main/data/json/system/widget_types/indoor_illuminance_progress_bar_with_background.json index 676428dbcd..7cb00cb4e6 100644 --- a/application/src/main/data/json/system/widget_types/indoor_illuminance_progress_bar_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_illuminance_progress_bar_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_illuminance_progress_bar_with_background", "name": "Indoor illuminance progress bar with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_illuminance_progress_bar_with_background_system_widget_image.png", "description": "Displays indoor illuminance reading as a horizontal progress bar with background. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_pm10_card.json b/application/src/main/data/json/system/widget_types/indoor_pm10_card.json index 1a58d29bd4..d042fe5e69 100644 --- a/application/src/main/data/json/system/widget_types/indoor_pm10_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_pm10_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_pm10_card", "name": "Indoor PM10 card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_pm10_card_system_widget_image.png", "description": "Displays the latest indoor fine and coarse particulate matter (PM10) telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_pm10_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_pm10_card_with_background.json index 14f1445ebc..eead478319 100644 --- a/application/src/main/data/json/system/widget_types/indoor_pm10_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_pm10_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_pm10_card_with_background", "name": "Indoor PM10 card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_pm10_card_with_background_system_widget_image.png", "description": "Displays the latest indoor fine and coarse particulate matter (PM10) telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_pm10_chart_card.json b/application/src/main/data/json/system/widget_types/indoor_pm10_chart_card.json index b04b7063cf..2f88f05151 100644 --- a/application/src/main/data/json/system/widget_types/indoor_pm10_chart_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_pm10_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_pm10_chart_card", "name": "Indoor PM10 chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_pm10_chart_card_system_widget_image.png", "description": "Displays a indoor fine and coarse particulate matter (PM10) data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_pm10_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_pm10_chart_card_with_background.json index 9eb69e6a26..d29a9d09d4 100644 --- a/application/src/main/data/json/system/widget_types/indoor_pm10_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_pm10_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_pm10_chart_card_with_background", "name": "Indoor PM10 chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_pm10_chart_card_with_background_system_widget_image.png", "description": "Displays a indoor fine and coarse particulate matter (PM10) data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_pm2_5_card.json b/application/src/main/data/json/system/widget_types/indoor_pm2_5_card.json index 990b7caecc..74044c2cbf 100644 --- a/application/src/main/data/json/system/widget_types/indoor_pm2_5_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_pm2_5_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_pm2_5_card", "name": "Indoor PM2.5 card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_pm2_5_card_system_widget_image.png", "description": "Displays the latest indoor fine particulate matter (PM2.5) telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_pm2_5_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_pm2_5_card_with_background.json index ef1fde22b2..43039d7a1f 100644 --- a/application/src/main/data/json/system/widget_types/indoor_pm2_5_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_pm2_5_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_pm2_5_card_with_background", "name": "Indoor PM2.5 card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_pm2_5_card_with_background_system_widget_image.png", "description": "Displays the latest indoor fine particulate matter (PM2.5) telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_pm2_5_chart_card.json b/application/src/main/data/json/system/widget_types/indoor_pm2_5_chart_card.json index 82d4e3af9a..d2507c6942 100644 --- a/application/src/main/data/json/system/widget_types/indoor_pm2_5_chart_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_pm2_5_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_pm2_5_chart_card", "name": "Indoor PM2.5 chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_pm2_5_chart_card_system_widget_image.png", "description": "Displays a indoor fine particulate matter (PM2.5) data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_pm2_5_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_pm2_5_chart_card_with_background.json index 9ee11386e6..32b78f75a6 100644 --- a/application/src/main/data/json/system/widget_types/indoor_pm2_5_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_pm2_5_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_pm2_5_chart_card_with_background", "name": "Indoor PM2.5 chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_pm2_5_chart_card_with_background_system_widget_image.png", "description": "Displays a indoor fine particulate matter (PM2.5) data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_simple_co2_chart_card.json b/application/src/main/data/json/system/widget_types/indoor_simple_co2_chart_card.json index 0a442fa4e0..8d783e8a3b 100644 --- a/application/src/main/data/json/system/widget_types/indoor_simple_co2_chart_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_simple_co2_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_simple_co2_chart_card", "name": "Indoor simple CO2 chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_simple_co2_chart_card_system_widget_image.png", "description": "Displays historical indoor CO2 level values as a simplified chart. Optionally may display the corresponding latest indoor CO2 level value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_simple_co2_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_simple_co2_chart_card_with_background.json index 537f95b673..9c0f5f4f82 100644 --- a/application/src/main/data/json/system/widget_types/indoor_simple_co2_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_simple_co2_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_simple_co2_chart_card_with_background", "name": "Indoor simple CO2 chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_simple_co2_chart_card_with_background_system_widget_image.png", "description": "Displays historical indoor CO2 level values as a simplified chart with background. Optionally may display the corresponding latest indoor CO2 level value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_simple_humidity_chart_card.json b/application/src/main/data/json/system/widget_types/indoor_simple_humidity_chart_card.json index eb278e4c0f..85d089175e 100644 --- a/application/src/main/data/json/system/widget_types/indoor_simple_humidity_chart_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_simple_humidity_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_simple_humidity_chart_card", "name": "Indoor simple humidity chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_simple_humidity_chart_card_system_widget_image.png", "description": "Displays historical indoor humidity values as a simplified chart. Optionally may display the corresponding latest indoor humidity value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_simple_humidity_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_simple_humidity_chart_card_with_background.json index eda7d8919d..56089749a1 100644 --- a/application/src/main/data/json/system/widget_types/indoor_simple_humidity_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_simple_humidity_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_simple_humidity_chart_card_with_background", "name": "Indoor simple humidity chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_simple_humidity_chart_card_with_background_system_widget_image.png", "description": "Displays historical indoor humidity values as a simplified chart with background. Optionally may display the corresponding latest indoor humidity value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_simple_illuminance_chart_card.json b/application/src/main/data/json/system/widget_types/indoor_simple_illuminance_chart_card.json index 4362cdf807..f6ee30f395 100644 --- a/application/src/main/data/json/system/widget_types/indoor_simple_illuminance_chart_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_simple_illuminance_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_simple_illuminance_chart_card", "name": "Indoor simple illuminance chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_simple_illuminance_chart_card_system_widget_image.png", "description": "Displays historical indoor illuminance values as a simplified chart. Optionally may display the corresponding latest indoor illuminance value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_simple_illuminance_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_simple_illuminance_chart_card_with_background.json index 5652a3693f..3f873c6c20 100644 --- a/application/src/main/data/json/system/widget_types/indoor_simple_illuminance_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_simple_illuminance_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_simple_illuminance_chart_card_with_background", "name": "Indoor simple illuminance chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_simple_illuminance_chart_card_with_background_system_widget_image.png", "description": "Displays historical indoor illuminance values as a simplified chart with background. Optionally may display the corresponding latest indoor illuminance value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_simple_pm10_chart_card.json b/application/src/main/data/json/system/widget_types/indoor_simple_pm10_chart_card.json index 995181bff6..76092d54c6 100644 --- a/application/src/main/data/json/system/widget_types/indoor_simple_pm10_chart_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_simple_pm10_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_simple_pm10_chart_card", "name": "Indoor simple PM10 chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_simple_pm10_chart_card_system_widget_image.png", "description": "Displays historical indoor fine and coarse particulate matter (PM10) values as a simplified chart. Optionally may display the corresponding latest indoor PM10 value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_simple_pm10_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_simple_pm10_chart_card_with_background.json index 26b4c74ca6..7ce70c7db5 100644 --- a/application/src/main/data/json/system/widget_types/indoor_simple_pm10_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_simple_pm10_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_simple_pm10_chart_card_with_background", "name": "Indoor simple PM10 chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_simple_pm10_chart_card_with_background_system_widget_image.png", "description": "Displays historical indoor fine and coarse particulate matter (PM10) values as a simplified chart with background. Optionally may display the corresponding latest indoor PM10 value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_simple_pm2_5_chart_card.json b/application/src/main/data/json/system/widget_types/indoor_simple_pm2_5_chart_card.json index 550bb8d03e..f949e1a067 100644 --- a/application/src/main/data/json/system/widget_types/indoor_simple_pm2_5_chart_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_simple_pm2_5_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_simple_pm2_5_chart_card", "name": "Indoor simple PM2.5 chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_simple_pm2_5_chart_card_system_widget_image.png", "description": "Displays historical indoor fine particulate matter (PM2.5) values as a simplified chart. Optionally may display the corresponding latest indoor PM2.5 value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_simple_pm2_5_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_simple_pm2_5_chart_card_with_background.json index 368e31b0f6..8cf7d64949 100644 --- a/application/src/main/data/json/system/widget_types/indoor_simple_pm2_5_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_simple_pm2_5_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_simple_pm2_5_chart_card_with_background", "name": "Indoor simple PM2.5 chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_simple_pm2_5_chart_card_with_background_system_widget_image.png", "description": "Displays historical indoor fine particulate matter (PM2.5) values as a simplified chart with background. Optionally may display the corresponding latest indoor PM2.5 value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_simple_temperature_chart_card.json b/application/src/main/data/json/system/widget_types/indoor_simple_temperature_chart_card.json index 3b118b8994..4f90b1ffa0 100644 --- a/application/src/main/data/json/system/widget_types/indoor_simple_temperature_chart_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_simple_temperature_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_simple_temperature_chart_card", "name": "Indoor simple temperature chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_simple_temperature_chart_card_system_widget_image.png", "description": "Displays historical indoor temperature values as a simplified chart. Optionally may display the corresponding latest indoor temperature value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_simple_temperature_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_simple_temperature_chart_card_with_background.json index e62cbba7fd..016efeda0d 100644 --- a/application/src/main/data/json/system/widget_types/indoor_simple_temperature_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_simple_temperature_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_simple_temperature_chart_card_with_background", "name": "Indoor simple temperature chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_simple_temperature_chart_card_with_background_system_widget_image.png", "description": "Displays historical indoor temperature values as a simplified chart with background. Optionally may display the corresponding latest indoor temperature value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_temperature_card.json b/application/src/main/data/json/system/widget_types/indoor_temperature_card.json index 5f55ace191..5faf1891d7 100644 --- a/application/src/main/data/json/system/widget_types/indoor_temperature_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_temperature_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_temperature_card", "name": "Indoor temperature card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_temperature_card_system_widget_image.png", "description": "Displays the latest indoor temperature telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_temperature_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_temperature_card_with_background.json index 23466d55ec..6e33a75f37 100644 --- a/application/src/main/data/json/system/widget_types/indoor_temperature_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_temperature_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_temperature_card_with_background", "name": "Indoor temperature card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_temperature_card_with_background_system_widget_image.png", "description": "Displays the latest indoor temperature telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_temperature_chart_card.json b/application/src/main/data/json/system/widget_types/indoor_temperature_chart_card.json index c24cb6e5eb..750842be0e 100644 --- a/application/src/main/data/json/system/widget_types/indoor_temperature_chart_card.json +++ b/application/src/main/data/json/system/widget_types/indoor_temperature_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "indoor_temperature_chart_card", "name": "Indoor temperature chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_temperature_chart_card_system_widget_image.png", "description": "Displays a indoor temperature data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_temperature_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/indoor_temperature_chart_card_with_background.json index 96e606d3b7..69d97ebbf3 100644 --- a/application/src/main/data/json/system/widget_types/indoor_temperature_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_temperature_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_temperature_chart_card_with_background", "name": "Indoor temperature chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_temperature_chart_card_with_background_system_widget_image.png", "description": "Displays a indoor temperature data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_temperature_gauge.json b/application/src/main/data/json/system/widget_types/indoor_temperature_gauge.json index a935befa6a..429a3b1c9e 100644 --- a/application/src/main/data/json/system/widget_types/indoor_temperature_gauge.json +++ b/application/src/main/data/json/system/widget_types/indoor_temperature_gauge.json @@ -1,7 +1,7 @@ { "fqn": "indoor_temperature_gauge", "name": "Indoor temperature gauge", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_temperature_gauge.svg", "description": "Indoor temperatue gauge.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_temperature_progress_bar.json b/application/src/main/data/json/system/widget_types/indoor_temperature_progress_bar.json index ae5196ce89..01c7174de8 100644 --- a/application/src/main/data/json/system/widget_types/indoor_temperature_progress_bar.json +++ b/application/src/main/data/json/system/widget_types/indoor_temperature_progress_bar.json @@ -1,7 +1,7 @@ { "fqn": "indoor_temperature_progress_bar", "name": "Indoor temperature progress bar", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_temperature_progress_bar_system_widget_image.png", "description": "Displays indoor temperature reading as a horizontal progress bar. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_temperature_progress_bar_with_background.json b/application/src/main/data/json/system/widget_types/indoor_temperature_progress_bar_with_background.json index e2b6443efe..9f954dbc3b 100644 --- a/application/src/main/data/json/system/widget_types/indoor_temperature_progress_bar_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_temperature_progress_bar_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_temperature_progress_bar_with_background", "name": "Indoor temperature progress bar with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_temperature_progress_bar_with_background_system_widget_image.png", "description": "Displays indoor temperature reading as a horizontal progress bar with background. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_temperature_range_chart.json b/application/src/main/data/json/system/widget_types/indoor_temperature_range_chart.json index 4dcf322cb7..84cd1c9c7e 100644 --- a/application/src/main/data/json/system/widget_types/indoor_temperature_range_chart.json +++ b/application/src/main/data/json/system/widget_types/indoor_temperature_range_chart.json @@ -1,7 +1,7 @@ { "fqn": "indoor_temperature_range_chart", "name": "Indoor temperature range chart", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_temperature_range_chart.svg", "description": "Displays changes to indoor temperature data over time visualized with color ranges.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/indoor_temperature_range_chart_with_background.json b/application/src/main/data/json/system/widget_types/indoor_temperature_range_chart_with_background.json index e8a0752aeb..07477b9c5e 100644 --- a/application/src/main/data/json/system/widget_types/indoor_temperature_range_chart_with_background.json +++ b/application/src/main/data/json/system/widget_types/indoor_temperature_range_chart_with_background.json @@ -1,7 +1,7 @@ { "fqn": "indoor_temperature_range_chart_with_background", "name": "Indoor temperature range chart with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/indoor_temperature_range_chart_with_background.svg", "description": "Displays changes to indoor temperature data over time visualized with color ranges and background.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/iot_hub.json b/application/src/main/data/json/system/widget_types/iot_hub.json new file mode 100644 index 0000000000..b6cfb28f60 --- /dev/null +++ b/application/src/main/data/json/system/widget_types/iot_hub.json @@ -0,0 +1,39 @@ +{ + "fqn": "home_page_widgets.iot_hub", + "name": "IoT Hub", + "deprecated": false, + "image": "tb-image;/api/images/system/iot_hub_system_widget_image.png", + "description": "Display featured solution templates and device library from IoT Hub.", + "descriptor": { + "type": "static", + "sizeX": 7.5, + "sizeY": 6.5, + "resources": [], + "templateHtml": "\n\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}", + "settingsDirective": "", + "defaultConfig": "{\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"IoT Hub\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"showLegend\":false}" + }, + "resources": [ + { + "link": "/api/images/system/iot_hub_system_widget_image.png", + "title": "\"IoT Hub\" system widget image", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "iot_hub_system_widget_image.png", + "publicResourceKey": "XqwXyzWV9EsIEzcq7967Xrru0fycYRlS", + "mediaType": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAYAAABJ/yOpAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAOdEVYdFNvZnR3YXJlAEZpZ21hnrGWYwAARGpJREFUeAHtfQmcZUV57//ue/ftfZmtZx+QZQYEBBkBWQTcUFmSXxJlsjyT58MlL76Ixpeo0cTkZwzRl2cWJclTo0ISRZRNAR1kh5kBBhhm7+npvfv27bvv7//VPdV95tLL7b49THfP+c+v5vY5p06dc6rqq2+tKhvKcDI1MbmZ7LBwqhBgSsDCqUaSKcKUlwMhjlVMYVjEcaoRgoVTDaGBOpRpwulAmXPIqBVlKsHCqYSHKQsLpxJCAxmUCSUk//mZ4rBgwYIZ40xuLVIVYcGCBTOEJuyWzmHBwgywCMSChRlgEYgFCzPAIhALFmaARSAWLMwAZxV5vEzbmdYx9TE9yJSuyNPA9PtMfzHN/T6UPZOS51+muH86vJnp/IpzPzDKmi9uZ/rGFGXIezYy9WLu2DHN+Ttx8rCe6SqU63Iv07NV3CPt8GGmv0X1eLPxq8uXtr7aOJbff8DC4lTU5bSohkCEOKRSpCJuRrnCHmPqRLnCD1bkX2+cazCOLzXy3cP0ECaJo9P4lQ6piUhSCpOd9yWjrHczPc903LhmfnblvZJWVFyTjj+K1xOFuZwLmNYy/XiKZwDTf6/A3HjyrCuZgji5kPqVunsO5TqWb33M+JXvl++VutCDU4Nx/JhxvzmfrpepvrGh4rkp45kw3aPbTP+t85nL7zS9D4x312Wb2+VU1OW0EE+6hJhEZ8gjH3Qxyh/xKFM3yiPHVqYWlEf411AmHBnJPoRyI2jCkkoKGXn0NfmVCttsXJOKeI/xvPdgshElFiZt5JPn9hrPudwoW8qoq7g3ZORvNa7dbJQh1541vkV+bzHKeAvKjtJ1xj3ynlcYx0LsLuNvSauNZx7C1NAN+nOmLUy7MTfMxZPeabzbHpQ74TVM+5h+w7gubSRtdh3KHfoTRl75bqnfT6LsNZbrIhloKUF/s+ak641f/c2rjbIPGfdI3b7bKON6lPvDENOtxnu938j7HuPamHHuWeM3buSvRK11uRAIV6ODSKcXkeRNTB9HuWHkb+EGIu4IAXinuVezf6kgPUr4jDLk3nuMsvRzHjL+bsD0eLNRVgST7L/XdK80/k5TGYeMazIqrjeV85Dp7w6UG6zXSJca5cuId6aRRxPGTkwNc4OO4tTgrIpjqX95L3l3M6dYZxwLtxTJQDjzVN88G3S9CwF2GOfuRbktpX2lDWyY5BpyTepZ2mKFcX3vFOUuhrpUqEbE0qPFD4y/pUObdQgfqtcpNLwVv3NB0ngfqWSp3M4q75Nnmd9TuNg3MPW7R4yytbggz5DOJN8uHOnfKvKf6gbdgMkRX95dOuFe413kb3lnM2FLHt1pfaZz5m+uBr4Zzt+Esq4z1WCnOfhU4uqiIQ5BNQQilSYsVBRCeXnpVL3GORkdKite2LZ0PqkYGc3lI4Ww+ow8Wg7+sFGecBEfqsfPjGcL9ziAMsueCTJainFAxJeDFe/6HuM9Dxrvtd4od6fxDfKdLxnvJ9+Qxusb9VQ2qLyrEK28uwxg8n4fw+R3SVtpZdosFkaMY91O/4rXf/NzFc9ZZ/xtPu+rKKOSg91i3PdyxflDxr3PVZxfVMShsabKfN4qz011fi73Votq7peGvRnVP8s7x2OBzKFpxMJgocLd5/u9c7m3mvzTXROO/OEpzi9kXS4E1kz8t0who9f5WDo4XeaDyKC1Dosfy55AlhqsCVOLC2ssT7oFCzPAIhALFmaARSAWLMwAi0AsWJgBFoFYsDADqnEUoq+vr8vhcEgQ2dZSqRSGhbnih8Vi8RMdHR1HsACIRCIT7YFyLJ2FueGHhULhEw0NDUdmyyiecDHzHp0ug0Ecu1wuV5gJNpsNFqoHBxTk83lkMpkxEsm2WYhEzLyxmcoziGOXx+Ox2mMe0O2RTqfHSCTbZiGS2c28drv9q263O8xkNcY8IHUmHVnq0Bj1awLL+KrX67XaY56Ya3tUo4Pc4HRWJYlZmAHSKBy9tqJ2WO2xAJABBmURdUZUVdMzjVRkUyC7xxsNiixIZtIYz2fgJZm3BBtQRAmFYh5xXvPKopE2B1wOp/BV5AtpOB0+pNIJ9b5OuxM5nnfZHUrOLKkYSxuS2TgcHDeKpfJSYW6XF7l8Vl33ewIsJ49kOoY6fwP0QpR8Ku9kefkCpuu8Rh0uiL6wWDnH0e5B3Hvvc3jn9VvR1dWBxYxq26PmoUgIZGBgAH6/H01NTTiZkGdRdhSxD/v27YO9PoDVre3YfehJ2Fdsgf/wcSTSKRRKdhy0F5Hu6MCqYADIRZAplNAebILd5iYRpdmpHZRFnciSwKRc6gdobKyHz+3Bwb6XkcmlVSV6nF6sbFqHRC6FXCGDfUeeQ1N9K9wkQDev2W1FFPLjcLrqmd+B0xVHukdw7jkfwoYNq/GLJw/jr778u+jqcGOpY95mXlF2pLPKiElFR50bHR1Ff3//CXlGRkZU5xNks1lRMlWSvzUSiYQ6F4vNqJ+K/I1AIACfz4fzzjtPlR8OhNCXsmFP7wEcxijyh7pJDGnUtQbQlIwjGemG3xWGx1YgIRQRG4+hkOOH28gl8mkSSBopEonDWUIymSBx2ZDNZZDLZZk/j9H4EEZiQxiIx0lUNjSEmpHOpnF0YD/6I8f4kTlyJi/LHOffMy9Qaf7m5Yav/f3P8KlPfRDPPfsPuOwtrfjN2x7FcsC8CUREHOnUuVwOVBpRV1enOm9ra6siCEnSgevr6yeOZeSXzh0MBtXf+ryIPMKBpBx9rpokxPnInmeQZ1kDGeDVjA3+p57B8YIT+SzLDZbg9tgxFD3C5zaoZ9vtNsUx4qkon+dEuC6E5oZ6BPhOHo8bLnINr8uHeHIMGRJCnoSSysbgJ9fpG3qN9zuwtmMLgr46DI71KG6UJ8XlSiLSzSz6SH0tV6xa14rbP/Xr6u/h4SR+9cBOHDkWw1LHvAlElBwhCBnV4xxdNTeRjq+TLZmEg1xBn5eOKSY2OTbnlfNCaKLImu83Jxl9hSjlV0Q6OVcSQmEHRrGg3mncF8Zrv3crBvheRZmKULAjlcijo34tsrEExseG4AvIFhwk3lxRlSf6RYLvlEhHEYkPsJOX0FzfRq5AIuO1oK+e3+gkJ+mHw+ZBR/MaBLxBng/zsQXqQUn0jvZjOCrTqmcmkFBo+QbrNjdOrqswMkLxNB/Fgf39WOqYN4FIBxWuIQQiXEJzBeEaOkE6I6/pY01UAnM+fd58rjJpQ4D8hsNl3UrEoY2r15GbMA91DHeRnb2UgtfnxTEbrUYF+gmKbrh53NgUVqLX2PgRlMgFCgV2ZopZ0VQEtixFPopTLuoUwjVC7PxtjasQouIfIoH4nD5FFF6PD/miXYlgNn5rfV0ruoeOo4U6yYoWcSfNvHuEcMnliGw2j/1HJ7mFw8GBjm06Np7HUseChJqI7iH6gyi15gTK9EXTeeEyY2Njr8uXJKcRDlJ5XichEOEykgRDQ+VFMEouD+598TleL3MTOzmJWJ9WuZJopjVrLF1EIBSk5WqcPMOBzraNaKxbQUXcRuJuwNBgD/KJIXb4NAJuL+ppmfLwV46b2Pk3rTgbq9vWw85BwOcNKfEqnhxieUm+S476Dy1nVDtEXMuQkxRP091Vjh2P4unHX5k4vvW3L8O//+v7ceEFrVjqWBACaWxsVOJD5agPfwB2chl9LDqGjP6V+WRkNfwEUyYhEq2gC1atWlV+MM/7G8jF3DTMuoQrlDDIHhsI+uBzjKM1EEQmVkBP77GyeMdO7Q+EKQi5MZ7sRzDgIFcIYYS6wVhsjPpEkdds8NGcG6KO4aBVSohPOIrD7lKmXjHzOvksv7tOqRydTasVJzvcvw+pzOm5zUqR3//444c4+JW5SFODC5s3hMi9k6/Lm07ncPd9r+CZ5w5zUF389bUgBDLdyH8yk0bONmlKLBkizug4LU/xXqRLowjUUxGvbyJB0HpGi5RYr0oQBd8H8iyKTLR4+Z1wFEQpj1EMyxlE6VQEUCzmSAAlRTjCneKZBC1fWfpHXEoPicSHEWXZ2XxK+VZOR3g8Toynw3jo56+qY6/Xh4Ywxe7Q69d7+9XjL+PG687ABeevxR9/6htY7Fjy0bw5Ox15NlnnrMxpxNDan8pRL2inOTaJkUQfRSQq83TiFWnmjcRG0RvpQy7D0Y5iWIlJfm20QiXjg7CX8kgXsuQUGeX3EH+Jz+NHHcUpoRhR6o8NH6VlrJf+j7JIl6WhwE7iKJ2mO9iF69ysvwJGRssrKPX1R/DsroN0HA6/Lu+P7y0T0T//62P47vf2YLFjyROIjO0pRwhZu4eOc5pwaZ2KwofD42kMJQvIpJ1k6+PIZdnh6SXvHX2NXOCwErHo/KBo54aD3MDl9ZOQ/Ogf6EEqGcVwYoTOwhTLz8JJruNx++BxuNWWQyUSxTB9I8X8uHqDZJqigl2I9PSMjQoFPcqS+MorA+p4ZLiIm3/3cTzzwol+oR/c9Ssc74njqWeP449ufxTve9/bsNixbOaD2A1dRZzZ4gOJUc8YpUl4MFdAfbAV1ECoc9TR+03rV56WKNApmIuT44gZmR2fzr56WqP89JU4yZH8zqDiDtE0zcPULcYpSmVySbjtItJJSEtBIljQUhemHiMdJIvTdQ9UqXfxN716uMxBlI/L3Yq860Sr3b337qI4WsRvfvRRNIaK+NPPvheLHctGaNZdU/lNPEIERQmk4sDuougU5QkHhmJ9FJec9Hnk6eMoYSwXQYebvhw6BkviQSeHCQbDiEbTNAoU4RLHYYmNTvNwhh73KBV5j4uGB4pdQjwSfuIj15KQk0Ixg9MZq9evxWiuHGrk93MQcbrR0HjieoDP9G/C/udfo/GjF//8d9eia83it3ItGw5SsJ1I63baXD20Cq9we1BHJd3nEQsVHZJ5IQ6x1ZOKHDQFU8GWEJRiMUVTbUyJYQ20jAVDdcrvIT4QH4nITdu+KPSpQpxKu+geJQzQOZhhYT6nXRHN6YxVq5sRjZQXQ+xoD9G83421Ha4T8vjyCRUXd9P1HfitX7sISwELQiAzOfhOVqpEjmyjZNIBiuJ9txXg95YQy4wjSgdg2NvIewNoqGvHyBj1EnKRZCGGaKKf4lZaKebZXIJEEqOYNqZ8HTa7+G/G6CisRzjUorzrYt0i44DX7ecznegp1tGx2E5T8Okbhn7GSsCV7VeREitWNMJVHEJrMHdCnsjxV7F2bSP+/HPvx1LBgrToXMOvJcTDXrHBro6t0jFa4rcQD7sOTZFj8YXoGK/KZ5ZI66Koe4rpiTMF6hJjaXbo4pgaCZzuetTb2eld4vCLYiDSTynMCWeDhybeLInJR10zi3h8hMp7IxKRIXi8HopeaTS3NsBLmVo86h7+8g2VnyTD91nr57uQuByn8abBH//IZnz8ts0YHh5Ge3s7LrxwleLYGk/vGcCR7sN44Mcfp2jVjKWCmglER+rOBdLRpcNrL7kca4LRfg49r0L/Sh6BDmfRXnUzMnY/nBR/HKWcEThow7FkDqt8ZcIboaLdRC5QoM+irXk1zbh5NT+kztdJaYv2Knrz7eJ8DHbA5XSgLtyILIlZdBEbn9kUaqXpOKEIwcF/QR+tZ9RDvPY8+sdG4POtJmHhtIWtJL6icru893pZU3tywPjRw9249YNbcc2V1e6ssDhQE4EIccgIL6EmpwL2KThXgiZfDz24LlBppm6Roi7STXPvJtrq7VTcs5kU0pkkve1htDWtolNxEHk2qtfnQi5N8c2WQSLeQ2dXCPZCkFwjoAS3ZDJOgioh4G6kgzGJBlrGPC4v/SpD8NMb3xkQHjYzJ53PYLJUEK4Po5scomtNeSnk3/vgdtzxzT34wA1+pKPDePg/v49///ansNQwbwIRMUkIQ0JMJMJWwkUk5ORkQp4zPj6uwtZ3794Nz8qVxhWzTmJTnCRr99F0SwdgTuTgLA7G89gW9sNHa1aO3CE2HqUy3gx/c0B5w0eSKaxrOwOpRIp6RwoBP/WNgkQQp8kVxBRcULFX+YIfI+M5eJwejNEzX6BOEqe33W5wxJkgc2OWA4SDVxK7iMNNTS3qb+HuoZAf576pGbd96mE02Lrx91//6JISrTTmTSAi8oj4o+OkJFhRrxghEbcSdyVpISGN0NxcruSLL74Yzx87pv4OFqJKBBKkHQEVfiIKu+ggYurNZwtIUWs4Go9gU70LjeF2DCdHlSc8SGI7SgJZ07lFhaEEggEq8FGkbRFyoQI5TgyJ4RiaGrsM/mCjcu4zRD0q8Hl62+lw9FbhJDTmQS95RKPRE0RcmfAmfiE51z/QT+tfud23X9CCS86TOL2r1KC2FDFvAhHiaGlpmRhNtCKtFWsNOScVKB1KQuKFC0h+TTypVErll2vyK8cSRj8X2BQ5TO+kK9ESVaR4Nchcb2lZraJy26lo51NxmmmT8NHSxRdFlASUL8aVfiEildMfRMjXxHvpI6HYFktD3VvnDyNOncXFb3bw2EGbv43iXGkKvcgMmRawHCBtuvOlY/j+47J/UXlGv/7VR4KtXc344OWb2N4FLFUsiBVLd3Ydkau5ivwthKGvy7EQkhCJNtXKPVoBl/sk71Rm3FpQdLhRzGWQIYe4Z99LePva9Wj01yMrczzsddi8sk0FI+YKSVqxfCTSGOzpOPzuEEp0AIo1Jp/PkSNJQKIDXqdXWa2cKvTETs97ET52frtzeXCI2SDt1D2SwPd+ddgUXWOb+BHVXJowGs/ity7bhKWMBSEQGVGkU+vFG6TTSKcXtirnRVeR0VO4i+QVLqGJJjNYntvh7uxQnEXmhphHWi3Xa9Ow5lhmcSVlD06MWsUpPklErhw5QYkcKkbu8EDPEN7SYScXYVOKOFYoE7XLwXdy2OkZ9yuH4fhYH8IUxzx0GKYT1F3oFwkEQiRyN+roOJTJVW28HksM8r1yp1cklrSLSuVDMVA4HDLIFDGhnTjKVqzSEo7AWRAC0dxizZqp9+Lp7Czvsyl5ZGKUdHaZOCVilb+jfeKannorBCVcpq2tbaIMIQiz/8PMZfK26myrNnKvUjaHaMmBp3p6cHV7I828opQ7kaAzce2KDeQKbOSSEEIbLVoljEUicLjIKVwltT+YhL8LNQrHcTpkjkgekUQa/nrb0u4Jc4SN/iOcQ+7Q0YxbfSnc1FnAcPcB9AQ24/P/+QIyuQLsyyA2bcE4iOgPMWP+uXReY/U61eGFKwg30dNq9fx1NZuwtw+ugB9uYyKVKIBiDZP8WrcR5HJlr6z2oWixbK6wUWkX86+DnT8GD30gJG4HxzxyjeHogBoVU/Rt1PuCNOnWqVD5VCpC7lVSI6TT1UDFPasih+Xd0okIOuucZaeY7fQgEGmn88/oRKe9mYYN4DMbCtjz0H+gvTiOrWdfglfHt+CHB6L4wGVbsNSxIAQiVitJesbfCQ9gJ9IWDD2rUNbP0ktn+js7Jq5Jp+/o6FDEJQSnOZMux1xmTXoKG3UkT1GrdxCXt9VjXYgE6fbTbDtKfcKBNHUQmSciYSdBT5imXYeaWlssZmmhCWIk2svR0Q0/aS2VGON3l9fIlTynQ0SWfGuny4Z7NrqxO0GDjK2Aw0d6cGazHUP9/bju0k34jbe7sVZY7hKXOxeEQGRVE9EPNAfR3nDRR4QLCMeQTi8+E+EmIl7ppYKKkTE1qnvJNfQidJJXCE6ISBOG5lJyXsSvmpbfFK+9cCSW4XU56J8YQo7tnMyNw+62o7O5C/HEMN/djgy97bG8LOjAAcDZrL5LPOhOugtLRSrzMstwhGZPOiTr2tpxOkFWtLyY3POeyCi+dfm1OJvG9EM0XPy/AAcwMbi4bUt+BsCCcRAzp6jUE7RCLsQieVasWDF5c2vLxDUhMvFz6Pnt+rwuUwhHi121WrpsxpKjY4kMNtSxw3toXcsF0D18ED3DhxCiKddL/0b34GtqFqKT3GxNx2aEsjRB50ZVlJFM3S3m1Sp0VOztKrDxdIDZISrNsJrWwEZvCC/mfXhvfaMoewh5Wb8lMffbFtwq+UZiQQhElG5JYp0yr20lHEB+xfstRCIimFpTN5lUHEGUdGeaSnCwkXXqURxCdBDhSJJPlH4doyVcQ+sfC7IeMEf+EsWpfJbiFMUlWfPKVspgZdMaDI8PKXFLYq7iFLcctMZ4XD5aqyTo0aWWHUVBwk0CSEpYC6+HwsHThkBgiqgWYjnTF8A/dtHAwaaSgcLtsCm/1HJY5WVBCERzDrNPQxOJdHpRus1rYJn3tSg5mii4uyZ8JnLNbL0y+0vM5S4M6AkvUdGmR92Wz1IhdyNOa1ZDsElxDCUelMqdQOa3y2IN8lsoudgZ6FuxU3fx8lpGHI4UKU6XaHcTBxGi8LlsapAQlC2NKpPiLks9wHlBmlSHmOgwdS0GaVFIYpBEH5Ek+ohwCLkmHMWeHAP8tBa5vIq4hBCE44jVSpYIqrRiaY99rUQiMrJ4z19K2jGSGyaN5pCln8NLs227hz6PPB2cdAbW+RrR1tKO0Wg/77EjHGxFnGboTHwcBwZfpvWN4mOJhBPLosPdcVpNm3KJJY+GC5djMozkRPG6dMK5pYgFIRAhDqkE6bgiGon4I+ElIkJpx56exyEQjqNXGUzGcnBkE/A0eyaig+U+HW6i79FilX5WrbCJhUxCYpj6KF6VMgmUqKnbvR4cS+dRkqVTi05skZizkYiad+50upA8chChfUcR4nlPehS9W7voSymv79Rcv5b5AjgdIJohjXcc7LKsQtfrph9oR+5Sdw0tCIGY525o/4W2YAlXkM5untchHESSeMy9ra1qlRB9v3AQWeRZiEBEM32fVLgmPnOsVy0QIvGUqEPkExC3Vsbp5YhYnnBVlMWoOQK+mqKRoZhBOJ2gMzGtvOmd4QD8xwYwcv5WeOQb+U42W0HFaZ0ucDugFgL3GZETai1m05plZZEaSx7zJhDtGBSLk3b8SaevVNKFSISbaCVdlG0ZbaTzS97sGE2kOZptW1qUGCWKflCttO6ZMBkL5Jp2PC6UDmITyxlJI2RPq0UeWj1FxEiEW1rbMUgRKpZNI03J7uVSA3wUnhrpCMwWbBi3Z7FhaEgt1uD1+GUCvNouweWY2XAgouNyQJkQgAi5hyjiTSHnxHlztINm9KeliKU3sxGOofcHmS68Xc8T0cGKAuksQlwucheXDRMVK0kISpJM3dTQSrr+XQjToSxincx7qWyTc7HTr6SWLaso9o6NIlYoIUlPu+TJ8u8MxbBxO8VAjpquYAahd23ERn6XDBKin/pD7lmjVoUDLhc8/MJx/M43HlMEctbqRnzo8k3YvqWNVsDARNvsPjSM8za0YSlj3gQiI7xYm6RDS8PrKN3KiF7hNHrEl2PhAPKriSYrofBi1eI5uV+uiy9kupXQzev11gyJtaNekcqQM7jr8HI8hSyCdPoZgXiGRaZkBOYVRLQTnYrfmsymMDA4DF9DSFVihHpKyD/zhCA9l2WpQw1imbxaVV/qcO/RUXzy355UK71csKEFKxr8ODIQw9Y1DacvgQh0dK3mHFN5tysnCWnFXfwdWgwrGZ1PrglXESLRSr5wKSlfx19pP4hrASZ/y5RdtdCCv2yFKeT9yufhmCC+8uyGoljQRByTRSPEguZ04yh1kR6hm2gCb/a5aXGrmzU+bCmLGpW4Zutq/OpL1B+LZV2joIwx5Xk5GVmTjL8toXK/sC9hW2/NSrpwj+mW4pkJujMJAZjLMlem3pJNm3jNkPvaKd5d1jr/xce89hyJwq24RMFWJna3eMfZ+R08FoIoUfwqGCNlee338gQtRzGPPD3GIlQ5pHPYSmp1+dMBIla3StRDw+xz7M0D6FLErATCUW+MHxmebvSr9ePnG5WrxDRykcZgEEsBCxhuMWN7vBEQrr/Upw9X2x7V8L7dU43gFuYG0cXYKD9C7di9nDcDfaMgfZoW1lnbY1YCoby/g4WNSYFLOejsVEHqTDo0k4z8H0eNkPaQsszTli1UD6kzEeUpolfVHsKnZRrg0Zky9fX1dVE3+Cr/vAEW5gQRUTlS7WZj7Ojo6DgyS3bZ5XPWrWHpSO2iiGm1xzzAdhhjm+yWgYbuiSOzZF8z8Z+FRYHluw3u0sSa03cxWQsWqoBFIBYszACLQCxYmAEWgViwMAPESxdmilaR9yYm2VTuOibZHkiWI3kZbxw2MNWyjLx+f0F3RbnyPQdM+eQ5c9nEW7yVv8G0G7VBwoHn4uSQd70S5feXb5rqnXXEp7lc/b6S31yn1xllbkfZYHAAM+N2I0+1dXWp8Vx5xjYjPY3qMdfn1QqhjTlbse4w/S0VvRWTjbDBSFtnuVZ5PyryoCKvlPEt41y7cR+myVv5DI0vVvxqSN4dpuPbjWdM9Zz2ineofPdaMRcrljz7dky+l24Xc70HjfOXVtx7G8r1UFlPO0znpOyp6tf8DF1XOk+76Z7K9jfD3IfacWJ9V96zoeKddL6p3k2fX4i2ENRsxZJKlpe63fj9Esovd90U16RR5ENkdJIRpN107YumY33/dcbfOzBZKUHjvO64t1WUs9243o4TO72g33jOfVN8R7vxbubG2THFc8zveaPxfvraTXhjIaOv/hb5tr8w3uM249ynMfn+lfE4X8P03GEjygQlZclILd9V2Ra6bXXHlGdux9Rteztmxg7jnqCp3NtwYp/ZZrwDTGXGcWI/0ZxPP3dBUAuB6MqRCpWXlZeThrof5YYTceMx41zQ+L0b5caRe68z7pP7NQEcMO7faeTfaOTdbRxPJ8IcMJ6lR8oO4300zCPbAUxWtkYQk6PYTHjM9E33G9+zUKNVrejHZB3Jt7dXnLsWJ3KF6aAHon7j+D7j721GmfJ7F8r1oNtD6vtO47xgo+lX1+1M0AR7p1G++b67jXJ3GXlvN477jW+SPiScV7f93ahd1J1ALdG88mLykveZjq+r4j7dAJJfPmQXXj+q7zDOSUXcgblhZ8V7CXSjSiPcPsV1TZg671KAHkB0Z5A6k++QTiUdrpIQ7sfkN86EXUaZEmUhdSEDlHTcnZjkypWiYHCK493GPZrAZoMeNDUnrCxXn7vLeI+PodyHdpp+N2KBUQuBSGPIh+tK+9os+YNGXmk4+UipQC2ySLrTlLcPkyKLriQJwZBRQhpQWG8cr1fW7jKuHTDKuNs4v9N4ln7naliwfk41jQvTu+4wPV83+MmAdDwhdi3i6Y54o+mc5gQ75vAev43yu8u90qYBTIqbuiNKXZpl/V/iRFFK59Fc4GuYXbHWxH2bqdydmBSZdP/YbZQl+aS9bzKO5Z0TOAmoNdSkWnnv9mnyt8+h3OAM12a7d6ZnzbWck4X5hJoEqzhX63fM9owNmFSQb5vlvrk+q5oyTlY7vaGxWNWIX6c7lmosljaKaOV6ucAKVlxksIIVFxesYEULFmaCRSAWLMwAi0AsWJgBFoFYsDADLAKxYGEGWARiwcIMqMqTLos2OBwO8WRulTWZYGGu+GGxWPxEFYs2VAVj0QbVHjBCsi3MCT8sFAqfqGLRhtlXNTGIY5fL5Qqbd4ayUB305kKZTEZWN9k2C5HMuqqJQRy7PB6P1R7zgG4PWfaHRLJtFiKZ3Q8iy/243e6w3rbZwtygNx+VOjRG/Zogy/14vV6rPeaJubZHNTrIDTVtuWxBwVj1fitqh9UeCwBj6dRZ26Oqmp5ppNIbc9YCWbWxltXa9VbRtawiXus76DKmg1GHC6IvWJyjdlTbHjUPRbKI9AOvlnBZV2Zigxg5p/csbGpqmvH+gYEBhBsb8IuxXbgkcJba9kD2HhHCkxXcpdNLGdN1flmCU23Nls+gmEkh7g6ovHpvEr17rt7kZyrIZj6yYvlIbxayxWA6V94JSvYokftl22rZz8S8UPd4OoZ/euoH+MHun+DYWB9WhTtwwYqz8dG3fggr6+caNGxhsWLeBKLXOJVOs7nNzQ42ue2aQDpuNZxFNuHJZDMIO0MT+6oLR5BfIZTKLREqofYXkY1uUEQpmz5hS2q9saTmMDOVoX69Tj7TDm8gPLH3iZQheymaV6EXgrjxXz+ifs3nJD3d8wK+fctXpiQSa9HppYd5E4h0XNlsU0bWrgaX2kTFTBD676pWhue95wY3qA4pHdHcGWUUr6aMotsLhy9wglKly9E7W00HzW1Cjbo6nK8rw/wtlcShIVzk2s1vwx/f91f4zq/9zeuuS31ZWFqYN4EIAcimnIeOHcK9j/wUe1/eg97R4wiyo2Zy5B4+N+Kj43D6ffCojWhsGI8kFCHd+IFfw4qO1WrDzv37XsKKFV2or2ukWFZSe979x3/9G7LpFJKpLGxOG+wuikhhv2wqiAw7cqFYQDyeItcQjuXADVe+Dx/+rf+O3t5exZHmsmeJed9AEelE3BJC7ezsnJJzPXHk+WmJ4+4P/R8lcj11bI9KF60694Q8snuWhaWFeROIdB7Z3rl36DiO9RzGwNAAfSa9qCdB5NiJPSSQXL4Aey6BYraAVIHiSi4Ph9uFA/v2IxlJUZxxoX+wH06vB8lMUo3Q2XgOfaNDSI/HkUxT9GoOAgnZBi0Jr9ODsfEEbBSFYtEYfI1+6iw2jIxG1fZva9bMfWqLiHECebaIZ0FjQ57pFOHHSSCC33vLLbj/1V9O6B+aOL7yi2+q668MHHgdgUy376KFxYva7YUklF8+90u47U7UBb3wuGSn2AKPHSg6yvtX2Bx2uGRfQZ8XiVgaD/3iftSH/IoTSI5n9z6t9rdLJdJwOVzwBTzI2qiHBO3I5grwuZ3webwo5kssw8XfIlw857a5EJHdchv8SkQSEUYIV28QKsq1nBdCrgZiIBA9QXQWMQyMjIwoQhFOKYQkx65SucrqvCFFFJ/44Z/jqzf8yQnEIRjPvFFrm1k4maiZQGScLeSoIJMYnC4HYvEMHE478oUiAi4P0rYCO10aJV4rkqP4632y4yOS4zH+2NUGnvTRw16yobE1jNhIQinLThJAEUWlUyTTWYpUw6qjUlij+OZBgM8YG6P1KeRGdCSmOrImDK2Ua1FrOiVd75YropXoGpJfuEhJbUhZVISldRA5DofDOG/NWer4K4/+s/q9+9b/o/42E4fgotUL4fJYfjhw4AAeffRBtLauwDvf+a55b8H3RqH2YEX2PRs7fJbiUySaInE41VbJeYpVY9E48kkSDDs//b7kAhS7eOyhhamhoQ71TUFyCzfSwykUUjnEh5k/m0OOf+dIaCD3CHh9CJHz+Ou8FMNSiKaTiJFrxMfTsk0tctkigo2BiZ1z1UfZ7RPWsNHR0RPOmZNwHLmvv79fHQuhSBmiG8mxcBRzfrl+TvMmXNxVXhlICOMDVNgriWMFLViV4tXpjsHBAXzve9/B5z7/OVx88Vuxa/fT+PZ3v4PFjpo5SLFUVIq3i4q0IyBbOvNc3qaUaxd1DDcJZjyWgstZ3uo5EPCqh7rJXchT1G6yWRKTncq2h2KZr96rdot1+p0IUx9IkaAKJIJ0gj4KmpIzJIq0M4+g1w9HXm3STOKzKx1ERn8RkbReISZaOSf6xVQmZ/GNaJFMCEXu01tN691ZzducCbeR9Lfv/eyEJesJQyfREOL4Ds28Fso4ePAg7r33Hry2/xXWZwDnnHs2vnXndzA6NowX9u5XdXzLTTdjsaJ2RyE7tYhZDi9H2FiGhMJuT5Gr4LYr4kmV6KFmp6K6ATt1EbfTgbM2bkYjKytPXWXvoaOIUvfweWje5b90mgp+nQdF8raBwYiybKUSKVq56pBg5w+KnuP3IEUl3skOnYjSUZgu758ohCBimIbmItMp3JoQtKglx5Jfs/1KK5YQoeg0K+racNcHv644x5NHdylCEb/H+990DW49/wPUT5bTwh7zQ09PD372swfwqyceRyjgQ1t7B95y0UXYsuUM7Nz5c9zzkyOqbX9w111o4gB11ZVXYTGiZgIRhbzED83G88pZ56BeYSeReMVRZxdveBYlEgWVDHYuXucT6+qpNPOeC855K9ZtTCJdysLOjnnvz/4TidQYLVjCLVxq7/FwEx2ILrfquAFygVyCxFBUrhOUqOeIJazEayL+iFIu0ObUeDyukhCN1k/MEHFKzotpV6xXQiAiVkk5YhGT883NzRP55ViIRAhndUMn7rjhs+qcEKacFw40m1NyuaO7uxs//elPcd99PyK32IqL3nIR2lpb8daLL2FdS13a8eu/fqtEiePp5/aAXQLfvPNOEkkTtm1bfIta1kwgLvZ4p4RdS8fg3+G6kNI3QrQ6jRWob7DjiLWKZEIzrQN+Fy1ZmRwaaKnqWn8G1mwUZbyEFvpBnnvqEaQojhUpbuXISYIhL+wUvzzkLnn66NxOEogtrwgkmxM9h4TlFGXcp1i15h5aLBKlWpL5nBliqRLO0dLSoq6LOKY98XIs18336bAZ8zntDzqdveRSH+KD+slPfozXDuxXovQ7rn0nzjhjC8477zwaO+o5cOQ5kIio7afq6MTv/7fbkPrbv8YeillZDjB/c8cd+Pyf/SnWdq3FYkLNBCKV46I4lebI7uUI2tbUio5QIyukgA1BmnKplOSLOYhB18OOfM6W8yAMJUelPhYZwWvJPjjpM+n7yUH09A0hSAJqbKxDnJavyFhCiWY5JT5RXEMSTSzTTX/IaKIIr8+p9J90quxD0dxCBx0KVxGOYCYeM4SDCLcZHByks3IFYrHYhKgmnGNoaEgRj8bY2Ji6JlzHbH0RLiLcR/tQTieIKPXwIw/jkUd+jq61XehiB7/g/POwetVqtLa3KI4qfcFud3EQSShHsMcd4OAXxsc/9kf49Gc/jd7+YSSScfz5l76EL/zZ55STdrFgAcy8NmSTeY4KNirVtDhRefBQxCpQgY5Fxikm2ZSi7uVIu76jE5e/9WoSTBHJRJz6SRLHE3vR2rgKpbYcR5tN5CDUA6jXhNkBm5uzyjMvIpT4UnzpcQzQABBLpdE7PCIWApRInCWHTY3k2qyrR3gRebTCPhUHkRgrwapVq1RDmj3dkl+IxHyfzj/VM/R+6KcLenqO4/77foLdL7yguO7l1CFaWxpw0QUX4eihw2huaebgVg7NkarKc2Bx2KUt6DAuyt8uhOoa8Kef/Sw+85lPYySa4IA4hs/9+Rfwl1/80ozBpW8kardiiTZAHdhPkaqZI4OHYpC71Y/ODZ3Kcy76cdFrg6ehHs2BZgzGjrGy6PdwFFmxNqz2+PHanhdxBs27tkQeQ5FhRJvakKWcX8+iz6RS3tbciA5awUZybdj58mvYQ7nK5rEjR73GToeiw1UezecbBi7cQAhEwk6EiwixCTGILmL2fgs3qiQk4T6aY2nxbLlCBoHDhw/jqaeexH0//QnCzS1Y3dUFGzv788/vof6ZxGj/IVxz7c0UpbwUneLkHOTywjZEQy3lWddOxUk8KurahabmTvyvT/4x/uxzn0ciXVBc+zN/8hl84fNfmDUS/I1A7Z509kkx6UpsFKgHB0M+uBv9WLeqk5XkVJ7ws7qupmrmM8QXmm0LWezv2Yczm7pw89k3kgqoHA/1Y3/uR9iwZQvIM0AnOvzt7ciTAFLxMd7vYPEevLkrjb37D6lKd1JW8wY9JLaFcTZVchztNNTQxGI+ZxbBzHFdyw3HjvXgzn/5JsXMqOIGZ597Pt1URRpeSmgM2ykhOLHr2ReRGn4Z5519Jlpa2znQBJDLp40S7OwPHkUwTjuJJx+n8cXOQc2B9Ru24LN/8r/w2f/9BWSoWx47dgz/9K1v4o8+8Yc41ZPDanq6jKaFfEGxUC/1iGw6g76BEbR31bMSqZjTgjXadwidwQsR8NtYiQ46ExN44JlHEY7sw2r/O6mjRGGnA7DE0SXc6J2Q7YU5j8aGSSolZbGy0VoGWrv6qAw2umW+iR3DQ2PwJzxU6BdGtDnZE5GWmoUrTYLf9+oruP+BB/D8rufQ2taGjezMKRKILXaUljzqZbYVHPVHsG7dGrR3tNDZexj33vNdrFz/JopQHYoohGPY7WU/VIGGGzu5iMMug2dCKe3PPn4f7vnRf9BBm6Hp3odVq9dgz54X8Ddf/Qr+8BP/85QSSU3h7uKlFqVV+m7BRUtGOoGG+mbQzoSMiEzUJVwjWYw88l282Ho9YuzHqRceRMvWN2ODpxexPbuRPnoUQXKCRHsT8p6CMhvbnSKCSczVZIi6OCCVw4XXC6msIpvm9jD81Hvc/hN1j/lA+0NOJiSWaylAfD0vv/Iq7nvgQex67hmli117/btp1IhjNBLBOEXNTl8Cb94Yx78/lsO2C66Cj76pPS+8iIbW9dj73CP4v3/7p/jI//xrGkfq2MF9LDOtBiDRPUqlstLef/wI/un/fgX79+3DitXrsHHzJg6SHRRlx/Hed70LDz54LwllN84//804VZg3gchIL5StYrH4z+1wI0N2O5hPIkxvd9EmptmShF0hlrMjRU4zOE5lvW0jevpHWVlOnOsrm/729UZQ39QADyvQ7uY9JIyCmTjYccdjVOL6hzAwOg6bm6IQpZl6EpEnZUewUB75p+IAMmqLdUvEJdEV9EQuPRlKvkGIXHvJp4KxCsYJIpYkuVdP7qpmzkqtU5NPNsTa9OgvfoGnn92F/oFBKtNFrKKOURduxPHjvbj+HddwYEzgO9/5Hh4/FEFvXx6bz92EYz3HsG7tStTRiheNjuOs86/As08+hH/5xhfx3//wS2WxyikREjTmsJ/0HH0V3/23v8cTTz6DjvaVOP+iq7Buy9kYHI7QIplCdHSQBPoKmlub0EofyqnEvAlEOofI3yPjI2Ull4SQpEc8Ek+oeCk7nXwulj5OL7gzRQvP2ONIOuvhqQ/Ay44YyYeRdsaRX78W9uYOHB2ms47KdjOv+wIy9aOkfCuZSBRHoykq7S4k6qm8bwgiRdNi1lVA7/gYAgUPBqMx1fGnm3moHXhaRzDrCnrWofaiTwchrkoi0Peaj2cS08xWsMWIr3397xAdi3BwsmHdmlUSZoe8hOuw/np6j+PRRx/Ciy++SJNsEvHxKEZDq9BLQlqxcjV1E7Yl9cvGxgZKFmO48C2X47UXH8Mzv/wvXPC2GylNOGmhjODHP/0W/uPu76M+3IQrr34vWjtWcxAtKoNIT/cRDA70kYhsWLniQlxzza00nZ/aOTQ1C3d2NfKyc2TzKvLRT7Os9FMbyuEbJeoKkqfVEcVYsI0juQt1oTqOyD5EvBKnNYqRLB2KoSCSzJdlBa2gPuNsbUG0QNGrsQvBdh9GqBz2dh9Ha0MYW7po0RofwnESDyQKN1C2Hh2luCbmQbPifLKhJ1gJd9DWsKWK1pY2vPWS7Vi5ciW+/vWv4ZZbbsZTTz9NbnKcRFPEQz9/mAq2TQV3Clf+/Q9/GINDEXKCJ9ixh/A/PvIHeOGFXWhr62Sb01TftBL3UrcINrTi0P79+O63v0ODjpOcYQ22v/16JOkwdnr9OPzKy3jp5RfR2EAxm1zmkosuxfvfvzjis2omkDxFp1Q0DQf9EQWKWKP0fXTSvPvsS/tRHwygycfRu+TC8MA4ckGnmn8u4ev+hlUY6utGU30ISW9OWTRSmSy8Gzcj7fdRQcxSfg2iu7sP59G2Hk6m6HXfxuN9GDowgmQ8o0aefIamZHuZGGU2oUTiiqwvYpFwORGLTqb5VUy+aiAw5ugvZWzf/jZ87/vfxgXbzoJYrr/85S+jY2UXzjrrHOyikt57bIDE045r33Etz52tFO8XX3oFz1Mku+76d+GFF/dh/fpNrPschoejCLduwBO/ehif+7O/pBuAFs76Vlq/LoSXA+Tx/sGJ+TZHDx+Cz+vhfRm8/3034frr34nFgto5CNmFi1Yll9eFDB1AiVQc2SGacfsOY9Omtagv2BX7PZKiojbUDSdNwB31VMLHnkFdY1ApZMJePQ11dCwVcehID9weNxrC9crk98rRw7j+3b9Z1hdo/ThwcC9+/szTkOAVmTNiE726aJvonBJaIuKQjOQiEmmz7cmCFqmWw1I8wjnOPXcbfk495LHHnsB7b3gfNm2gztg3gLFoBO9+97twzdVvp6VyGAdoao/ExunHaMbbLrtChYv0DfTTrJvBJRdfiAMHDnNg8iNQ34H+noO47tr3o3PdJopoL8FGcfhFOhglxm5opI9ilF9ZtT7/+b9UYuhiqsvaQ034zyFRs+QeYZnZl86j97Uj6GrrQPLQKB4+HoGLHT7MkXZNyIk1jezUxTH0jQzCTuehxO0kOerni+KNt6vJVknqMVl2+Aw5Q5bWjzv+4a9x3pkX03v7Gn7+BNk8HY+pCF31fjqhsiVlFZEwj+Hh8qQqcegJQUnSBKLnm8uILxxFRAQhpEoCEg4kSSZLCfHLPebwleWOKy6/Eke6j+H3z9lKg2EG73jH1ezUL7NOY3DZ0tj10ovoOT7EwUwmwuVUqInP60YAIYxHR5XVS/QWv9erOEK4sYmWxnpEZXrP4aOIRaP45fPPob4xhKHRPvrL1pKgLsUHbrq5pnXNThYWINzdDqerbKUaGKROwA6bpoiVISdJtK4mZ6GIRa9GKOhipdHSRZ8HGQ2OD/biVZr5VnDEV5agjJ2kli8vt8N0PDqGdD6rnICxaNk8+jwbZ1CifcfSCFGZH8+kaVb2I1cqy/3S+UUPEGLR1iURtfQSQuKZlfMiDlUGNmpIuIrElMEYxfTSRjPZ4oUQF/vMuGoh33nLjTfye2Q9sIxy5J177jkYHhnA4796ku2RQYIDWJIEc/x4t7JCSp6NG87EEBX2t158MQfDoBpgxsfj9GnUqYlpx3u6mb+HAyJFZzqTUyzjA++5EZdf8XasXr14t8ms3QMjHYxOioCHzh8eOoK0GpEQkqkiDg8dp4OwDm9as5JKewEvHDyAM1Z10WEofpIiHEUHBkZiKiLYVSyvf+Wk6StBh6BMxZVVS+KjEeTpRPzJvd/Frv2vIJbKob45iJKEr9jcKpjR5Sx3TmORaBWEKISgJ0CJAi0+G+Ei8rfY9UX+FcuXdG5pTGlEIYQGmpDT/Ltu3dqJZYjkPuE4Up4OMxEuoydj6QWRlwvq6sIkgAgNHuVoZ6nL9es28Juz2HfgILq6gnjsF49S/8uIdMt2LODokYO46qpryE2cigPlunuwZuUqvLT3ebQ0NNI7fliZ/G0czIK+enz56/+oxOHFLprWHqxIZ6AQiQ8e2Cny5JXfIUnLkh+FXBbDgwNIt7XAW09b+tAoGlkpnU1t9KgPY5zm4AJNenVZdvZCWUzL0eFYdNtoLXGWzaYs3kkTr8tdnlrrgayUSJ2DVjMJccmJ4zBfNrfK6CcEING2elVGvbqiEIEQgJ4kJR1drmkFXnf8IonGK8sUGb4OEa3UXHiDQ2iOY6y1O+E/kePlFGri84fpIe/hNxWoiO/B4YMH8f27/wtbz7sITeEANm/egldefgljNPfmqJR7aI3q7R9UE+bWrdtIgujGa6+9giSv96eiyCQT5ETnK33lirdfiaWCBQh3LyplOUJFu6hG/RItR95yrJRHlGWKU8Njam55c1NI6Rs5DiUZWqXEV+InYdgL5ajgAv1oZCocZWzw2p1qMk3BbliIJDK0WF5MweayKyJJx9NqWq9MxNIdVzqrcAbp0NJptWikO7x21umRsXJNXsUQDVOtCqUhQcnyqHKviG6Vvgxtwar0iSx1yHcJJ3npxd340V3fxsHuXnR0dCku2trSgU0bAypwkdo7Vq5ah44VnVi3fh36e3vw0AP3IcgBMkLR2MkBMDKUwB985GO45NLtp9zxN1csAAcpByuKEySbENMsRZRkjooZxR8RPaiBRBMj2He8hJXN7ZC5tDkqIUU1T7csRtENSE4indOm7Odiay8qwrNzBMvSeViPFw8eRYi2wuRYjEo6fS0U6Ty8O5fMlmO1DMKQBtQKuIzoQih6CVMtgpnX/hVRSTr9BOEUEnyHvCpPe8jb29tfp2OYp+m+EWEqpwJebwjnnHsRxpI2/Ppv/C7FrHUUmT1qlZr7HnwIm7dsoPgLbDpjMzrot3rphedp8n0aDc1htb5xjn6OjV2b8KVv/RUappjRuRRQux+EptkkfRsl1lSw3kvlPK86fpqs2SMLMbDjCcGMjx1XIQnXb79MzZcVu7f0KWHPSTtNtrJUUKYIL4nEQSIRRdlG3aLAjvkqlbtBmhRXBJuQSxcovlH04bOLEg3qcai5Iloc0jMEzRBOYA5bl2PhLOZpuNrBZ/OFydHyE9xDyhP9Rf6W/JojmZ2Ceh3g5cZFBMroUVcPL0VfsSwePPAKjTGD/O1Gmu1+7rln01ASwg//8wfU40ZRT3N9iSLvxfSkX375FbjkrZee8ojcWlD7mxegOrSbJlwVXuL2kUiyalqs6M6RCH0c9JEI0XhIFAePHUJqaJjWL474VO7qnD40+gJqedKRwjicJLQQ2bPoK71jo+QW1CvYAVvr/BzdizhzfQds5Cw9VN5FFHIXZf57eZ64+FOkMYQzCKfQeoXoGXIs1/UUWT0LUPJrK5Tkz8VkqaIkfG2tqsysWigiqAhMcxWBjumS+6Xs5WoGlnq55cYPIDV2DA8/eC8e+eXjJIwCzbTNSt944P4Hse3cTdRZnOT6dbRWhnHDe27ArTt+Z1n4hmqfk85KsskccVqXZI2rcXYuW94ON4lCiEBWPbRTBMvEJI6ppBZ4sPG8OPgcvC9DcebsTedjc9da7NnzDG3jYzh09DiGc3GsaGvCqNjfxQgg4SQ2J0I2D/wUrsZ9aQxH2eF9DrU+lnAQsYpoRd0MrXvo81pJr4QKQKT3X5IWmbSCrleZ16OhWUnXMxmXkyXLjKuvfgdu//Qf4/FnXsBV/Hs/LVmHjx5DhGb2lpY6jJJz+Lz1uOnG38SFF5ZXLlkumDeBSGeQ2XeihZ9NBe3Y8CgiCTrYgj5lDgyEPGptXRntS1TEXQF6z0kQDd4gFfpxOhQLSNIm7mAH27NvL554/hl0rViJgz29MikT68KtSkHvjg6R0KjMh/wqyHGEolauPgia4+kLoc+CYpbPUR7VT8YovpBTP4VrLUXIILCDHCFKv8YXvvAF7N69Gx/+gz/AFZddgt/7nd9BKpnGCpp0JVBRvOfLCfMmEBFLlH+Ao8iTe2kbb2nCxmbK/j47R/ZydK2NinQqnVIWjTz1EFmJpMkdxNHoOOK0YtWFqLNQFpPgN9H2n937AnX4IkWuOsR5Xx/FqCi94i2djRiJxtFGWdjmsqlrwjVCJeocySLKGv/ix1I2A2/etBlXXnEZPvnJj9NwkcU//+M3cA697dqKt1x3vZo3gYglSIIDY+lxhNoDGCah7Nvbi01rOrGqpQHHRkeQprXKT7FG1lDMyJpYNN0Ok+sMkIDCEoJOohA9oqOlHhE67kS8l5XeQ0F6rmkWtjf6UB9PYSgRRRO5hk/mM9NLn6PJVxaZs+dopqVDsm+wvO/GQlmSzMr2QnrIzWtsLUXceOMtuOCCC2nmbaXOMblKzHLeEq4mHURkcj8dROe/6W3suDSpnllQXnRZxGrbinV0CoaoQ9Dvkc6prRAcPifWNbbhnOI2RNNxxKIJbFq7GsPDEbStphIvliafRy10LcQkazYm+/qRJEF4iuVwEVm4OkMOImHRXjoQj49EsG1beaHo+TaUjOx60pTevk30FYnpEh1EruntEfRoqVdg1Ap+NSuaLIeOtGbN4lq36mSjZiW9pakVn/ztj9LUaisvd2gXXwaUd1uib+Vvia0SE6Hb7VHm3BJHfrFoqUUdxNPtMGbz6Q4knEBHycqic+QabodT3e9WMwCzSLE8yVagn6UuWKd0ovmaWeXZZsW+aKzUaO74euahGebJVvo+C8sLsxIIO88YR83wdKOfh512w7oNeCMhwSGVO36cLB/EQnX6BXQkztgeFqpDte1RTXzx7qr2GbQwI4yYsB+hduy2NgOtHdKnOfjN2h6zEghH5h0sbExHrVqYG/SKi0wy8n8cNULaQ8qSMq32mDv0zE+K0FW1h/BpCcY/OlOmvr6+LiqvX+WfN8DCnCAiKkeq3WyMHR0dHUdmyS7ey9gseSQ0v4v6kdUe8wDbYYxtslsGGvq4jsySfc3EfxYWBaxtcBcX1iy+OY4WLCwiWARiwcIMsAjEgoUZYBGIBQszQAKNZI+yaBV5ZQO5HUyPmc7dxiTr5shGDu1M/age4l38MNN1TN1MWeP4aaOseBX3mt9F3i1klDUbthnpQBXPEtxkvN8oTi48xnOqgby/1P9FTNtRrnuZAWauU3nfL1bkMX+DXJMJ4m9ienkOz4bx7KeNv3X9vJdpN8p1Csxc3lbjPXdj8ULt31etFUs+5qdMep+xduP4OuNcuyltxWQlBSuONf5uir83GOkO41ef21px71bj2VtNz9DvMtUzN5jOwfS+lzLdXpHP/N4bTOeCpl/z+5ifVesebHOxYl2Hye/V+Jbp7x8Yv3cYv8GK6+Zr8u5fNJ0318MG0/3tU9y7w0g6b9C4dqkpr7ludNk3Mn0JixtztmLtRHm0EEil3Gf8LaOTjGjSYNeiXAm3Gdek4oPGsbkDlYyypLI+avzeZMoTxGQn2IDJRtC4z3iufv5O428p53bjfv27wyh7u6m8bcY1/bzbjPPXmfLeZpR3namcG03v0276vi+idgKZK/RgpAm2z3hXOV+5uK1wyek8i/3Gdd1O+vu2YrLedblm7DDuudM4NrexuS62Y5J4bjKedzeq8PmcasyVQIQdmkeUA9PkkY+XCtcNt9H4NQdtfQzlBjWPQPr+fuP3WqMcGVm3VzxHzuuRSUYrTazSkHehLH49ZjxT8koj7sSJjbzb+AZd1v1GPv3e9+NEMU7wL8Y5yb/NlGequjjZ0Bxbd8bPoEwElaP67Ua6c4ay9GAhddlhnNNiqCbC3dM834x+TLafhh7MzAPZksB8onnlw6Wy5UNni5iTjieVJRUUxKSOIn9vN85L5/oWJjt45f07jd+prgshyKgljdhhugem51QD8z0LISq9UZC2uN/421ynck7qVAhC6uYvZilHE4fu3Lqudb3cjqn1SylXOOoOzEx85sFstndZVJiPFUsqT0al+6vIK40jFSts1cwldIVJxUsH31Vxn9YP7sSkeLR1ivKlg+hOYX6/HZgUj2ZSAuOYFA8PmN7na6gOQrzXGvdsxRsP4ZaaO2idSX9DNSP1Rrz+m6W+dJ1r7itl3zVNGXfj9XrZfkyK2BrT3b/o8UaEmgTncS1YRZ75PnOhnqM75ULpIAsValLreywEB10qXHgmWLFYCwDNQS5F7bBisRYXLAJZZLAIZHHBCla0YGEmWARiwcIMsAjEgoUZYBGIBQszwCIQCxZmgEUgFizMACGQIixCsWChEnb9n8TsW/Z3CxZOhEQCJIVAZI9lWahQiMTiJBZOdwgN1BspoqNxJapXZqP5YOFUQpZMT8DCqYSoHCJVCePI/38dFJWAHLr/9wAAAABJRU5ErkJggg==", + "public": true + } + ], + "scada": false, + "tags": [ + "iot hub", + "widgets", + "solution templates", + "device library", + "marketplace" + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/leaf_wetness_card.json b/application/src/main/data/json/system/widget_types/leaf_wetness_card.json index 65109597fd..5ed2af2184 100644 --- a/application/src/main/data/json/system/widget_types/leaf_wetness_card.json +++ b/application/src/main/data/json/system/widget_types/leaf_wetness_card.json @@ -1,7 +1,7 @@ { "fqn": "leaf_wetness_card", "name": "Leaf wetness card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/leaf_wetness_card_system_widget_image.png", "description": "Displays the latest leaf wetness telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/leaf_wetness_card_with_background.json b/application/src/main/data/json/system/widget_types/leaf_wetness_card_with_background.json index 3258de6b32..aeb98e9119 100644 --- a/application/src/main/data/json/system/widget_types/leaf_wetness_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/leaf_wetness_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "leaf_wetness_card_with_background", "name": "Leaf wetness card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/leaf_wetness_card_with_background_system_widget_image.png", "description": "Displays the latest leaf wetness telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/leaf_wetness_chart_card.json b/application/src/main/data/json/system/widget_types/leaf_wetness_chart_card.json index 235a468a74..a6efbe6cac 100644 --- a/application/src/main/data/json/system/widget_types/leaf_wetness_chart_card.json +++ b/application/src/main/data/json/system/widget_types/leaf_wetness_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "leaf_wetness_chart_card", "name": "Leaf wetness chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/leaf_wetness_chart_card_system_widget_image.png", "description": "Displays a leaf wetness data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/leaf_wetness_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/leaf_wetness_chart_card_with_background.json index 3c9f15b0b2..e62ad0cc5f 100644 --- a/application/src/main/data/json/system/widget_types/leaf_wetness_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/leaf_wetness_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "leaf_wetness_chart_card_with_background", "name": "Leaf wetness chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/leaf_wetness_chart_card_with_background_system_widget_image.png", "description": "Displays a leaf wetness data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/leaf_wetness_progress_bar.json b/application/src/main/data/json/system/widget_types/leaf_wetness_progress_bar.json index fe3919e6bf..55692ea464 100644 --- a/application/src/main/data/json/system/widget_types/leaf_wetness_progress_bar.json +++ b/application/src/main/data/json/system/widget_types/leaf_wetness_progress_bar.json @@ -1,7 +1,7 @@ { "fqn": "leaf_wetness_progress_bar", "name": "Leaf wetness progress bar", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/leaf_wetness_progress_bar_system_widget_image.png", "description": "Displays leaf wetness reading as a horizontal progress bar. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/leaf_wetness_progress_bar_with_background.json b/application/src/main/data/json/system/widget_types/leaf_wetness_progress_bar_with_background.json index 271dca2485..cd854fc08c 100644 --- a/application/src/main/data/json/system/widget_types/leaf_wetness_progress_bar_with_background.json +++ b/application/src/main/data/json/system/widget_types/leaf_wetness_progress_bar_with_background.json @@ -1,7 +1,7 @@ { "fqn": "leaf_wetness_progress_bar_with_background", "name": "Leaf wetness progress bar with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/leaf_wetness_progress_bar_with_background_system_widget_image.png", "description": "Displays leaf wetness reading as a horizontal progress bar with background. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__card.json b/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__card.json index 95605deae6..11c90052d6 100644 --- a/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__card.json +++ b/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__card.json @@ -1,7 +1,7 @@ { "fqn": "nitrogen_dioxide_no2_card", "name": "Nitrogen dioxide (NO2) card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/NO2-value-card.svg", "description": "A gas produced from vehicle emissions and the burning of fossil fuels.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__card_with_background.json b/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__card_with_background.json index 7fee7b991a..ba6b1d630c 100644 --- a/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__card_with_background.json +++ b/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "nitrogen_dioxide_no2_card_with_background", "name": "Nitrogen dioxide (NO2) card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/NO2-value-card-with-background.svg", "description": "A gas produced from vehicle emissions and the burning of fossil fuels.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__chart_card.json b/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__chart_card.json index 39def6e43b..b4089e045c 100644 --- a/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__chart_card.json +++ b/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__chart_card.json @@ -1,7 +1,7 @@ { "fqn": "nitrogen_dioxide_no2_chart_card", "name": "Nitrogen dioxide (NO2) chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/NO2-value-chart-card.svg", "description": "A gas produced from vehicle emissions and the burning of fossil fuels. Results displayed by combining the latest and aggregated values and optional simplified chart. ", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__chart_card_with_background.json b/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__chart_card_with_background.json index e48517f3bb..362ff731bb 100644 --- a/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/nitrogen_dioxide__no2__chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "nitrogen_dioxide_no2_chart_card_with_background", "name": "Nitrogen dioxide (NO2) chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/NO2-value-chart-card-with-background.svg", "description": "A gas produced from vehicle emissions and the burning of fossil fuels. Results displayed by combining the latest and aggregated values and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/noise_level_card.json b/application/src/main/data/json/system/widget_types/noise_level_card.json index 4b214b63a0..6cb7dbbfba 100644 --- a/application/src/main/data/json/system/widget_types/noise_level_card.json +++ b/application/src/main/data/json/system/widget_types/noise_level_card.json @@ -1,7 +1,7 @@ { "fqn": "noise_level_card", "name": "Noise level card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/noise_level_card_system_widget_image.png", "description": "Displays the latest noise level telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/noise_level_card_with_background.json b/application/src/main/data/json/system/widget_types/noise_level_card_with_background.json index 564d3d665e..7ac2c36c8b 100644 --- a/application/src/main/data/json/system/widget_types/noise_level_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/noise_level_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "noise_level_card_with_background", "name": "Noise level card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/noise_level_card_with_background_system_widget_image.png", "description": "Displays the latest noise level telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/noise_level_chart_card.json b/application/src/main/data/json/system/widget_types/noise_level_chart_card.json index 00da830302..bfd06d8760 100644 --- a/application/src/main/data/json/system/widget_types/noise_level_chart_card.json +++ b/application/src/main/data/json/system/widget_types/noise_level_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "noise_level_chart_card", "name": "Noise level chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/noise_level_chart_card_system_widget_image.png", "description": "Displays a noise level data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/noise_level_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/noise_level_chart_card_with_background.json index 032e34c994..dd617a1764 100644 --- a/application/src/main/data/json/system/widget_types/noise_level_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/noise_level_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "noise_level_chart_card_with_background", "name": "Noise level chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/noise_level_chart_card_with_background_system_widget_image.png", "description": "Displays a noise level data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/ozone__o3__card.json b/application/src/main/data/json/system/widget_types/ozone__o3__card.json index 77f12bc288..e5e98b0ef7 100644 --- a/application/src/main/data/json/system/widget_types/ozone__o3__card.json +++ b/application/src/main/data/json/system/widget_types/ozone__o3__card.json @@ -1,7 +1,7 @@ { "fqn": "ozone_o3_card", "name": "Ozone (O3) card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/ozone-value-card.svg", "description": "A beneficial layer in the upper atmosphere, but harmful when present near ground level. Results mainly from vehicle exhaust and industrial emissions.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/ozone__o3__card_with_background.json b/application/src/main/data/json/system/widget_types/ozone__o3__card_with_background.json index 02145f1e0a..36568288eb 100644 --- a/application/src/main/data/json/system/widget_types/ozone__o3__card_with_background.json +++ b/application/src/main/data/json/system/widget_types/ozone__o3__card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "ozone_o3_card_with_background", "name": "Ozone (O3) card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/ozone-value-card-with-background.svg", "description": "A beneficial layer in the upper atmosphere, but harmful when present near ground level. Results mainly from vehicle exhaust and industrial emissions.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/ozone__o3__chart_card.json b/application/src/main/data/json/system/widget_types/ozone__o3__chart_card.json index c4a3e47767..cac726d689 100644 --- a/application/src/main/data/json/system/widget_types/ozone__o3__chart_card.json +++ b/application/src/main/data/json/system/widget_types/ozone__o3__chart_card.json @@ -1,7 +1,7 @@ { "fqn": "ozone_o3_chart_card", "name": "Ozone (O3) chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/ozone-value-chart-card.svg", "description": "A beneficial layer in the upper atmosphere, but harmful when present near ground level. Results displays mainly from vehicle exhaust and industrial emissions by combining the latest and aggregated values and optional simplified chart. ", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/ozone__o3__chart_card_with_background.json b/application/src/main/data/json/system/widget_types/ozone__o3__chart_card_with_background.json index 6037c4454c..e7b46fa7ee 100644 --- a/application/src/main/data/json/system/widget_types/ozone__o3__chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/ozone__o3__chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "ozone_o3_chart_card_with_background", "name": "Ozone (O3) chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/ozone-value-chart-card-with-background.svg", "description": "A beneficial layer in the upper atmosphere, but harmful when present near ground level. Results displays mainly from vehicle exhaust and industrial emissions by combining the latest and aggregated values and optional simplified chart. ", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pm10_card.json b/application/src/main/data/json/system/widget_types/pm10_card.json index 3412808d60..d953d7db67 100644 --- a/application/src/main/data/json/system/widget_types/pm10_card.json +++ b/application/src/main/data/json/system/widget_types/pm10_card.json @@ -1,7 +1,7 @@ { "fqn": "pm10_card", "name": "PM10 card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pm10_card_system_widget_image.png", "description": "Displays the latest fine and coarse particulate matter (PM10) telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pm10_card_with_background.json b/application/src/main/data/json/system/widget_types/pm10_card_with_background.json index fbb8c7251e..fe069ceba8 100644 --- a/application/src/main/data/json/system/widget_types/pm10_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/pm10_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "pm10_card_with_background", "name": "PM10 card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pm10_card_with_background_system_widget_image.png", "description": "Displays the latest fine and coarse particulate matter (PM10) telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pm10_chart_card.json b/application/src/main/data/json/system/widget_types/pm10_chart_card.json index 5014d27f7c..e4b30f0a91 100644 --- a/application/src/main/data/json/system/widget_types/pm10_chart_card.json +++ b/application/src/main/data/json/system/widget_types/pm10_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "pm10_chart_card", "name": "PM10 chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pm10_chart_card_system_widget_image.png", "description": "Displays a fine and coarse particulate matter (PM10) data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pm10_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/pm10_chart_card_with_background.json index 8f2ba1d14e..23ae1523e1 100644 --- a/application/src/main/data/json/system/widget_types/pm10_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/pm10_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "pm10_chart_card_with_background", "name": "PM10 chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pm10_chart_card_with_background_system_widget_image.png", "description": "Displays a fine and coarse particulate matter (PM10) data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pm2_5_card.json b/application/src/main/data/json/system/widget_types/pm2_5_card.json index 0770923327..b281cf43df 100644 --- a/application/src/main/data/json/system/widget_types/pm2_5_card.json +++ b/application/src/main/data/json/system/widget_types/pm2_5_card.json @@ -1,7 +1,7 @@ { "fqn": "pm2_5_card", "name": "PM2.5 card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pm2_5_card_system_widget_image.png", "description": "Displays the latest fine particulate matter (PM2.5) telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pm2_5_card_with_background.json b/application/src/main/data/json/system/widget_types/pm2_5_card_with_background.json index e82a840d27..12f572163f 100644 --- a/application/src/main/data/json/system/widget_types/pm2_5_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/pm2_5_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "pm2_5_card_with_background", "name": "PM2.5 card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pm2_5_card_with_background_system_widget_image.png", "description": "Displays the latest fine particulate matter (PM2.5) telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pm2_5_chart_card.json b/application/src/main/data/json/system/widget_types/pm2_5_chart_card.json index eff5191922..b23e40011e 100644 --- a/application/src/main/data/json/system/widget_types/pm2_5_chart_card.json +++ b/application/src/main/data/json/system/widget_types/pm2_5_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "pm2_5_chart_card", "name": "PM2.5 chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pm2_5_chart_card_system_widget_image.png", "description": "Displays a fine particulate matter (PM2.5) data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pm2_5_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/pm2_5_chart_card_with_background.json index cdfac469eb..3a6303d581 100644 --- a/application/src/main/data/json/system/widget_types/pm2_5_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/pm2_5_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "pm2_5_chart_card_with_background", "name": "PM2.5 chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pm2_5_chart_card_with_background_system_widget_image.png", "description": "Displays a fine particulate matter (PM2.5) data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/power_consumption_card.json b/application/src/main/data/json/system/widget_types/power_consumption_card.json index e99cc76bb4..e743d32d35 100644 --- a/application/src/main/data/json/system/widget_types/power_consumption_card.json +++ b/application/src/main/data/json/system/widget_types/power_consumption_card.json @@ -1,7 +1,7 @@ { "fqn": "power_consumption_card", "name": "Power consumption card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/power_consumption_card.svg", "description": "Displays the latest power consumption telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/power_consumption_card_with_background.json b/application/src/main/data/json/system/widget_types/power_consumption_card_with_background.json index 2f08a16a5b..c95eec0e17 100644 --- a/application/src/main/data/json/system/widget_types/power_consumption_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/power_consumption_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "power_consumption_card_with_background", "name": "Power consumption card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/power_consumption_card_with_background.svg", "description": "Displays the latest power consumption telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/power_consumption_chart_card.json b/application/src/main/data/json/system/widget_types/power_consumption_chart_card.json index 1009371fe6..13106bdf7e 100644 --- a/application/src/main/data/json/system/widget_types/power_consumption_chart_card.json +++ b/application/src/main/data/json/system/widget_types/power_consumption_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "power_consumption_chart_card", "name": "Power consumption chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/power_consumption_chart_card.svg", "description": "Displays power consumption data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/power_consumption_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/power_consumption_chart_card_with_background.json index c006f600fc..5df45ff182 100644 --- a/application/src/main/data/json/system/widget_types/power_consumption_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/power_consumption_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "power_consumption_chart_card_with_background", "name": "Power consumption chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/power_consumption_chart_card_with_background.svg", "description": "Displays power consumption data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/power_consumption_range_chart.json b/application/src/main/data/json/system/widget_types/power_consumption_range_chart.json index bc295c570f..878f0c5083 100644 --- a/application/src/main/data/json/system/widget_types/power_consumption_range_chart.json +++ b/application/src/main/data/json/system/widget_types/power_consumption_range_chart.json @@ -1,7 +1,7 @@ { "fqn": "power_consumption_range_chart", "name": "Power consumption range chart", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/power_consumption_range_chart.svg", "description": "Displays changes to power consumption data over time visualized with color ranges.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/power_consumption_range_chart_with_background.json b/application/src/main/data/json/system/widget_types/power_consumption_range_chart_with_background.json index 3ebcbb96c9..347938d831 100644 --- a/application/src/main/data/json/system/widget_types/power_consumption_range_chart_with_background.json +++ b/application/src/main/data/json/system/widget_types/power_consumption_range_chart_with_background.json @@ -1,7 +1,7 @@ { "fqn": "power_consumption_range_chart_with_background", "name": "Power consumption range chart with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/power_consumption_range_chart_with_background.svg", "description": "Displays changes to power consumption data over time visualized with color ranges and background.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pressure_card.json b/application/src/main/data/json/system/widget_types/pressure_card.json index 9138ab6391..3d5afbd1d9 100644 --- a/application/src/main/data/json/system/widget_types/pressure_card.json +++ b/application/src/main/data/json/system/widget_types/pressure_card.json @@ -1,7 +1,7 @@ { "fqn": "pressure_card", "name": "Pressure card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pressure_card_system_widget_image.png", "description": "Displays the latest pressure telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pressure_card_with_background.json b/application/src/main/data/json/system/widget_types/pressure_card_with_background.json index d87db84e8a..fa82b7790c 100644 --- a/application/src/main/data/json/system/widget_types/pressure_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/pressure_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "pressure_card_with_background", "name": "Pressure card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pressure_card_with_background_system_widget_image.png", "description": "Displays the latest pressure telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pressure_chart_card.json b/application/src/main/data/json/system/widget_types/pressure_chart_card.json index e0ea0e9895..0ec733b6de 100644 --- a/application/src/main/data/json/system/widget_types/pressure_chart_card.json +++ b/application/src/main/data/json/system/widget_types/pressure_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "pressure_chart_card", "name": "Pressure chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pressure_chart_card_system_widget_image.png", "description": "Displays a pressure data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pressure_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/pressure_chart_card_with_background.json index 58c5859931..d5aefcba58 100644 --- a/application/src/main/data/json/system/widget_types/pressure_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/pressure_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "pressure_chart_card_with_background", "name": "Pressure chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pressure_chart_card_with_background_system_widget_image.png", "description": "Displays a pressure data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pressure_progress_bar.json b/application/src/main/data/json/system/widget_types/pressure_progress_bar.json index 93d19780ae..652b18c60b 100644 --- a/application/src/main/data/json/system/widget_types/pressure_progress_bar.json +++ b/application/src/main/data/json/system/widget_types/pressure_progress_bar.json @@ -1,7 +1,7 @@ { "fqn": "pressure_progress_bar", "name": "Pressure progress bar", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pressure_progress_bar_system_widget_image.png", "description": "Displays pressure reading as a horizontal progress bar. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pressure_progress_bar_with_background.json b/application/src/main/data/json/system/widget_types/pressure_progress_bar_with_background.json index e5189912d2..6ed924e65d 100644 --- a/application/src/main/data/json/system/widget_types/pressure_progress_bar_with_background.json +++ b/application/src/main/data/json/system/widget_types/pressure_progress_bar_with_background.json @@ -1,7 +1,7 @@ { "fqn": "pressure_progress_bar_with_background", "name": "Pressure progress bar with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/pressure_progress_bar_with_background_system_widget_image.png", "description": "Displays pressure reading as a horizontal progress bar with background. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pump_vibration_card.json b/application/src/main/data/json/system/widget_types/pump_vibration_card.json index 414cfaeb36..9ec50e2b1d 100644 --- a/application/src/main/data/json/system/widget_types/pump_vibration_card.json +++ b/application/src/main/data/json/system/widget_types/pump_vibration_card.json @@ -1,7 +1,7 @@ { "fqn": "pump_vibration_card", "name": "Vibration card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/vibration_card.svg", "description": "Displays the latest vibration telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pump_vibration_card_with_background.json b/application/src/main/data/json/system/widget_types/pump_vibration_card_with_background.json index c3e90f61a3..3b16aafca2 100644 --- a/application/src/main/data/json/system/widget_types/pump_vibration_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/pump_vibration_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "pump_vibration_card_with_background", "name": "Vibration card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/vibration_card_with_background.svg", "description": "Displays the latest vibration telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pump_vibration_chart_card.json b/application/src/main/data/json/system/widget_types/pump_vibration_chart_card.json index acbe3524e0..c887086f5a 100644 --- a/application/src/main/data/json/system/widget_types/pump_vibration_chart_card.json +++ b/application/src/main/data/json/system/widget_types/pump_vibration_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "pump_vibration_chart_card", "name": "Vibration chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/vibration_chart_card.svg", "description": "Displays vibration data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pump_vibration_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/pump_vibration_chart_card_with_background.json index 46c249f200..78813ec690 100644 --- a/application/src/main/data/json/system/widget_types/pump_vibration_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/pump_vibration_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "pump_vibration_chart_card_with_background", "name": "Vibration chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/vibration_chart_card_with_background.svg", "description": "Displays vibrarion data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pump_vibration_range_chart.json b/application/src/main/data/json/system/widget_types/pump_vibration_range_chart.json index 561130c14e..735484b0eb 100644 --- a/application/src/main/data/json/system/widget_types/pump_vibration_range_chart.json +++ b/application/src/main/data/json/system/widget_types/pump_vibration_range_chart.json @@ -1,7 +1,7 @@ { "fqn": "pump_vibration_range_chart", "name": "Vibration range chart", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/vibration_range_chart.svg", "description": "Displays changes to vibration data over time visualized with color ranges.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/pump_vibration_range_chart_with_background.json b/application/src/main/data/json/system/widget_types/pump_vibration_range_chart_with_background.json index 27ba578048..86667b8b5a 100644 --- a/application/src/main/data/json/system/widget_types/pump_vibration_range_chart_with_background.json +++ b/application/src/main/data/json/system/widget_types/pump_vibration_range_chart_with_background.json @@ -1,7 +1,7 @@ { "fqn": "pump_vibration_range_chart_with_background", "name": "Vibration range chart with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/vibration_range_chart_with_background.svg", "description": "Displays changes to vibration data over time visualized with color ranges and background.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/radon_level_card.json b/application/src/main/data/json/system/widget_types/radon_level_card.json index 8c8acc6a88..804e24d0d3 100644 --- a/application/src/main/data/json/system/widget_types/radon_level_card.json +++ b/application/src/main/data/json/system/widget_types/radon_level_card.json @@ -1,7 +1,7 @@ { "fqn": "radon_level_card", "name": "Radon level card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/radon_level_card_system_widget_image.png", "description": "Displays the latest radon level telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/radon_level_card_with_background.json b/application/src/main/data/json/system/widget_types/radon_level_card_with_background.json index 9f5b6f79ad..8573966948 100644 --- a/application/src/main/data/json/system/widget_types/radon_level_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/radon_level_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "radon_level_card_with_background", "name": "Radon level card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/radon_level_card_with_background_system_widget_image.png", "description": "Displays the latest radon level telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/radon_level_chart_card.json b/application/src/main/data/json/system/widget_types/radon_level_chart_card.json index d76f5d2d9f..7a3a2a4c74 100644 --- a/application/src/main/data/json/system/widget_types/radon_level_chart_card.json +++ b/application/src/main/data/json/system/widget_types/radon_level_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "radon_level_chart_card", "name": "Radon level chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/radon_level_chart_card_system_widget_image.png", "description": "Displays a radon level data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/radon_level_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/radon_level_chart_card_with_background.json index aded058cd6..807cf86888 100644 --- a/application/src/main/data/json/system/widget_types/radon_level_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/radon_level_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "radon_level_chart_card_with_background", "name": "Radon level chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/radon_level_chart_card_with_background_system_widget_image.png", "description": "Displays a radon level data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/rainfall_card.json b/application/src/main/data/json/system/widget_types/rainfall_card.json index 04ce14806b..a6e83db315 100644 --- a/application/src/main/data/json/system/widget_types/rainfall_card.json +++ b/application/src/main/data/json/system/widget_types/rainfall_card.json @@ -1,7 +1,7 @@ { "fqn": "rainfall_card", "name": "Rainfall card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/rainfall_card_system_widget_image.png", "description": "Displays the latest rainfall telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/rainfall_card_with_background.json b/application/src/main/data/json/system/widget_types/rainfall_card_with_background.json index 81ede92ad1..7d3aec5759 100644 --- a/application/src/main/data/json/system/widget_types/rainfall_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/rainfall_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "rainfall_card_with_background", "name": "Rainfall card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/rainfall_card_with_background_system_widget_image.png", "description": "Displays the latest rainfall telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/rainfall_chart_card.json b/application/src/main/data/json/system/widget_types/rainfall_chart_card.json index b5bd51ad00..9aa17966d6 100644 --- a/application/src/main/data/json/system/widget_types/rainfall_chart_card.json +++ b/application/src/main/data/json/system/widget_types/rainfall_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "rainfall_chart_card", "name": "Rainfall chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/rainfall_chart_card_system_widget_image.png", "description": "Displays a rainfall data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/rainfall_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/rainfall_chart_card_with_background.json index f25ba166f7..d70028dff0 100644 --- a/application/src/main/data/json/system/widget_types/rainfall_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/rainfall_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "rainfall_chart_card_with_background", "name": "Rainfall chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/rainfall_chart_card_with_background_system_widget_image.png", "description": "Displays a rainfall data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/rotational_speed_card.json b/application/src/main/data/json/system/widget_types/rotational_speed_card.json index 681d53c66d..05dd27a0bd 100644 --- a/application/src/main/data/json/system/widget_types/rotational_speed_card.json +++ b/application/src/main/data/json/system/widget_types/rotational_speed_card.json @@ -1,7 +1,7 @@ { "fqn": "rotational_speed_card", "name": "Rotational speed card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/rotational_speed_card.svg", "description": "Displays the latest rotational speed telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/rotational_speed_card_with_background.json b/application/src/main/data/json/system/widget_types/rotational_speed_card_with_background.json index efaa958688..c48ad0c699 100644 --- a/application/src/main/data/json/system/widget_types/rotational_speed_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/rotational_speed_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "rotational_speed_card_with_background", "name": "Rotational speed card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/rotational_speed_card_with_background.svg", "description": "Displays the latest rotational speed telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/rotational_speed_chart_card.json b/application/src/main/data/json/system/widget_types/rotational_speed_chart_card.json index d5c48138f2..13781dcc3b 100644 --- a/application/src/main/data/json/system/widget_types/rotational_speed_chart_card.json +++ b/application/src/main/data/json/system/widget_types/rotational_speed_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "rotational_speed_chart_card", "name": "Rotational speed chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/rotational_speed_chart_card.svg", "description": "Displays rotational speed data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/rotational_speed_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/rotational_speed_chart_card_with_background.json index 1e2a0c2397..b99138d7eb 100644 --- a/application/src/main/data/json/system/widget_types/rotational_speed_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/rotational_speed_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "rotational_speed_chart_card_with_background", "name": "Rotational speed chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/rotational_speed_chart_card_with_background.svg", "description": "Displays rotational speed data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/rotational_speed_gauge.json b/application/src/main/data/json/system/widget_types/rotational_speed_gauge.json index f42d801090..6cc99263e8 100644 --- a/application/src/main/data/json/system/widget_types/rotational_speed_gauge.json +++ b/application/src/main/data/json/system/widget_types/rotational_speed_gauge.json @@ -1,7 +1,7 @@ { "fqn": "rotational_speed_gauge", "name": "Rotational speed gauge", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/rotational_speed_gauge.svg", "description": "Rotational speed gauge.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/rotational_speed_progress_bar.json b/application/src/main/data/json/system/widget_types/rotational_speed_progress_bar.json index 7288b70478..ec57b21f9a 100644 --- a/application/src/main/data/json/system/widget_types/rotational_speed_progress_bar.json +++ b/application/src/main/data/json/system/widget_types/rotational_speed_progress_bar.json @@ -1,7 +1,7 @@ { "fqn": "rotational_speed_progress_bar", "name": "Rotational speed progress bar", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/rotational_speed_progress_bar.svg", "description": "Displays rotational speed reading as a horizontal progress bar. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/rotational_speed_progress_bar_with_background.json b/application/src/main/data/json/system/widget_types/rotational_speed_progress_bar_with_background.json index c8d2f0e188..90aa6db6b6 100644 --- a/application/src/main/data/json/system/widget_types/rotational_speed_progress_bar_with_background.json +++ b/application/src/main/data/json/system/widget_types/rotational_speed_progress_bar_with_background.json @@ -1,7 +1,7 @@ { "fqn": "rotational_speed_progress_bar_with_background", "name": "Rotational speed progress bar with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/rotational_speed_progress_bar_with_background.svg", "description": "Displays rotational speed reading as a horizontal progress bar with background. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/rotational_speed_range_chart.json b/application/src/main/data/json/system/widget_types/rotational_speed_range_chart.json index bcbeb8c9a8..02b7279d0b 100644 --- a/application/src/main/data/json/system/widget_types/rotational_speed_range_chart.json +++ b/application/src/main/data/json/system/widget_types/rotational_speed_range_chart.json @@ -1,7 +1,7 @@ { "fqn": "rotational_speed_range_chart", "name": "Rotational speed range chart", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/rotational_speed_range_chart.svg", "description": "Displays changes to rotational speed data over time visualized with color ranges.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/rotational_speed_range_chart_with_background.json b/application/src/main/data/json/system/widget_types/rotational_speed_range_chart_with_background.json index 537314bd0d..c4db34c71f 100644 --- a/application/src/main/data/json/system/widget_types/rotational_speed_range_chart_with_background.json +++ b/application/src/main/data/json/system/widget_types/rotational_speed_range_chart_with_background.json @@ -1,7 +1,7 @@ { "fqn": "rotational_speed_range_chart_with_background", "name": "Rotational speed range chart with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/rotational_speed_range_chart_with_background.svg", "description": "Displays changes to rotational speed data over time visualized with color ranges and background.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_air_quality_index_chart_card.json b/application/src/main/data/json/system/widget_types/simple_air_quality_index_chart_card.json index 812aa8694b..7145a8be5d 100644 --- a/application/src/main/data/json/system/widget_types/simple_air_quality_index_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_air_quality_index_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_air_quality_chart_card", "name": "Simple air quality index chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_air_quality_index_chart_card_system_widget_image.png", "description": "Displays historical air quality index values as a simplified chart. Optionally may display the corresponding latest air quality index value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_air_quality_index_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_air_quality_index_chart_card_with_background.json index 1c22c060f2..1d0df6e671 100644 --- a/application/src/main/data/json/system/widget_types/simple_air_quality_index_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_air_quality_index_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_air_quality_chart_card_with_background", "name": "Simple air quality index chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_air_quality_index_chart_card_with_background_system_widget_image.png", "description": "Displays historical air quality index values as a simplified chart with background. Optionally may display the corresponding latest air quality index value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_carbon_monoxide__co__chart_card.json b/application/src/main/data/json/system/widget_types/simple_carbon_monoxide__co__chart_card.json index d494338fee..838e7d79ab 100644 --- a/application/src/main/data/json/system/widget_types/simple_carbon_monoxide__co__chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_carbon_monoxide__co__chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_carbon_monoxide_co_chart_card", "name": "Simple carbon monoxide (CO) chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/CO-simple-value-and-chart-card.svg", "description": "A colorless and odorless gas resulting from incomplete combustion, particularly in motor vehicles. Results displayed by combining the latest and aggregated values and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_carbon_monoxide__co__chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_carbon_monoxide__co__chart_card_with_background.json index 66364f8d06..d64b6cd6aa 100644 --- a/application/src/main/data/json/system/widget_types/simple_carbon_monoxide__co__chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_carbon_monoxide__co__chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_carbon_monoxide_co_chart_card_with_background", "name": "Simple carbon monoxide (CO) chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/CO-simple-chart-value-card-with-background.svg", "description": "A colorless and odorless gas resulting from incomplete combustion, particularly in motor vehicles. Results displayed by combining the latest and aggregated values and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_co2_chart_card.json b/application/src/main/data/json/system/widget_types/simple_co2_chart_card.json index 9e4be18356..f90c813214 100644 --- a/application/src/main/data/json/system/widget_types/simple_co2_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_co2_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_co2_chart_card", "name": "Simple CO2 chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_co2_chart_card_system_widget_image.png", "description": "Displays historical CO2 level values as a simplified chart. Optionally may display the corresponding latest CO2 level value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_co2_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_co2_chart_card_with_background.json index d6ef0088b4..6c87c52552 100644 --- a/application/src/main/data/json/system/widget_types/simple_co2_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_co2_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_co2_chart_card_with_background", "name": "Simple CO2 chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_co2_chart_card_with_background_system_widget_image.png", "description": "Displays historical CO2 level values as a simplified chart with background. Optionally may display the corresponding latest CO2 level value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_efficiency_chart_card.json b/application/src/main/data/json/system/widget_types/simple_efficiency_chart_card.json index d957a82a58..03d8d320f6 100644 --- a/application/src/main/data/json/system/widget_types/simple_efficiency_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_efficiency_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_efficiency_chart_card", "name": "Simple efficiency chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_efficiency_chart_card.svg", "description": "Displays historical efficiency values as a simplified chart. Optionally may display the corresponding efficiency value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_efficiency_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_efficiency_chart_card_with_background.json index 9fe02a7725..f0b6f95cf1 100644 --- a/application/src/main/data/json/system/widget_types/simple_efficiency_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_efficiency_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_efficiency_chart_card_with_background", "name": "Simple efficiency chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_efficiency_chart_card_with_background.svg", "description": "Displays historical efficiency values as a simplified chart with background. Optionally may display the corresponding efficiency value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_flooding_level_chart_card.json b/application/src/main/data/json/system/widget_types/simple_flooding_level_chart_card.json index 55a4da3aba..a00bf0a1bd 100644 --- a/application/src/main/data/json/system/widget_types/simple_flooding_level_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_flooding_level_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_flooding_level_chart_card", "name": "Simple flooding level chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_flooding_level_chart_card_system_widget_image.png", "description": "Displays historical flooding level values as a simplified chart. Optionally may display the corresponding latest flooding level value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_flooding_level_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_flooding_level_chart_card_with_background.json index dab9a38433..d1a6af6c4c 100644 --- a/application/src/main/data/json/system/widget_types/simple_flooding_level_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_flooding_level_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_flooding_level_chart_card_with_background", "name": "Simple flooding level chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_flooding_level_chart_card_with_background_system_widget_image.png", "description": "Displays historical flooding level values as a simplified chart with background. Optionally may display the corresponding latest flooding level value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_flow_rate_chart_card.json b/application/src/main/data/json/system/widget_types/simple_flow_rate_chart_card.json index 05fd114507..455972e0b4 100644 --- a/application/src/main/data/json/system/widget_types/simple_flow_rate_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_flow_rate_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_flow_rate_chart_card", "name": "Simple flow rate chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_flow_rate_chart_card_(1).svg", "description": "Displays historical flow rate values as a simplified chart. Optionally may display the corresponding latest flow rate value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_flow_rate_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_flow_rate_chart_card_with_background.json index bea92879b0..d1bd9e166a 100644 --- a/application/src/main/data/json/system/widget_types/simple_flow_rate_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_flow_rate_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_flow_rate_chart_card_with_background", "name": "Simple flow rate chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_flow_rate_chart_card_with_background.svg", "description": "Displays historical flow rate values as a simplified chart with background. Optionally may display the corresponding latest flow rate value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_fluid_pressure_chart_card.json b/application/src/main/data/json/system/widget_types/simple_fluid_pressure_chart_card.json index 57417c5986..cdb1c3653f 100644 --- a/application/src/main/data/json/system/widget_types/simple_fluid_pressure_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_fluid_pressure_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_fluid_pressure_chart_card", "name": "Simple pressure chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_pressure_chart_card_(1).svg", "description": "Displays historical fluid pressure values as a simplified chart. Optionally may display the corresponding latest fluid pressure value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_fluid_pressure_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_fluid_pressure_chart_card_with_background.json index e7b8f22933..240b66d43a 100644 --- a/application/src/main/data/json/system/widget_types/simple_fluid_pressure_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_fluid_pressure_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_fluid_pressure_chart_card_with_background", "name": "Simple pressure chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_pressure_chart_card_with_background.svg", "description": "Displays historical fluid pressure values as a simplified chart with background. Optionally may display the corresponding latest fluid pressure value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_ground_temperature_chart_card.json b/application/src/main/data/json/system/widget_types/simple_ground_temperature_chart_card.json index e57b06fdd1..717d72d4b9 100644 --- a/application/src/main/data/json/system/widget_types/simple_ground_temperature_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_ground_temperature_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_ground_temperature_chart_card", "name": "Simple ground temperature chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_ground_temperature_chart_card_system_widget_image.png", "description": "Displays historical ground temperature values as a simplified chart. Optionally may display the corresponding latest ground temperature value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_ground_temperature_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_ground_temperature_chart_card_with_background.json index 81f971a8ba..3fb22c1596 100644 --- a/application/src/main/data/json/system/widget_types/simple_ground_temperature_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_ground_temperature_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_ground_temperature_chart_card_with_background", "name": "Simple ground temperature chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_ground_temperature_chart_card_with_background_system_widget_image.png", "description": "Displays historical ground temperature values as a simplified chart with background. Optionally may display the corresponding latest ground temperature value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_humidity_chart_card.json b/application/src/main/data/json/system/widget_types/simple_humidity_chart_card.json index e45f5f63c5..0c2807920b 100644 --- a/application/src/main/data/json/system/widget_types/simple_humidity_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_humidity_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_humidity_chart_card", "name": "Simple humidity chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_humidity_chart_card_system_widget_image.png", "description": "Displays historical humidity values as a simplified chart. Optionally may display the corresponding latest humidity value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_humidity_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_humidity_chart_card_with_background.json index 484c9c1a08..94820fa8be 100644 --- a/application/src/main/data/json/system/widget_types/simple_humidity_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_humidity_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_humidity_chart_card_with_background", "name": "Simple humidity chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_humidity_chart_card_with_background_system_widget_image.png", "description": "Displays historical humidity values as a simplified chart with background. Optionally may display the corresponding latest humidity value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_illuminance_chart_card.json b/application/src/main/data/json/system/widget_types/simple_illuminance_chart_card.json index 43c8a79361..5e2ca4f008 100644 --- a/application/src/main/data/json/system/widget_types/simple_illuminance_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_illuminance_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_illuminance_chart_card", "name": "Simple illuminance chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_illuminance_chart_card_system_widget_image.png", "description": "Displays historical illuminance values as a simplified chart. Optionally may display the corresponding latest illuminance value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_illuminance_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_illuminance_chart_card_with_background.json index 373fac46dc..0495640507 100644 --- a/application/src/main/data/json/system/widget_types/simple_illuminance_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_illuminance_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_illuminance_chart_card_with_background", "name": "Simple illuminance chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_illuminance_chart_card_with_background_system_widget_image.png", "description": "Displays historical illuminance values as a simplified chart with background. Optionally may display the corresponding latest illuminance value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_individual_allergy_index__iai__chart_card.json b/application/src/main/data/json/system/widget_types/simple_individual_allergy_index__iai__chart_card.json index 81764c775f..018de4a068 100644 --- a/application/src/main/data/json/system/widget_types/simple_individual_allergy_index__iai__chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_individual_allergy_index__iai__chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_individual_allergy_index_iai_chart_card", "name": "Simple individual allergy index (IAI) chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple-IAI-value-and-chart-card.svg", "description": "Displays the concentration of airborne allergens, including pollen and mold spores, which can trigger allergic reactions in sensitive individuals as a simplified chart. Optionally may display the corresponding latest concentration of allergens value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_individual_allergy_index__iai__chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_individual_allergy_index__iai__chart_card_with_background.json index 283ff4a8e0..8a24d9446e 100644 --- a/application/src/main/data/json/system/widget_types/simple_individual_allergy_index__iai__chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_individual_allergy_index__iai__chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_individual_allergy_index_iai_chart_card_with_background2", "name": "Simple individual allergy index (IAI) chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/IAI-simple-chart-value-card-with-background.svg", "description": "Displays the concentration of airborne allergens, including pollen and mold spores, which can trigger allergic reactions in sensitive individuals as a simplified chart. Optionally may display the corresponding latest concentration of allergens value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_leaf_wetness_chart_card.json b/application/src/main/data/json/system/widget_types/simple_leaf_wetness_chart_card.json index 2205278c9f..c598469ff0 100644 --- a/application/src/main/data/json/system/widget_types/simple_leaf_wetness_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_leaf_wetness_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_leaf_wetness_chart_card", "name": "Simple leaf wetness chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_leaf_wetness_chart_card_system_widget_image.png", "description": "Displays historical leaf wetness values as a simplified chart. Optionally may display the corresponding latest leaf wetness value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_leaf_wetness_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_leaf_wetness_chart_card_with_background.json index d71fc4a5bb..cc47bced80 100644 --- a/application/src/main/data/json/system/widget_types/simple_leaf_wetness_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_leaf_wetness_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_leaf_wetness_chart_card_with_background", "name": "Simple leaf wetness chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_leaf_wetness_chart_card_with_background_system_widget_image.png", "description": "Displays historical leaf wetness values as a simplified chart with background. Optionally may display the corresponding latest leaf wetness value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_nitrogen_dioxide__no2__chart_card.json b/application/src/main/data/json/system/widget_types/simple_nitrogen_dioxide__no2__chart_card.json index 0a1c1d8657..5ed427bbaf 100644 --- a/application/src/main/data/json/system/widget_types/simple_nitrogen_dioxide__no2__chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_nitrogen_dioxide__no2__chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_nitrogen_dioxide_no2_chart_card", "name": "Simple nitrogen dioxide (NO2) chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple-NO2-value-and-chart-card.svg", "description": "A gas produced from vehicle emissions and the burning of fossil fuels. Results displayed by combining the latest and aggregated values and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_nitrogen_dioxide__no2__chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_nitrogen_dioxide__no2__chart_card_with_background.json index 892238dc9b..43fe8dbe83 100644 --- a/application/src/main/data/json/system/widget_types/simple_nitrogen_dioxide__no2__chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_nitrogen_dioxide__no2__chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_nitrogen_dioxide_no2_chart_card_with_background", "name": "Simple nitrogen dioxide (NO2) chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/NO2-simple-chart-value-card-with-background.svg", "description": "A gas produced from vehicle emissions and the burning of fossil fuels. Results displayed by combining the latest and aggregated values and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_noise_level_chart_card.json b/application/src/main/data/json/system/widget_types/simple_noise_level_chart_card.json index d33eed776f..81bb1bbff2 100644 --- a/application/src/main/data/json/system/widget_types/simple_noise_level_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_noise_level_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_noise_level_chart_card", "name": "Simple noise level chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_noise_level_chart_card_system_widget_image.png", "description": "Displays historical noise level values as a simplified chart. Optionally may display the corresponding latest noise level value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_noise_level_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_noise_level_chart_card_with_background.json index 225d30b14c..76236956fb 100644 --- a/application/src/main/data/json/system/widget_types/simple_noise_level_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_noise_level_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_noise_level_chart_card_with_background", "name": "Simple noise level chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_noise_level_chart_card_with_background_system_widget_image.png", "description": "Displays historical noise level values as a simplified chart with background. Optionally may display the corresponding latest noise level value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_ozone__o3__chart_card.json b/application/src/main/data/json/system/widget_types/simple_ozone__o3__chart_card.json index b2c9eb5e97..cd7510ddd3 100644 --- a/application/src/main/data/json/system/widget_types/simple_ozone__o3__chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_ozone__o3__chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_ozone_o3_chart_card", "name": "Simple ozone (O3) chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple-ozone-value-and-chart-card.svg", "description": "A beneficial layer in the upper atmosphere, but harmful when present near ground level. Results displays mainly from vehicle exhaust and industrial emissions by combining the latest and aggregated values and optional simplified chart. ", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_ozone__o3__chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_ozone__o3__chart_card_with_background.json index 88995bb4f5..6601f78b1a 100644 --- a/application/src/main/data/json/system/widget_types/simple_ozone__o3__chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_ozone__o3__chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_ozone_o3_chart_card_with_background", "name": "Simple ozone (O3) chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/ozone-chart-value-card-with-background.svg", "description": "A beneficial layer in the upper atmosphere, but harmful when present near ground level. Results displays mainly from vehicle exhaust and industrial emissions by combining the latest and aggregated values and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_pm10_chart_card.json b/application/src/main/data/json/system/widget_types/simple_pm10_chart_card.json index 6e7d5c1369..691f6f633c 100644 --- a/application/src/main/data/json/system/widget_types/simple_pm10_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_pm10_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_pm10_chart_card", "name": "Simple PM10 chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_pm10_chart_card_system_widget_image.png", "description": "Displays historical fine and coarse particulate matter (PM10) values as a simplified chart. Optionally may display the corresponding latest PM10 value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_pm10_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_pm10_chart_card_with_background.json index a0b3d74e54..d8fd1d528a 100644 --- a/application/src/main/data/json/system/widget_types/simple_pm10_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_pm10_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_pm10_chart_card_with_background", "name": "Simple PM10 chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_pm10_chart_card_with_background_system_widget_image.png", "description": "Displays historical fine and coarse particulate matter (PM10) values as a simplified chart with background. Optionally may display the corresponding latest PM10 value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_pm2_5_chart_card.json b/application/src/main/data/json/system/widget_types/simple_pm2_5_chart_card.json index 25502227f3..5f1e083dea 100644 --- a/application/src/main/data/json/system/widget_types/simple_pm2_5_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_pm2_5_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_pm2_5_chart_card", "name": "Simple PM2.5 chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_pm2_5_chart_card_system_widget_image.png", "description": "Displays historical fine particulate matter (PM2.5) values as a simplified chart. Optionally may display the corresponding latest PM2.5 value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_pm2_5_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_pm2_5_chart_card_with_background.json index ccdf3ba0a1..98f2a91d22 100644 --- a/application/src/main/data/json/system/widget_types/simple_pm2_5_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_pm2_5_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_pm2_5_chart_card_with_background", "name": "Simple PM2.5 chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_pm2_5_chart_card_with_background_system_widget_image.png", "description": "Displays historical fine particulate matter (PM2.5) values as a simplified chart with background. Optionally may display the corresponding latest PM2.5 value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_power_consumption_chart_card.json b/application/src/main/data/json/system/widget_types/simple_power_consumption_chart_card.json index befea52bb3..f123980aa0 100644 --- a/application/src/main/data/json/system/widget_types/simple_power_consumption_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_power_consumption_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_power_consumption_chart_card", "name": "Simple power consumption chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_power_consumption_chart_card_(1).svg", "description": "Displays historical power consumption values as a simplified chart. Optionally may display the corresponding latest power consumption value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_power_consumption_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_power_consumption_chart_card_with_background.json index f0bb8ba9e9..ac9221538e 100644 --- a/application/src/main/data/json/system/widget_types/simple_power_consumption_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_power_consumption_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_power_consumption_chart_card_with_background", "name": "Simple power consumption chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_power_consumption_chart_card_with_background.svg", "description": "Displays historical power consumption values as a simplified chart with background. Optionally may display the corresponding latest power consumption value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_pressure_chart_card.json b/application/src/main/data/json/system/widget_types/simple_pressure_chart_card.json index dcc4b11510..ea07d4defe 100644 --- a/application/src/main/data/json/system/widget_types/simple_pressure_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_pressure_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_pressure_chart_card", "name": "Simple pressure chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_pressure_chart_card_system_widget_image.png", "description": "Displays historical pressure values as a simplified chart. Optionally may display the corresponding latest pressure value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_pressure_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_pressure_chart_card_with_background.json index 0c177e6364..6360e69120 100644 --- a/application/src/main/data/json/system/widget_types/simple_pressure_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_pressure_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_pressure_chart_card_with_background", "name": "Simple pressure chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_pressure_chart_card_with_background_system_widget_image.png", "description": "Displays historical pressure values as a simplified chart with background. Optionally may display the corresponding latest pressure value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_pump_vibration_chart_card.json b/application/src/main/data/json/system/widget_types/simple_pump_vibration_chart_card.json index a84a49e2de..7a9d8b90a1 100644 --- a/application/src/main/data/json/system/widget_types/simple_pump_vibration_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_pump_vibration_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_pump_vibration_chart_card", "name": "Simple vibration chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_vibration_chart_card_(1).svg", "description": "Displays historical vibration values as a simplified chart. Optionally may display the corresponding latest vibration value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_pump_vibration_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_pump_vibration_chart_card_with_background.json index 5cb064c807..dc30f0f5ba 100644 --- a/application/src/main/data/json/system/widget_types/simple_pump_vibration_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_pump_vibration_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_pump_vibration_chart_card_with_background", "name": "Simple vibration chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_vibration_chart_card_with_background.svg", "description": "Displays historical vibration values as a simplified chart with background. Optionally may display the corresponding latest vibration value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_radon_level_chart_card.json b/application/src/main/data/json/system/widget_types/simple_radon_level_chart_card.json index 27faab79ac..446e1ecac4 100644 --- a/application/src/main/data/json/system/widget_types/simple_radon_level_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_radon_level_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_radon_level_chart_card", "name": "Simple radon level chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_radon_level_chart_card_system_widget_image.png", "description": "Displays historical radon level values as a simplified chart. Optionally may display the corresponding latest radon level value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_radon_level_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_radon_level_chart_card_with_background.json index 84e90026ad..a105267cc9 100644 --- a/application/src/main/data/json/system/widget_types/simple_radon_level_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_radon_level_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_radon_level_chart_card_with_background", "name": "Simple radon level chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_radon_level_chart_card_with_background_system_widget_image.png", "description": "Displays historical radon level values as a simplified chart with background. Optionally may display the corresponding latest radon level value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_rainfall_chart_card.json b/application/src/main/data/json/system/widget_types/simple_rainfall_chart_card.json index ddbf9c66ee..b195796912 100644 --- a/application/src/main/data/json/system/widget_types/simple_rainfall_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_rainfall_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_rainfall_chart_card", "name": "Simple rainfall chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_rainfall_chart_card_system_widget_image.png", "description": "Displays historical rainfall values as a simplified chart. Optionally may display the corresponding latest rainfall value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_rainfall_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_rainfall_chart_card_with_background.json index c5f82d49b9..90c6c65228 100644 --- a/application/src/main/data/json/system/widget_types/simple_rainfall_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_rainfall_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_rainfall_chart_card_with_background", "name": "Simple rainfall chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_rainfall_chart_card_with_background_system_widget_image.png", "description": "Displays historical rainfall values as a simplified chart with background. Optionally may display the corresponding latest rainfall value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_rotational_speed_chart_card.json b/application/src/main/data/json/system/widget_types/simple_rotational_speed_chart_card.json index 9e39e6ad0a..12677ae61c 100644 --- a/application/src/main/data/json/system/widget_types/simple_rotational_speed_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_rotational_speed_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_rotational_speed_chart_card", "name": "Simple rotational speed chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_rotational_speed_chart_card.svg", "description": "Displays historical rotational speed values as a simplified chart. Optionally may display the corresponding latest rotational speed value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_rotational_speed_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_rotational_speed_chart_card_with_background.json index 2f338515c5..16f77d316c 100644 --- a/application/src/main/data/json/system/widget_types/simple_rotational_speed_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_rotational_speed_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_rotational_speed_chart_card_with_background", "name": "Simple rotational speed chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_rotational_speed_chart_card_with_background.svg", "description": "Displays historical rotational speed values as a simplified chart with background. Optionally may display the corresponding latest rotational speed value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_snow_depth_chart_card.json b/application/src/main/data/json/system/widget_types/simple_snow_depth_chart_card.json index e23671c563..35f841e85e 100644 --- a/application/src/main/data/json/system/widget_types/simple_snow_depth_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_snow_depth_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_snow_depth_chart_card", "name": "Simple snow depth chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_snow_depth_chart_card_system_widget_image.png", "description": "Displays historical snow depth values as a simplified chart. Optionally may display the corresponding latest snow depth value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_snow_depth_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_snow_depth_chart_card_with_background.json index 48010dbcc9..92680d4f65 100644 --- a/application/src/main/data/json/system/widget_types/simple_snow_depth_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_snow_depth_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_snow_depth_chart_card_with_background", "name": "Simple snow depth chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_snow_depth_chart_card_with_background_system_widget_image.png", "description": "Displays historical snow depth values as a simplified chart with background. Optionally may display the corresponding latest snow depth value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_soil_moisture_chart_card.json b/application/src/main/data/json/system/widget_types/simple_soil_moisture_chart_card.json index 3f9e4450aa..32401d128a 100644 --- a/application/src/main/data/json/system/widget_types/simple_soil_moisture_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_soil_moisture_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_soil_moisture_chart_card", "name": "Simple soil moisture chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_soil_moisture_chart_card_system_widget_image.png", "description": "Displays historical soil moisture values as a simplified chart. Optionally may display the corresponding latest soil moisture value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_soil_moisture_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_soil_moisture_chart_card_with_background.json index 916c7d7d37..619fa49dcc 100644 --- a/application/src/main/data/json/system/widget_types/simple_soil_moisture_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_soil_moisture_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_soil_moisture_chart_card_with_background", "name": "Simple soil moisture chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_soil_moisture_chart_card_with_background_system_widget_image.png", "description": "Displays historical soil moisture values as a simplified chart with background. Optionally may display the corresponding latest soil moisture value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_solar_radiation_chart_card.json b/application/src/main/data/json/system/widget_types/simple_solar_radiation_chart_card.json index e3ebfac43a..a02997a7a6 100644 --- a/application/src/main/data/json/system/widget_types/simple_solar_radiation_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_solar_radiation_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_solar_radiation_chart_card", "name": "Simple solar radiation chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_solar_radiation_chart_card_system_widget_image.png", "description": "Displays historical solar radiation values as a simplified chart. Optionally may display the corresponding latest solar radiation value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_solar_radiation_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_solar_radiation_chart_card_with_background.json index f3fc53349f..b33497ba6b 100644 --- a/application/src/main/data/json/system/widget_types/simple_solar_radiation_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_solar_radiation_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_solar_radiation_chart_card_with_background", "name": "Simple solar radiation chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_solar_radiation_chart_card_with_background_system_widget_image.png", "description": "Displays historical solar radiation values as a simplified chart with background. Optionally may display the corresponding latest solar radiation value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_sulfur_dioxide__so2__chart_card.json b/application/src/main/data/json/system/widget_types/simple_sulfur_dioxide__so2__chart_card.json index 0a91102cdc..3d4c2c6381 100644 --- a/application/src/main/data/json/system/widget_types/simple_sulfur_dioxide__so2__chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_sulfur_dioxide__so2__chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_sulfur_dioxide_so2_chart_card", "name": "Simple sulfur dioxide (SO2) chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/SO2-simple-value-and-chart-card.svg", "description": "Produced by volcanoes and industrial processes, particularly the burning of coal and oil. Results displayed by combining the latest and aggregated values and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_sulfur_dioxide__so2__chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_sulfur_dioxide__so2__chart_card_with_background.json index 2a016e3072..1d76efc260 100644 --- a/application/src/main/data/json/system/widget_types/simple_sulfur_dioxide__so2__chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_sulfur_dioxide__so2__chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_sulfur_dioxide_so2_chart_card_with_background", "name": "Simple sulfur dioxide (SO2) chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/SO2-simple-chart-value-card-with-background.svg", "description": "Produced by volcanoes and industrial processes, particularly the burning of coal and oil. Results displayed by combining the latest and aggregated values and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_temperature_chart_card.json b/application/src/main/data/json/system/widget_types/simple_temperature_chart_card.json index bac4ec201e..dd99a6593d 100644 --- a/application/src/main/data/json/system/widget_types/simple_temperature_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_temperature_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_temperature_chart_card", "name": "Simple temperature chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_temperature_chart_card_system_widget_image.png", "description": "Displays historical temperature values as a simplified chart. Optionally may display the corresponding latest temperature value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_temperature_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_temperature_chart_card_with_background.json index 50145be107..ad5df7c8fa 100644 --- a/application/src/main/data/json/system/widget_types/simple_temperature_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_temperature_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_temperature_chart_card_with_background", "name": "Simple temperature chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_temperature_chart_card_with_background_system_widget_image.png", "description": "Displays historical temperature values as a simplified chart with background. Optionally may display the corresponding latest temperature value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_uv_index_chart_card.json b/application/src/main/data/json/system/widget_types/simple_uv_index_chart_card.json index d10f55833b..23e4aefaf2 100644 --- a/application/src/main/data/json/system/widget_types/simple_uv_index_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_uv_index_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_uv_index_chart_card", "name": "Simple UV Index chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_uv_index_chart_card_system_widget_image.png", "description": "Displays historical UV index values as a simplified chart. Optionally may display the corresponding latest UV index value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_uv_index_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_uv_index_chart_card_with_background.json index a1ba49e7bc..bc93c8267a 100644 --- a/application/src/main/data/json/system/widget_types/simple_uv_index_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_uv_index_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_uv_index_chart_card_with_background", "name": "Simple UV Index chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_uv_index_chart_card_with_background_system_widget_image.png", "description": "Displays historical UV index values as a simplified chart with background. Optionally may display the corresponding latest UV index value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_vibration_chart_card.json b/application/src/main/data/json/system/widget_types/simple_vibration_chart_card.json index a045af5ef6..a2fd2087d0 100644 --- a/application/src/main/data/json/system/widget_types/simple_vibration_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_vibration_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_vibration_chart_card", "name": "Simple vibration chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_vibration_chart_card_system_widget_image.png", "description": "Displays historical vibration values as a simplified chart. Optionally may display the corresponding latest vibration value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_vibration_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_vibration_chart_card_with_background.json index 9e39eed359..5eb472b4b7 100644 --- a/application/src/main/data/json/system/widget_types/simple_vibration_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_vibration_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_vibration_chart_card_with_background", "name": "Simple vibration chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_vibration_chart_card_with_background_system_widget_image.png", "description": "Displays historical vibration values as a simplified chart with background. Optionally may display the corresponding latest vibration value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_visibility_chart_card.json b/application/src/main/data/json/system/widget_types/simple_visibility_chart_card.json index 7f098c8d96..4391a693ab 100644 --- a/application/src/main/data/json/system/widget_types/simple_visibility_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_visibility_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_visibility_chart_card", "name": "Simple visibility chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_visibility_chart_card_system_widget_image.png", "description": "Displays historical visibility values as a simplified chart. Optionally may display the corresponding latest visibility value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_visibility_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_visibility_chart_card_with_background.json index 6deff18090..e4a609c9c0 100644 --- a/application/src/main/data/json/system/widget_types/simple_visibility_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_visibility_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_visibility_chart_card_with_background", "name": "Simple visibility chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_visibility_chart_card_with_background_system_widget_image.png", "description": "Displays historical visibility values as a simplified chart with background. Optionally may display the corresponding latest visibility value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_volatile_organic_compounds_chart_card.json b/application/src/main/data/json/system/widget_types/simple_volatile_organic_compounds_chart_card.json index d176526dcb..69b0fef3be 100644 --- a/application/src/main/data/json/system/widget_types/simple_volatile_organic_compounds_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_volatile_organic_compounds_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_volatile_organic_compounds_chart_card", "name": "Simple volatile organic compounds chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_volatile_organic_compounds_chart_card_system_widget_image.png", "description": "Displays historical volatile organic compounds (VOCs) values as a simplified chart. Optionally may display the corresponding latest VOCs value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_volatile_organic_compounds_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_volatile_organic_compounds_chart_card_with_background.json index 1eba6b3fd0..0055ce2466 100644 --- a/application/src/main/data/json/system/widget_types/simple_volatile_organic_compounds_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_volatile_organic_compounds_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_volatile_organic_compounds_chart_card_with_background", "name": "Simple volatile organic compounds chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_volatile_organic_compounds_chart_card_with_background_system_widget_image.png", "description": "Displays historical volatile organic compounds (VOCs) values as a simplified chart with background. Optionally may display the corresponding latest VOCs value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_wind_speed_chart_card.json b/application/src/main/data/json/system/widget_types/simple_wind_speed_chart_card.json index 6660f67d78..d23269162c 100644 --- a/application/src/main/data/json/system/widget_types/simple_wind_speed_chart_card.json +++ b/application/src/main/data/json/system/widget_types/simple_wind_speed_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "simple_wind_speed_chart_card", "name": "Simple wind speed chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_wind_speed_chart_card_system_widget_image.png", "description": "Displays historical wind speed values as a simplified chart. Optionally may display the corresponding latest wind speed value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/simple_wind_speed_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/simple_wind_speed_chart_card_with_background.json index fadebc2925..beab2caeca 100644 --- a/application/src/main/data/json/system/widget_types/simple_wind_speed_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/simple_wind_speed_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "simple_wind_speed_chart_card_with_background", "name": "Simple wind speed chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/simple_wind_speed_chart_card_with_background_system_widget_image.png", "description": "Displays historical wind speed values as a simplified chart with background. Optionally may display the corresponding latest wind speed value.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/snow_depth_card.json b/application/src/main/data/json/system/widget_types/snow_depth_card.json index 1d8f56a2fb..a866de773e 100644 --- a/application/src/main/data/json/system/widget_types/snow_depth_card.json +++ b/application/src/main/data/json/system/widget_types/snow_depth_card.json @@ -1,7 +1,7 @@ { "fqn": "snow_depth_card", "name": "Snow depth card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/snow_depth_card_system_widget_image.png", "description": "Displays the latest snow depth telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/snow_depth_card_with_background.json b/application/src/main/data/json/system/widget_types/snow_depth_card_with_background.json index c9f1b12488..8ac7ea2e29 100644 --- a/application/src/main/data/json/system/widget_types/snow_depth_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/snow_depth_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "snow_depth_card_with_background", "name": "Snow depth card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/snow_depth_card_with_background_system_widget_image.png", "description": "Displays the latest snow depth telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/snow_depth_chart_card.json b/application/src/main/data/json/system/widget_types/snow_depth_chart_card.json index decfe00951..403579060a 100644 --- a/application/src/main/data/json/system/widget_types/snow_depth_chart_card.json +++ b/application/src/main/data/json/system/widget_types/snow_depth_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "snow_depth_chart_card", "name": "Snow depth chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/snow_depth_chart_card_system_widget_image.png", "description": "Displays a snow depth data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/snow_depth_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/snow_depth_chart_card_with_background.json index f0b2951731..e96d5a26d8 100644 --- a/application/src/main/data/json/system/widget_types/snow_depth_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/snow_depth_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "snow_depth_chart_card_with_background", "name": "Snow depth chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/snow_depth_chart_card_with_background_system_widget_image.png", "description": "Displays a snow depth data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/soil_moisture_card.json b/application/src/main/data/json/system/widget_types/soil_moisture_card.json index 03df119344..bfedfec7cb 100644 --- a/application/src/main/data/json/system/widget_types/soil_moisture_card.json +++ b/application/src/main/data/json/system/widget_types/soil_moisture_card.json @@ -1,7 +1,7 @@ { "fqn": "soil_moisture_card", "name": "Soil moisture card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/soil_moisture_card_system_widget_image.png", "description": "Displays the latest soil moisture telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/soil_moisture_card_with_background.json b/application/src/main/data/json/system/widget_types/soil_moisture_card_with_background.json index a66fbc1532..444f51deb0 100644 --- a/application/src/main/data/json/system/widget_types/soil_moisture_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/soil_moisture_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "soil_moisture_card_with_background", "name": "Soil moisture card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/soil_moisture_card_with_background_system_widget_image.png", "description": "Displays the latest soil moisture telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/soil_moisture_chart_card.json b/application/src/main/data/json/system/widget_types/soil_moisture_chart_card.json index 986100613a..c0bc989c21 100644 --- a/application/src/main/data/json/system/widget_types/soil_moisture_chart_card.json +++ b/application/src/main/data/json/system/widget_types/soil_moisture_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "soil_moisture_chart_card", "name": "Soil moisture chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/soil_moisture_chart_card_system_widget_image.png", "description": "Displays a soil moisture data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/soil_moisture_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/soil_moisture_chart_card_with_background.json index acce7054b3..554e236cfc 100644 --- a/application/src/main/data/json/system/widget_types/soil_moisture_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/soil_moisture_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "soil_moisture_chart_card_with_background", "name": "Soil moisture chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/soil_moisture_chart_card_with_background_system_widget_image.png", "description": "Displays a soil moisture data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/soil_moisture_progress_bar.json b/application/src/main/data/json/system/widget_types/soil_moisture_progress_bar.json index efd88de86c..952fd6b242 100644 --- a/application/src/main/data/json/system/widget_types/soil_moisture_progress_bar.json +++ b/application/src/main/data/json/system/widget_types/soil_moisture_progress_bar.json @@ -1,7 +1,7 @@ { "fqn": "soil_moisture_progress_bar", "name": "Soil moisture progress bar", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/soil_moisture_progress_bar_system_widget_image.png", "description": "Displays soil moisture reading as a horizontal progress bar. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/soil_moisture_progress_bar_with_background.json b/application/src/main/data/json/system/widget_types/soil_moisture_progress_bar_with_background.json index da1ec3617a..c274dc7c9c 100644 --- a/application/src/main/data/json/system/widget_types/soil_moisture_progress_bar_with_background.json +++ b/application/src/main/data/json/system/widget_types/soil_moisture_progress_bar_with_background.json @@ -1,7 +1,7 @@ { "fqn": "soil_moisture_progress_bar_with_background", "name": "Soil moisture progress bar with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/soil_moisture_progress_bar_with_background_system_widget_image.png", "description": "Displays soil moisture reading as a horizontal progress bar with background. Allows to configure value range, bar colors, and other settings.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/solar_radiation_card.json b/application/src/main/data/json/system/widget_types/solar_radiation_card.json index 286668be56..14dbb2173f 100644 --- a/application/src/main/data/json/system/widget_types/solar_radiation_card.json +++ b/application/src/main/data/json/system/widget_types/solar_radiation_card.json @@ -1,7 +1,7 @@ { "fqn": "solar_radiation_card", "name": "Solar radiation card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/solar_radiation_card_system_widget_image.png", "description": "Displays the latest solar radiation telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/solar_radiation_card_with_background.json b/application/src/main/data/json/system/widget_types/solar_radiation_card_with_background.json index 7264f59fe2..4dd421f3d1 100644 --- a/application/src/main/data/json/system/widget_types/solar_radiation_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/solar_radiation_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "solar_radiation_card_with_background", "name": "Solar radiation card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/solar_radiation_card_with_background_system_widget_image.png", "description": "Displays the latest solar radiation telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/solar_radiation_chart_card.json b/application/src/main/data/json/system/widget_types/solar_radiation_chart_card.json index a96b0f8c89..9d4c0c3003 100644 --- a/application/src/main/data/json/system/widget_types/solar_radiation_chart_card.json +++ b/application/src/main/data/json/system/widget_types/solar_radiation_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "solar_radiation_chart_card", "name": "Solar radiation chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/solar_radiation_chart_card_system_widget_image.png", "description": "Displays a solar radiation data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/solar_radiation_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/solar_radiation_chart_card_with_background.json index a0dac85a70..1fb1360ddb 100644 --- a/application/src/main/data/json/system/widget_types/solar_radiation_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/solar_radiation_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "solar_radiation_chart_card_with_background", "name": "Solar radiation chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/solar_radiation_chart_card_with_background_system_widget_image.png", "description": "Displays a solar radiation data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__card.json b/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__card.json index 25b950df3e..f1b605e322 100644 --- a/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__card.json +++ b/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__card.json @@ -1,7 +1,7 @@ { "fqn": "sulfur_dioxide_so2_card", "name": "Sulfur dioxide (SO2) card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/SO2-value-card.svg", "description": "Produced by volcanoes and industrial processes, particularly the burning of coal and oil.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__card_with_background.json b/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__card_with_background.json index a097776152..6a266e6486 100644 --- a/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__card_with_background.json +++ b/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "sulfur_dioxide_so2_card_with_background", "name": "Sulfur dioxide (SO2) card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/SO2-value-card-with-background.svg", "description": "Produced by volcanoes and industrial processes, particularly the burning of coal and oil.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__chart_card.json b/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__chart_card.json index 597c454242..d47d15c23b 100644 --- a/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__chart_card.json +++ b/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__chart_card.json @@ -1,7 +1,7 @@ { "fqn": "sulfur_dioxide_so2_chart_card", "name": "Sulfur dioxide (SO2) chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/SO2-value-and-chart-card.svg", "description": "Produced by volcanoes and industrial processes, particularly the burning of coal and oil. Results displayed by combining the latest and aggregated values and optional simplified chart. ", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__chart_card_with_background.json b/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__chart_card_with_background.json index 3934f87dc0..7ba6e5ba62 100644 --- a/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/sulfur_dioxide__so2__chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "sulfur_dioxide_so2_chart_card_with_background", "name": "Sulfur dioxide (SO2) chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/SO2-value-chart-card-with-background.svg", "description": "Produced by volcanoes and industrial processes, particularly the burning of coal and oil. Results displayed by combining the latest and aggregated values and optional simplified chart. ", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/temperature_card.json b/application/src/main/data/json/system/widget_types/temperature_card.json index 1308512c13..a851c8bd0d 100644 --- a/application/src/main/data/json/system/widget_types/temperature_card.json +++ b/application/src/main/data/json/system/widget_types/temperature_card.json @@ -1,7 +1,7 @@ { "fqn": "temperature_card", "name": "Temperature card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/temperature_card_system_widget_image.png", "description": "Displays the latest temperature telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/temperature_card_with_background.json b/application/src/main/data/json/system/widget_types/temperature_card_with_background.json index f711b715d5..bd95a50b8c 100644 --- a/application/src/main/data/json/system/widget_types/temperature_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/temperature_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "temperature_card_with_background", "name": "Temperature card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/temperature_card_with_background_system_widget_image.png", "description": "Displays the latest temperature telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/temperature_chart_card.json b/application/src/main/data/json/system/widget_types/temperature_chart_card.json index 689de25c3f..49495ec4ec 100644 --- a/application/src/main/data/json/system/widget_types/temperature_chart_card.json +++ b/application/src/main/data/json/system/widget_types/temperature_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "temperature_chart_card", "name": "Temperature chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/temperature_chart_card_system_widget_image.png", "description": "Displays a temperature data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/temperature_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/temperature_chart_card_with_background.json index 582d22c0f6..eedef9011e 100644 --- a/application/src/main/data/json/system/widget_types/temperature_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/temperature_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "temperature_chart_card_with_background", "name": "Temperature chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/temperature_chart_card_with_background_system_widget_image.png", "description": "Displays a temperature data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/temperature_gauge.json b/application/src/main/data/json/system/widget_types/temperature_gauge.json index 0db4b047f0..4dda4ee0b0 100644 --- a/application/src/main/data/json/system/widget_types/temperature_gauge.json +++ b/application/src/main/data/json/system/widget_types/temperature_gauge.json @@ -1,7 +1,7 @@ { "fqn": "temperature_gauge", "name": "Temperature gauge", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/Temperature_gauge.svg", "description": "Temperature gauge.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/temperature_range_chart.json b/application/src/main/data/json/system/widget_types/temperature_range_chart.json index 2810381c51..1d34f69066 100644 --- a/application/src/main/data/json/system/widget_types/temperature_range_chart.json +++ b/application/src/main/data/json/system/widget_types/temperature_range_chart.json @@ -1,7 +1,7 @@ { "fqn": "temperature_range_chart", "name": "Temperature range chart", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/temperature_range_chart.svg", "description": "Displays changes to temperature data over time visualized with color ranges.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/temperature_range_chart_with_background.json b/application/src/main/data/json/system/widget_types/temperature_range_chart_with_background.json index e4e49fe12c..96f913a1b0 100644 --- a/application/src/main/data/json/system/widget_types/temperature_range_chart_with_background.json +++ b/application/src/main/data/json/system/widget_types/temperature_range_chart_with_background.json @@ -1,7 +1,7 @@ { "fqn": "temperature_range_chart_with_background", "name": "Temperature range chart with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/temperature_range_chart_with_background.svg", "description": "Displays changes to temperature data over time visualized with color ranges and background.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/uv_index_card.json b/application/src/main/data/json/system/widget_types/uv_index_card.json index 797948b1cb..305771860e 100644 --- a/application/src/main/data/json/system/widget_types/uv_index_card.json +++ b/application/src/main/data/json/system/widget_types/uv_index_card.json @@ -1,7 +1,7 @@ { "fqn": "uv_index_card", "name": "UV Index card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/uv_index_card_system_widget_image.png", "description": "Displays the latest UV index telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/uv_index_card_with_background.json b/application/src/main/data/json/system/widget_types/uv_index_card_with_background.json index 4b3f255401..e515e0f13f 100644 --- a/application/src/main/data/json/system/widget_types/uv_index_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/uv_index_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "uv_index_card_with_background", "name": "UV Index card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/uv_index_card_with_background_system_widget_image.png", "description": "Displays the latest UV index telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/uv_index_chart_card.json b/application/src/main/data/json/system/widget_types/uv_index_chart_card.json index 393f7359a4..9a00d0a809 100644 --- a/application/src/main/data/json/system/widget_types/uv_index_chart_card.json +++ b/application/src/main/data/json/system/widget_types/uv_index_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "uv_index_chart_card", "name": "UV Index chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/uv_index_chart_card_system_widget_image.png", "description": "Displays a UV index data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/uv_index_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/uv_index_chart_card_with_background.json index 78a7b899be..9c19f15e50 100644 --- a/application/src/main/data/json/system/widget_types/uv_index_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/uv_index_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "uv_index_chart_card_with_background", "name": "UV Index chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/uv_index_chart_card_with_background_system_widget_image.png", "description": "Displays a UV index data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/vibration_card.json b/application/src/main/data/json/system/widget_types/vibration_card.json index 033da084fb..5976833c8a 100644 --- a/application/src/main/data/json/system/widget_types/vibration_card.json +++ b/application/src/main/data/json/system/widget_types/vibration_card.json @@ -1,7 +1,7 @@ { "fqn": "vibration_card", "name": "Vibration card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/vibration_card_system_widget_image.png", "description": "Displays the latest vibration telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/vibration_card_with_background.json b/application/src/main/data/json/system/widget_types/vibration_card_with_background.json index f078b93194..7aa3439472 100644 --- a/application/src/main/data/json/system/widget_types/vibration_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/vibration_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "vibration_card_with_background", "name": "Vibration card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/vibration_card_with_background_system_widget_image.png", "description": "Displays the latest vibration telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/vibration_chart_card.json b/application/src/main/data/json/system/widget_types/vibration_chart_card.json index c7dcf7a6ed..75d31e3954 100644 --- a/application/src/main/data/json/system/widget_types/vibration_chart_card.json +++ b/application/src/main/data/json/system/widget_types/vibration_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "vibration_chart_card", "name": "Vibration chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/vibration_chart_card_system_widget_image.png", "description": "Displays a vibration data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/vibration_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/vibration_chart_card_with_background.json index 443d1b7a33..bdf5418806 100644 --- a/application/src/main/data/json/system/widget_types/vibration_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/vibration_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "vibration_chart_card_with_background", "name": "Vibration chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/vibration_chart_card_with_background_system_widget_image.png", "description": "Displays a vibration data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/visibility_card.json b/application/src/main/data/json/system/widget_types/visibility_card.json index c640c702e9..aca104e0bd 100644 --- a/application/src/main/data/json/system/widget_types/visibility_card.json +++ b/application/src/main/data/json/system/widget_types/visibility_card.json @@ -1,7 +1,7 @@ { "fqn": "visibility_card", "name": "Visibility card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/visibility_card_system_widget_image.png", "description": "Displays the latest visibility telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/visibility_card_with_background.json b/application/src/main/data/json/system/widget_types/visibility_card_with_background.json index 02e12105b8..0100aa7f7c 100644 --- a/application/src/main/data/json/system/widget_types/visibility_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/visibility_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "visibility_card_with_background", "name": "Visibility card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/visibility_card_with_background_system_widget_image.png", "description": "Displays the latest visibility telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/visibility_chart_card.json b/application/src/main/data/json/system/widget_types/visibility_chart_card.json index fe69562505..5659ba9f8c 100644 --- a/application/src/main/data/json/system/widget_types/visibility_chart_card.json +++ b/application/src/main/data/json/system/widget_types/visibility_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "visibility_chart_card", "name": "Visibility chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/visibility_chart_card_system_widget_image.png", "description": "Displays a visibility data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/visibility_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/visibility_chart_card_with_background.json index 52454f7947..00daf878de 100644 --- a/application/src/main/data/json/system/widget_types/visibility_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/visibility_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "visibility_chart_card_with_background", "name": "Visibility chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/visibility_chart_card_with_background_system_widget_image.png", "description": "Displays a visibility data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/volatile_organic_compounds_card.json b/application/src/main/data/json/system/widget_types/volatile_organic_compounds_card.json index 517ecaf1ce..6678ec814b 100644 --- a/application/src/main/data/json/system/widget_types/volatile_organic_compounds_card.json +++ b/application/src/main/data/json/system/widget_types/volatile_organic_compounds_card.json @@ -1,7 +1,7 @@ { "fqn": "volatile_organic_compounds_card", "name": "Volatile organic compounds card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/volatile_organic_compounds_card_system_widget_image.png", "description": "Displays the latest volatile organic compounds (VOCs) telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/volatile_organic_compounds_card_with_background.json b/application/src/main/data/json/system/widget_types/volatile_organic_compounds_card_with_background.json index fb5bf45da1..4a8dce094a 100644 --- a/application/src/main/data/json/system/widget_types/volatile_organic_compounds_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/volatile_organic_compounds_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "volatile_organic_compounds_card_with_background", "name": "Volatile organic compounds card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/volatile_organic_compounds_card_with_background_system_widget_image.png", "description": "Displays the latest volatile organic compounds (VOCs) telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/volatile_organic_compounds_chart_card.json b/application/src/main/data/json/system/widget_types/volatile_organic_compounds_chart_card.json index b146908138..ab35fe4aa1 100644 --- a/application/src/main/data/json/system/widget_types/volatile_organic_compounds_chart_card.json +++ b/application/src/main/data/json/system/widget_types/volatile_organic_compounds_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "volatile_organic_compounds_chart_card", "name": "Volatile organic compounds chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/volatile_organic_compounds_chart_card_system_widget_image.png", "description": "Displays a volatile organic compounds (VOCs) data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/volatile_organic_compounds_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/volatile_organic_compounds_chart_card_with_background.json index 0e1e1890f6..d2bf98bf09 100644 --- a/application/src/main/data/json/system/widget_types/volatile_organic_compounds_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/volatile_organic_compounds_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "volatile_organic_compounds_chart_card_with_background", "name": "Volatile organic compounds chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/volatile_organic_compounds_chart_card_with_background_system_widget_image.png", "description": "Displays a volatile organic compounds (VOCs) data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/wind_speed_and_direction.json b/application/src/main/data/json/system/widget_types/wind_speed_and_direction.json index 78c4fdabf0..65835b597d 100644 --- a/application/src/main/data/json/system/widget_types/wind_speed_and_direction.json +++ b/application/src/main/data/json/system/widget_types/wind_speed_and_direction.json @@ -1,7 +1,7 @@ { "fqn": "wind_speed_and_direction", "name": "Wind speed and direction", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/wind_speed_and_direction_system_widget_image.png", "description": "Displays the latest values of the wind speed and direction.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/wind_speed_and_direction_with_background.json b/application/src/main/data/json/system/widget_types/wind_speed_and_direction_with_background.json index f96a21781f..7d0c1e07f7 100644 --- a/application/src/main/data/json/system/widget_types/wind_speed_and_direction_with_background.json +++ b/application/src/main/data/json/system/widget_types/wind_speed_and_direction_with_background.json @@ -1,7 +1,7 @@ { "fqn": "wind_speed_and_direction_with_background", "name": "Wind speed and direction with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/wind_speed_and_direction_with_background_system_widget_image.png", "description": "Displays the latest values of the wind speed and direction with background.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/wind_speed_card.json b/application/src/main/data/json/system/widget_types/wind_speed_card.json index 0727d4a905..071fefdaab 100644 --- a/application/src/main/data/json/system/widget_types/wind_speed_card.json +++ b/application/src/main/data/json/system/widget_types/wind_speed_card.json @@ -1,7 +1,7 @@ { "fqn": "wind_speed_card", "name": "Wind speed card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/wind_speed_card_system_widget_image.png", "description": "Displays the latest wind speed telemetry in a scalable rectangle card.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/wind_speed_card_with_background.json b/application/src/main/data/json/system/widget_types/wind_speed_card_with_background.json index 1fdc2e2ec3..1113dfe606 100644 --- a/application/src/main/data/json/system/widget_types/wind_speed_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/wind_speed_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "wind_speed_card_with_background", "name": "Wind speed card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/wind_speed_card_with_background_system_widget_image.png", "description": "Displays the latest wind speed telemetry in a scalable rectangle card with the background image.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/wind_speed_chart_card.json b/application/src/main/data/json/system/widget_types/wind_speed_chart_card.json index 1b057c932d..c793dd86ad 100644 --- a/application/src/main/data/json/system/widget_types/wind_speed_chart_card.json +++ b/application/src/main/data/json/system/widget_types/wind_speed_chart_card.json @@ -1,7 +1,7 @@ { "fqn": "wind_speed_chart_card", "name": "Wind speed chart card", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/wind_speed_chart_card_system_widget_image.png", "description": "Displays a wind speed data by combining the latest and aggregated values with an optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/wind_speed_chart_card_with_background.json b/application/src/main/data/json/system/widget_types/wind_speed_chart_card_with_background.json index 7c5b7ff83d..f8bf3db950 100644 --- a/application/src/main/data/json/system/widget_types/wind_speed_chart_card_with_background.json +++ b/application/src/main/data/json/system/widget_types/wind_speed_chart_card_with_background.json @@ -1,7 +1,7 @@ { "fqn": "wind_speed_chart_card_with_background", "name": "Wind speed chart card with background", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/wind_speed_chart_card_with_background_system_widget_image.png", "description": "Displays a wind speed data by combining the latest and aggregated values with the background image and optional simplified chart.", "descriptor": { diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 98c7a93c52..f19b447f6a 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -43,4 +43,4 @@ DROP INDEX IF EXISTS idx_widgets_bundle_external_id; -- DROP INDEXES THAT DUPLICATE UNIQUE CONSTRAINT END -ALTER TABLE mobile_app ADD COLUMN IF NOT EXISTS title varchar(255); \ No newline at end of file +ALTER TABLE mobile_app ADD COLUMN IF NOT EXISTS title varchar(255); diff --git a/application/src/main/data/upgrade/lts/schema_update.sql b/application/src/main/data/upgrade/lts/schema_update.sql index 3140b4f253..b52d8ee14e 100644 --- a/application/src/main/data/upgrade/lts/schema_update.sql +++ b/application/src/main/data/upgrade/lts/schema_update.sql @@ -17,3 +17,25 @@ -- LTS cumulative schema update file. -- All statements must be idempotent (use IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, DO $$ ... END $$ guards, etc.). -- This file is executed by SystemPatchApplier on every version increase within the LTS family. + +-- IOT HUB INSTALLED ITEM START + +CREATE TABLE IF NOT EXISTS iot_hub_installed_item ( + id UUID NOT NULL PRIMARY KEY, + created_time BIGINT NOT NULL, + tenant_id UUID NOT NULL, + item_id UUID NOT NULL, + item_version_id UUID NOT NULL, + item_name VARCHAR NOT NULL, + item_type VARCHAR NOT NULL, + version VARCHAR NOT NULL, + descriptor JSONB NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_iot_hub_installed_item_tenant_id ON iot_hub_installed_item(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_iot_hub_installed_item_item_type ON iot_hub_installed_item(tenant_id, item_type); + +CREATE INDEX IF NOT EXISTS idx_iot_hub_installed_item_item_id ON iot_hub_installed_item(tenant_id, item_id); + +-- IOT HUB INSTALLED ITEM END diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index 8a113fb424..998ca1cfa5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmCommentInfo; import org.thingsboard.server.common.data.alarm.AlarmCommentType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; @@ -39,6 +40,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.alarm.TbAlarmCommentService; +import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import static org.thingsboard.server.controller.ControllerConstants.ALARM_COMMENT_ID_PARAM_DESCRIPTION; @@ -77,9 +79,13 @@ public class AlarmCommentController extends BaseController { checkParameter(ALARM_ID, strAlarmId); AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE); + SecurityUser currentUser = getCurrentUser(); + if (alarmComment.getId() != null) { + checkUserPermission(alarmComment, alarmId, "edit", currentUser); + } alarmComment.setAlarmId(alarmId); alarmComment.setType(AlarmCommentType.OTHER); - return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser()); + return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, currentUser); } @ApiOperation(value = "Delete Alarm comment (deleteAlarmComment)", @@ -93,7 +99,11 @@ public class AlarmCommentController extends BaseController { AlarmCommentId alarmCommentId = new AlarmCommentId(toUUID(strCommentId)); AlarmComment alarmComment = checkAlarmCommentId(alarmCommentId, alarmId); - tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, getCurrentUser()); + SecurityUser currentUser = getCurrentUser(); + if (!currentUser.isTenantAdmin()) { + checkUserPermission(alarmComment, alarmId, "delete", currentUser); + } + tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, currentUser); } @ApiOperation(value = "Get Alarm comments (getAlarmComments)", @@ -120,4 +130,12 @@ public class AlarmCommentController extends BaseController { return checkNotNull(alarmCommentService.findAlarmComments(alarm.getTenantId(), alarmId, pageLink)); } + private void checkUserPermission(AlarmComment alarmComment, AlarmId alarmId, String operation, SecurityUser currentUser) throws ThingsboardException { + AlarmComment existingAlarmComment = checkAlarmCommentId(alarmComment.getId(), alarmId); + if (existingAlarmComment.getUserId() != null && !existingAlarmComment.getUserId().equals(currentUser.getId())) { + throw new ThingsboardException("User is not allowed to " + operation + " other user's comment", + ThingsboardErrorCode.PERMISSION_DENIED); + } + } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/IotHubController.java b/application/src/main/java/org/thingsboard/server/controller/IotHubController.java new file mode 100644 index 0000000000..2af21a4af5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/IotHubController.java @@ -0,0 +1,162 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 io.swagger.v3.oas.annotations.Hidden; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import jakarta.servlet.http.HttpServletRequest; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.iot_hub.DeviceInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; +import org.thingsboard.server.dao.device.DeviceConnectivityService; +import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.system.SystemSecurityService; +import org.thingsboard.server.service.iot_hub.InstallItemVersionResult; +import org.thingsboard.server.service.iot_hub.InstallPlan; +import org.thingsboard.server.service.iot_hub.InstallPlanResult; +import org.thingsboard.server.service.iot_hub.UpdateItemVersionResult; +import org.thingsboard.server.service.iot_hub.IotHubService; + +@Hidden +@RestController +@TbCoreComponent +@RequestMapping("/api/iot-hub") +@RequiredArgsConstructor +@Slf4j +public class IotHubController extends BaseController { + + private final IotHubService iotHubService; + private final IotHubInstalledItemService iotHubInstalledItemService; + private final DeviceConnectivityService deviceConnectivityService; + private final SystemSecurityService systemSecurityService; + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/versions/{versionId}/install") + @ResponseBody + public InstallItemVersionResult installItemVersion(@PathVariable String versionId, + @RequestBody(required = false) JsonNode data, + HttpServletRequest request) throws ThingsboardException { + return iotHubService.installItemVersion(getCurrentUser(), versionId, data, request); + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/versions/{versionId}/installPlan") + @ResponseBody + public InstallPlan resolveInstallPlan(@PathVariable String versionId) throws ThingsboardException { + return iotHubService.resolveInstallPlan(getCurrentUser(), versionId); + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/installPlan") + @ResponseBody + public InstallPlanResult installPlan(@RequestBody InstallPlanRequest body, + HttpServletRequest request) throws ThingsboardException { + return iotHubService.installPlan(getCurrentUser(), body.plan(), body.data(), request); + } + + public record InstallPlanRequest(InstallPlan plan, JsonNode data) {} + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/device/register") + @ResponseBody + public InstallItemVersionResult registerDeviceInstall( + @RequestParam String versionId, + @RequestBody JsonNode body) throws ThingsboardException { + DeviceInstalledItemDescriptor descriptor = JacksonUtil.treeToValue(body, DeviceInstalledItemDescriptor.class); + return iotHubService.registerDeviceInstall(getCurrentUser(), versionId, descriptor); + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/installedItems/{installedItemId}/update/{versionId}") + @ResponseBody + public UpdateItemVersionResult updateItemVersion(@PathVariable UUID installedItemId, + @PathVariable String versionId, + @RequestParam(required = false, defaultValue = "false") boolean force, + HttpServletRequest request) throws ThingsboardException { + return iotHubService.updateItemVersion(getCurrentUser(), new IotHubInstalledItemId(installedItemId), versionId, force, request); + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/installedItems") + @ResponseBody + public PageData getInstalledItems(@RequestParam int pageSize, + @RequestParam int page, + @RequestParam(required = false) String textSearch, + @RequestParam(required = false) String sortProperty, + @RequestParam(required = false) String sortOrder, + @RequestParam(required = false) List itemTypes, + @RequestParam(required = false) UUID itemId) throws ThingsboardException { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return iotHubInstalledItemService.findByTenantId(getTenantId(), itemTypes, itemId, pageLink); + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/installedItems/count") + @ResponseBody + public long getInstalledItemsCount(@RequestParam(required = false) String itemType) throws ThingsboardException { + return iotHubInstalledItemService.countByTenantId(getTenantId(), itemType); + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/installedItems/itemIds") + @ResponseBody + public List getInstalledItemIds() throws ThingsboardException { + return iotHubInstalledItemService.findInstalledItemIdsByTenantId(getTenantId()); + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/installedItems/counts") + @ResponseBody + public Map getInstalledItemCounts(@RequestParam String itemType) throws ThingsboardException { + return iotHubInstalledItemService.findInstalledItemCounts(getTenantId(), itemType); + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @DeleteMapping("/installedItems/{installedItemId}") + @ResponseBody + public void deleteInstalledItem(@PathVariable UUID installedItemId) throws ThingsboardException { + iotHubService.deleteInstalledItem(getCurrentUser(), new IotHubInstalledItemId(installedItemId)); + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/connectivity") + @ResponseBody + public JsonNode getConnectivitySettings(HttpServletRequest request) throws Exception { + String baseUrl = systemSecurityService.getBaseUrl(getTenantId(), getCurrentUser().getCustomerId(), request); + return deviceConnectivityService.getConnectivityInfo(baseUrl); + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java index 1f7a3ab5f6..285a250c08 100644 --- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -81,6 +81,9 @@ public class SystemInfoController extends BaseController { @Value("${sql.entity_data_query_nulls_order_strategy:default}") private String nullsOrderStrategy; + @Value("${iot-hub.base-url:https://iot-hub.thingsboard.io}") + private String iotHubBaseUrl; + private static final Set ACCEPTED_NULLS_ORDER_STRATEGIES = Set.of("default", "nulls_first", "nulls_last"); @Autowired(required = false) @@ -176,6 +179,7 @@ public class SystemInfoController extends BaseController { systemParams.setMaxDataPointsPerRollingArg(tenantProfileConfiguration.getMaxDataPointsPerRollingArg()); systemParams.setTrendzSettings(trendzSettingsService.findTrendzSettings(currentUser.getTenantId())); } + systemParams.setIotHubBaseUrl(iotHubBaseUrl); systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID)) .map(QrCodeSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage) .orElse(false)); diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java index 15be6f3734..a2bc29104b 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java @@ -15,14 +15,8 @@ */ package org.thingsboard.server.service.ai; -import com.fasterxml.jackson.core.io.JsonStringEncoder; import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.Futures; -import dev.langchain4j.data.message.ChatMessage; -import dev.langchain4j.data.message.Content; -import dev.langchain4j.data.message.TextContent; -import dev.langchain4j.data.message.UserMessage; -import dev.langchain4j.model.ModelProvider; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.response.ChatResponse; @@ -31,9 +25,6 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; -import java.util.List; -import java.util.stream.Collectors; - @Service @RequiredArgsConstructor class AiChatModelServiceImpl implements AiChatModelService { @@ -49,39 +40,7 @@ class AiChatModelServiceImpl implements AiChatModelService { } catch (Throwable t) { return FluentFuture.from(Futures.immediateFailedFuture(t)); } - if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) { - chatRequest = prepareGithubChatRequest(chatRequest); - } return aiRequestsExecutor.sendChatRequestAsync(langChainChatModel, chatRequest); } - private ChatRequest prepareGithubChatRequest(ChatRequest chatRequest) { - List messages = chatRequest.messages().stream() - .map(this::prepareUserMessage) - .collect(Collectors.toList()); - - return ChatRequest.builder() - .messages(messages) - .responseFormat(chatRequest.responseFormat()) - .build(); - } - - private ChatMessage prepareUserMessage(ChatMessage message) { - if (message instanceof UserMessage userMessage) { - List newContents = userMessage.contents().stream() - .map(this::prepareContent) - .collect(Collectors.toList()); - - return UserMessage.from(newContents); - } - return message; - } - - private Content prepareContent(Content content) { - if (content instanceof TextContent txt) { - return new TextContent(new String(JsonStringEncoder.getInstance().quoteAsString(txt.text()))); - } - return content; - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java index 8b569f052c..3bb39c3671 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -15,26 +15,18 @@ */ package org.thingsboard.server.service.ai; -import com.google.api.gax.core.FixedCredentialsProvider; -import com.google.api.gax.retrying.RetrySettings; +import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.ServiceAccountCredentials; -import com.google.cloud.vertexai.Transport; -import com.google.cloud.vertexai.VertexAI; -import com.google.cloud.vertexai.api.GenerationConfig; -import com.google.cloud.vertexai.api.PredictionServiceClient; -import com.google.cloud.vertexai.api.PredictionServiceSettings; -import com.google.cloud.vertexai.generativeai.GenerativeModel; import dev.langchain4j.model.anthropic.AnthropicChatModel; import dev.langchain4j.model.azure.AzureOpenAiChatModel; import dev.langchain4j.model.bedrock.BedrockChatModel; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequestParameters; -import dev.langchain4j.model.github.GitHubModelsChatModel; -import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel; +import dev.langchain4j.model.google.genai.GoogleGenAiChatModel; import dev.langchain4j.model.mistralai.MistralAiChatModel; import dev.langchain4j.model.ollama.OllamaChatModel; import dev.langchain4j.model.openai.OpenAiChatModel; -import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; +import dev.langchain4j.model.openaiofficial.OpenAiOfficialChatModel; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; import org.thingsboard.common.util.SsrfProtectionValidator; @@ -50,7 +42,6 @@ import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; -import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; @@ -107,7 +98,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(GoogleAiGeminiChatModelConfig chatModelConfig) { - return GoogleAiGeminiChatModel.builder() + return GoogleGenAiChatModel.builder() .apiKey(chatModelConfig.providerConfig().apiKey()) .modelName(chatModelConfig.modelId()) .temperature(chatModelConfig.temperature()) @@ -123,84 +114,28 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(GoogleVertexAiGeminiChatModelConfig chatModelConfig) { - GoogleVertexAiGeminiProviderConfig providerConfig = chatModelConfig.providerConfig(); - - // construct service account credentials using service account key JSON - ServiceAccountCredentials serviceAccountCredentials; + GoogleCredentials credentials; try { - serviceAccountCredentials = ServiceAccountCredentials.fromStream(new ByteArrayInputStream(providerConfig.serviceAccountKey().getBytes())); + credentials = ServiceAccountCredentials + .fromStream(new ByteArrayInputStream(chatModelConfig.providerConfig().serviceAccountKey().getBytes(StandardCharsets.UTF_8))) + .createScoped("https://www.googleapis.com/auth/cloud-platform"); } catch (IOException e) { throw new RuntimeException("Failed to parse service account key JSON", e); } - - PredictionServiceSettings predictionServiceClientSettings; - try { - // create prediction service settings for REST transport with service account key credentials - PredictionServiceSettings.Builder settingsBuilder = PredictionServiceSettings.newHttpJsonBuilder() - .setCredentialsProvider(FixedCredentialsProvider.create(serviceAccountCredentials)); - - // get the retry settings that control request timeout for generateContent RPC - RetrySettings.Builder retrySettings = settingsBuilder - .generateContentSettings() - .getRetrySettings() - .toBuilder(); - - // set request timeout from model config - if (chatModelConfig.timeoutSeconds() != null) { - retrySettings.setTotalTimeoutDuration(Duration.ofSeconds(chatModelConfig.timeoutSeconds())); - } - - // set updated retry settings - settingsBuilder.generateContentSettings().setRetrySettings(retrySettings.build()); - - // build the client settings - predictionServiceClientSettings = settingsBuilder.build(); - } catch (IOException e) { - throw new RuntimeException("Failed to create prediction service client settings", e); - } - - // construct Vertex AI instance - var vertexAI = new VertexAI.Builder() - .setProjectId(providerConfig.projectId()) - .setLocation(providerConfig.location()) - .setPredictionClientSupplier(() -> createPredictionServiceClient(predictionServiceClientSettings)) - .setTransport(Transport.REST) // GRPC also possible, but likely does not work with service account keys + return GoogleGenAiChatModel.builder() + .projectId(chatModelConfig.providerConfig().projectId()) + .location(chatModelConfig.providerConfig().location()) + .googleCredentials(credentials) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .topK(chatModelConfig.topK()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxOutputTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) .build(); - - // map model config to generation config - var generationConfigBuilder = GenerationConfig.newBuilder(); - if (chatModelConfig.temperature() != null) { - generationConfigBuilder.setTemperature(chatModelConfig.temperature().floatValue()); - } - if (chatModelConfig.topP() != null) { - generationConfigBuilder.setTopP(chatModelConfig.topP().floatValue()); - } - if (chatModelConfig.topK() != null) { - generationConfigBuilder.setTopK(chatModelConfig.topK()); - } - if (chatModelConfig.frequencyPenalty() != null) { - generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue()); - } - if (chatModelConfig.presencePenalty() != null) { - generationConfigBuilder.setPresencePenalty(chatModelConfig.presencePenalty().floatValue()); - } - if (chatModelConfig.maxOutputTokens() != null) { - generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens()); - } - var generationConfig = generationConfigBuilder.build(); - - // construct generative model instance - var generativeModel = new GenerativeModel(chatModelConfig.modelId(), vertexAI).withGenerationConfig(generationConfig); - - return new VertexAiGeminiChatModel(generativeModel, generationConfig, chatModelConfig.maxRetries()); - } - - private static PredictionServiceClient createPredictionServiceClient(PredictionServiceSettings settings) { - try { - return PredictionServiceClient.create(settings); - } catch (IOException e) { - throw new RuntimeException("Failed to create prediction service client", e); - } } @Override @@ -262,14 +197,16 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig) { - return GitHubModelsChatModel.builder() - .gitHubToken(chatModelConfig.providerConfig().personalAccessToken()) + return OpenAiOfficialChatModel.builder() + .isGitHubModels(true) + .strictJsonSchema(true) + .apiKey(chatModelConfig.providerConfig().personalAccessToken()) .modelName(chatModelConfig.modelId()) .temperature(chatModelConfig.temperature()) .topP(chatModelConfig.topP()) .frequencyPenalty(chatModelConfig.frequencyPenalty()) .presencePenalty(chatModelConfig.presencePenalty()) - .maxTokens(chatModelConfig.maxOutputTokens()) + .maxCompletionTokens(chatModelConfig.maxOutputTokens()) .timeout(toDuration(chatModelConfig.timeoutSeconds())) .maxRetries(chatModelConfig.maxRetries()) .build(); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java index 0d018f5def..e3f1e94bf4 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java @@ -85,7 +85,7 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { return expressionResult; } if (decimals.equals(0)) { - return TbUtils.toInt(expressionResult); + return TbUtils.toLong(expressionResult); } return TbUtils.toFixed(expressionResult, decimals); } @@ -96,6 +96,8 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { ObjectNode valuesNode = JacksonUtil.newObjectNode(); if (result instanceof Double doubleValue) { valuesNode.put(outputName, doubleValue); + } else if (result instanceof Long longValue) { + valuesNode.put(outputName, longValue); } else if (result instanceof Integer integerValue) { valuesNode.put(outputName, integerValue); } else { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java index f7e729c6b7..67c0a19e66 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java @@ -70,9 +70,11 @@ public class KafkaEdgeGrpcSession extends EdgeGrpcSession { private void processMsgs(List> msgs, TbQueueConsumer> consumer) { log.trace("[{}][{}] starting processing edge events", tenantId, edge.getId()); - if (!isConnected() || isSyncInProgress() || isHighPriorityProcessing) { + // Defensive backstop: the loop already gates polling on readiness; this only fires on the narrow race + // where readiness flips during poll(), and that already-polled batch is dropped here (can't rewind). + if (!isReadyToProcessGeneralEvents()) { log.debug("[{}][{}] edge not connected, edge sync is not completed or high priority processing in progress, " + - "connected = {}, sync in progress = {}, high priority in progress = {}. Skipping iteration", + "connected = {}, sync in progress = {}, high priority in progress = {}. Skipping iteration", tenantId, edge.getId(), isConnected(), isSyncInProgress(), isHighPriorityProcessing); return; } @@ -96,6 +98,10 @@ public class KafkaEdgeGrpcSession extends EdgeGrpcSession { } } + private boolean isReadyToProcessGeneralEvents() { + return isConnected() && !isSyncInProgress() && !isHighPriorityProcessing; + } + @Override public ListenableFuture migrateEdgeEvents() throws Exception { return super.processEdgeEvents(); @@ -103,7 +109,7 @@ public class KafkaEdgeGrpcSession extends EdgeGrpcSession { @Override public ListenableFuture processEdgeEvents() { - if (!isConnected() || isSyncInProgress() || isHighPriorityProcessing) { + if (!isReadyToProcessGeneralEvents()) { log.warn("[{}][{}] Session is not ready (connected={}, syncInProgress={}, highPriority={}), skip starting edge event consumer", tenantId, edge != null ? edge.getId() : null, isConnected(), isSyncInProgress(), isHighPriorityProcessing); return Futures.immediateFuture(Boolean.FALSE); @@ -126,6 +132,7 @@ public class KafkaEdgeGrpcSession extends EdgeGrpcSession { .consumerCreator(() -> tbCoreQueueFactory.createEdgeEventMsgConsumer(tenantId, edge.getId())) .consumerExecutor(consumerExecutor) .threadPrefix("edge-events-" + edge.getId()) + .readinessCheck(this::isReadyToProcessGeneralEvents) .build(); consumer.subscribe(); consumer.launch(); @@ -140,8 +147,11 @@ public class KafkaEdgeGrpcSession extends EdgeGrpcSession { @Override public void processHighPriorityEvents() { isHighPriorityProcessing = true; - super.processHighPriorityEvents(); - isHighPriorityProcessing = false; + try { + super.processHighPriorityEvents(); + } finally { + isHighPriorityProcessing = false; + } } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java index 0fb43a4511..276ba1bedc 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java @@ -60,7 +60,7 @@ public class DefaultTbAlarmCommentService extends AbstractTbEntityService implem alarmComment.setType(AlarmCommentType.SYSTEM); alarmComment.setUserId(null); alarmComment.setComment(JacksonUtil.newObjectNode().put("text", - String.format("User %s deleted his comment", + String.format("Comment was deleted by user %s", (user.getFirstName() == null || user.getLastName() == null) ? user.getName() : user.getFirstName() + " " + user.getLastName()))); AlarmComment savedAlarmComment = checkNotNull(alarmCommentService.saveAlarmComment(alarm.getTenantId(), alarmComment)); logEntityActionService.logEntityAction(alarm.getTenantId(), alarm.getId(), alarm, alarm.getCustomerId(), ActionType.DELETED_COMMENT, user, savedAlarmComment); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java index fde88bf084..7a7f907aa6 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java @@ -20,6 +20,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -76,7 +77,7 @@ public class DefaultTbCalculatedFieldService extends AbstractTbEntityService imp @Override @Transactional - public void delete(CalculatedField calculatedField, SecurityUser user) { + public void delete(CalculatedField calculatedField, User user) { ActionType actionType = ActionType.DELETED; TenantId tenantId = calculatedField.getTenantId(); CalculatedFieldId calculatedFieldId = calculatedField.getId(); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java index 25b58c1d39..4cf12daae8 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.entitiy.cf; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CalculatedFieldId; @@ -31,6 +32,6 @@ public interface TbCalculatedFieldService { PageData findAllByTenantIdAndEntityId(EntityId entityId, SecurityUser user, PageLink pageLink); - void delete(CalculatedField calculatedField, SecurityUser user); + void delete(CalculatedField calculatedField, User user); } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java index 87c1aaa707..4e42b41dd2 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfi import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; @@ -45,6 +46,7 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb private final QueueService queueService; private final TbClusterService tbClusterService; private final TbQueueAdmin tbQueueAdmin; + private final TopicService topicService; @Override public Queue saveQueue(Queue queue) { @@ -173,9 +175,10 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb private void createTopicsIfNeeded(Queue queue, Queue oldQueue) { int newPartitions = queue.getPartitions(); int oldPartitions = oldQueue != null ? oldQueue.getPartitions() : 0; + String topic = topicService.buildTopicName(queue.getTopic()); for (int i = oldPartitions; i < newPartitions; i++) { tbQueueAdmin.createTopicIfNotExists( - new TopicPartitionInfo(queue.getTopic(), queue.getTenantId(), i, false).getFullTopicName(), + new TopicPartitionInfo(topic, queue.getTenantId(), i, false).getFullTopicName(), queue.getCustomProperties(), true); // forcing topic creation because the topic may still be cached on some nodes } diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java new file mode 100644 index 0000000000..e88d812f5b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java @@ -0,0 +1,1018 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.iot_hub; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.iot_hub.CalculatedFieldInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.DashboardInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.DeviceInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.RuleChainInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.SolutionTemplateInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.WidgetInstalledItemDescriptor; +import org.thingsboard.server.service.solutions.SolutionService; +import org.thingsboard.server.service.solutions.data.solution.SolutionInstallResponse; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.NodeConnectionInfo; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.cf.CalculatedFieldService; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.asset.profile.TbAssetProfileService; +import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService; +import org.thingsboard.server.service.entitiy.dashboard.TbDashboardService; +import org.thingsboard.server.service.entitiy.device.TbDeviceService; +import org.thingsboard.server.service.entitiy.device.profile.TbDeviceProfileService; +import org.thingsboard.server.service.entitiy.widgets.type.TbWidgetTypeService; +import org.thingsboard.server.service.rule.TbRuleChainService; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +@Slf4j +public class DefaultIotHubService implements IotHubService { + + private static final String ACTION_INSTALL = "install"; + private static final String ACTION_UPDATE = "update"; + private static final String SECTION_PACKAGE_DATA = "package data"; + private static final String SECTION_RULE_CHAIN_METADATA = "rule chain metadata"; + + private static final String ITEM_TYPE_WIDGET = "widget"; + private static final String ITEM_TYPE_DASHBOARD = "dashboard"; + private static final String ITEM_TYPE_CALCULATED_FIELD = "calculated field"; + private static final String ITEM_TYPE_RULE_CHAIN = "rule chain"; + private static final String ITEM_TYPE_DEVICE_PROFILE = "device profile"; + + private final IotHubRestClient iotHubRestClient; + private final TbWidgetTypeService tbWidgetTypeService; + private final TbDashboardService tbDashboardService; + private final TbCalculatedFieldService tbCalculatedFieldService; + private final RuleChainService ruleChainService; + private final TbRuleChainService tbRuleChainService; + private final TbDeviceProfileService tbDeviceProfileService; + private final AssetProfileService assetProfileService; + private final TbAssetProfileService tbAssetProfileService; + private final IotHubInstalledItemService iotHubInstalledItemService; + private final WidgetTypeService widgetTypeService; + private final DashboardService dashboardService; + private final CalculatedFieldService calculatedFieldService; + private final DeviceProfileService deviceProfileService; + private final DeviceService deviceService; + private final TbDeviceService tbDeviceService; + private final SolutionService solutionService; + + // Field names of the marketplace version JSON payload. Both the install path and the + // install-plan resolver parse the same shape, so the contract lives here in one place. + private static final String FIELD_ID = "id"; + private static final String FIELD_ITEM_ID = "itemId"; + private static final String FIELD_TYPE = "type"; + private static final String FIELD_NAME = "name"; + private static final String FIELD_VERSION = "version"; + private static final String FIELD_RELATED_ITEMS = "relatedItems"; + + @Override + public InstallItemVersionResult installItemVersion(SecurityUser user, String versionId, JsonNode data, HttpServletRequest request) { + TenantId tenantId = user.getTenantId(); + log.info("[{}] Installing IoT Hub item version: {}", tenantId, versionId); + + JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId); + if (versionInfo == null) { + throw new IllegalArgumentException("Failed to get version info from IoT Hub"); + } + + try { + IotHubInstalledItem installedItem = doInstallVersion(user, versionId, data, request); + return InstallItemVersionResult.success(installedItem.getDescriptor()); + } catch (Exception e) { + log.error("[{}] Failed to install IoT Hub item version: {}", tenantId, versionId, e); + return InstallItemVersionResult.error(e.getMessage()); + } + } + + /** + * Fetch + apply a single marketplace version, persist the installed-item record, and ping + * the marketplace install counter. Throws on failure so callers (cascade install) can roll back. + * Package-private so the cascade-install / rollback orchestration can be unit-tested in isolation. + */ + IotHubInstalledItem doInstallVersion(SecurityUser user, String versionId, JsonNode data, HttpServletRequest request) throws Exception { + TenantId tenantId = user.getTenantId(); + JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId); + String itemType = versionInfo.get(FIELD_TYPE).asText(); + String itemName = versionInfo.get(FIELD_NAME).asText(); + UUID itemId = UUID.fromString(versionInfo.get(FIELD_ITEM_ID).asText()); + String version = versionInfo.get(FIELD_VERSION).asText(); + log.debug("[{}] Fetched version info: {} (type: {})", tenantId, itemName, itemType); + + byte[] fileData = iotHubRestClient.getVersionFileData(versionId); + log.debug("[{}] Fetched file data, size: {} bytes", tenantId, fileData != null ? fileData.length : 0); + + IotHubInstalledItemDescriptor descriptor = switch (itemType) { + case "WIDGET" -> installWidget(user, tenantId, fileData); + case "DASHBOARD" -> installDashboard(user, tenantId, fileData); + case "CALCULATED_FIELD" -> installCalculatedField(user, tenantId, fileData, data); + case "ALARM_RULE" -> throw new IllegalArgumentException( + "Alarm Rules require ThingsBoard 4.3 or later. Please update your platform instance to install Alarm Rule packages."); + case "RULE_CHAIN" -> installRuleChain(user, tenantId, fileData, data); + case "DEVICE" -> installDeviceProfile(user, tenantId, fileData); + case "SOLUTION_TEMPLATE" -> installSolution(user, tenantId, fileData, request); + default -> throw new IllegalArgumentException("Unsupported IoT Hub item type: " + itemType); + }; + + IotHubInstalledItem installedItem = new IotHubInstalledItem(); + installedItem.setTenantId(tenantId); + installedItem.setItemId(itemId); + installedItem.setItemVersionId(UUID.fromString(versionId)); + installedItem.setItemName(itemName); + installedItem.setItemType(itemType); + installedItem.setVersion(version); + installedItem.setDescriptor(descriptor); + try { + installedItem = iotHubInstalledItemService.save(tenantId, installedItem); + } catch (Exception e) { + // Tracking save failed after the entity was already created locally — compensate by + // deleting the orphaned entity so the user is not left with an installed item that + // IoT Hub update/delete flows cannot see. + log.error("[{}] Failed to save IoT Hub installed-item tracking row for {} ({}); rolling back created entity", + tenantId, itemName, itemType, e); + try { + deleteEntityForDescriptor(user, tenantId, descriptor, null); + } catch (Exception rb) { + log.error("[{}] Compensating delete after failed tracking save threw — entity may remain orphaned", + tenantId, rb); + } + throw e; + } + + try { + iotHubRestClient.reportVersionInstalled(versionId); + } catch (Exception e) { + // Counter ping is best-effort — do not fail the install if it errors. + log.warn("[{}] Failed to report install counter for version {}: {}", tenantId, versionId, e.getMessage()); + } + log.info("[{}] Successfully installed IoT Hub item version: {} (type: {})", tenantId, itemName, itemType); + return installedItem; + } + + private WidgetInstalledItemDescriptor installWidget(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { + WidgetTypeDetails widgetTypeDetails; + try { + widgetTypeDetails = JacksonUtil.fromString(new String(fileData), WidgetTypeDetails.class, true); + } catch (Exception e) { + throw parseFailure(ACTION_INSTALL, ITEM_TYPE_WIDGET, e); + } + widgetTypeDetails.setId(null); + widgetTypeDetails.setTenantId(tenantId); + WidgetTypeDetails saved = tbWidgetTypeService.save(widgetTypeDetails, false, user); + log.debug("[{}] Widget installed: {}", tenantId, saved.getName()); + WidgetInstalledItemDescriptor descriptor = new WidgetInstalledItemDescriptor(); + descriptor.setWidgetTypeId(saved.getId()); + return descriptor; + } + + private DashboardInstalledItemDescriptor installDashboard(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { + Dashboard dashboard; + try { + dashboard = JacksonUtil.fromString(new String(fileData), Dashboard.class, true); + } catch (Exception e) { + throw parseFailure(ACTION_INSTALL, ITEM_TYPE_DASHBOARD, e); + } + dashboard.setId(null); + dashboard.setTenantId(tenantId); + Dashboard saved = tbDashboardService.save(dashboard, user); + log.debug("[{}] Dashboard installed: {}", tenantId, saved.getTitle()); + DashboardInstalledItemDescriptor descriptor = new DashboardInstalledItemDescriptor(); + descriptor.setDashboardId(saved.getId()); + return descriptor; + } + + private CalculatedFieldInstalledItemDescriptor installCalculatedField(SecurityUser user, TenantId tenantId, byte[] fileData, JsonNode data) throws Exception { + CalculatedField calculatedField; + try { + calculatedField = JacksonUtil.fromString(new String(fileData), CalculatedField.class, true); + } catch (Exception e) { + throw parseFailure(ACTION_INSTALL, ITEM_TYPE_CALCULATED_FIELD, e); + } + calculatedField.setId(null); + calculatedField.setTenantId(tenantId); + if (data != null && data.has("entityId")) { + EntityId entityId = JacksonUtil.treeToValue(data.get("entityId"), EntityId.class); + calculatedField.setEntityId(entityId); + } + CalculatedField saved = tbCalculatedFieldService.save(calculatedField, user); + log.debug("[{}] Calculated field installed: {}", tenantId, saved.getName()); + CalculatedFieldInstalledItemDescriptor descriptor = new CalculatedFieldInstalledItemDescriptor(); + descriptor.setCalculatedFieldId(saved.getId()); + descriptor.setEntityId(saved.getEntityId()); + return descriptor; + } + + private RuleChainInstalledItemDescriptor installRuleChain( + SecurityUser user, TenantId tenantId, byte[] fileData, JsonNode data) throws Exception { + JsonNode json = JacksonUtil.toJsonNode(new String(fileData)); + + RuleChain ruleChain; + try { + ruleChain = JacksonUtil.fromString(json.get("ruleChain").toString(), RuleChain.class, true); + } catch (Exception e) { + throw parseFailure(ACTION_INSTALL, ITEM_TYPE_RULE_CHAIN, e); + } + + RuleChainMetaData metadata; + try { + metadata = JacksonUtil.fromString(json.get("metadata").toString(), RuleChainMetaData.class, true); + } catch (Exception e) { + throw parseFailure(ACTION_INSTALL, ITEM_TYPE_RULE_CHAIN, SECTION_RULE_CHAIN_METADATA, e); + } + + ruleChain.setId(null); + ruleChain.setTenantId(tenantId); + RuleChain savedRuleChain = ruleChainService.saveRuleChain(ruleChain); + + metadata.setRuleChainId(savedRuleChain.getId()); + metadata.setVersion(savedRuleChain.getVersion()); + ruleChainService.saveRuleChainMetaData(tenantId, metadata, tbRuleChainService::updateRuleNodeConfiguration); + + log.debug("[{}] Rule chain installed: {}", tenantId, savedRuleChain.getName()); + + RuleChainInstalledItemDescriptor descriptor = new RuleChainInstalledItemDescriptor(); + descriptor.setRuleChainId(savedRuleChain.getId()); + + if (data != null && data.has("entityId") && !data.get("entityId").isNull()) { + EntityId entityId = JacksonUtil.treeToValue(data.get("entityId"), EntityId.class); + setAsDefaultRuleChain(user, tenantId, entityId, savedRuleChain.getId()); + descriptor.setTargetProfileId(entityId); + } + + return descriptor; + } + + private void setAsDefaultRuleChain(SecurityUser user, TenantId tenantId, + EntityId entityId, RuleChainId ruleChainId) throws Exception { + switch (entityId.getEntityType()) { + case DEVICE_PROFILE -> { + DeviceProfile profile = deviceProfileService.findDeviceProfileById( + tenantId, (DeviceProfileId) entityId); + if (profile == null) { + throw new IllegalArgumentException( + "Device profile not found: " + entityId.getId()); + } + profile.setDefaultRuleChainId(ruleChainId); + tbDeviceProfileService.save(profile, user); + log.debug("[{}] Set rule chain {} as default on device profile {}", + tenantId, ruleChainId.getId(), entityId.getId()); + } + case ASSET_PROFILE -> { + AssetProfile profile = assetProfileService.findAssetProfileById( + tenantId, (AssetProfileId) entityId); + if (profile == null) { + throw new IllegalArgumentException( + "Asset profile not found: " + entityId.getId()); + } + profile.setDefaultRuleChainId(ruleChainId); + tbAssetProfileService.save(profile, user); + log.debug("[{}] Set rule chain {} as default on asset profile {}", + tenantId, ruleChainId.getId(), entityId.getId()); + } + default -> throw new IllegalArgumentException( + "Rule chain can only be set as default on device or asset profile, got: " + + entityId.getEntityType()); + } + } + + private DeviceInstalledItemDescriptor installDeviceProfile(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { + DeviceProfile deviceProfile; + try { + deviceProfile = JacksonUtil.fromString(new String(fileData), DeviceProfile.class, true); + } catch (Exception e) { + throw parseFailure(ACTION_INSTALL, ITEM_TYPE_DEVICE_PROFILE, e); + } + deviceProfile.setId(null); + deviceProfile.setTenantId(tenantId); + tbDeviceProfileService.save(deviceProfile, user); + log.debug("[{}] Device profile installed: {}", tenantId, deviceProfile.getName()); + return new DeviceInstalledItemDescriptor(); + } + + private SolutionTemplateInstalledItemDescriptor installSolution(SecurityUser user, TenantId tenantId, byte[] fileData, HttpServletRequest request) throws Exception { + SolutionInstallResponse response = solutionService.installSolution(user, tenantId, fileData, request); + if (!response.isSuccess()) { + throw new RuntimeException(response.getDetails()); + } + SolutionTemplateInstalledItemDescriptor descriptor = new SolutionTemplateInstalledItemDescriptor(); + descriptor.setCreatedEntityIds(response.getCreatedEntityIds()); + descriptor.setTenantTelemetryKeys(response.getTenantTelemetryKeys()); + descriptor.setTenantAttributeKeys(response.getTenantAttributeKeys()); + descriptor.setDashboardId(response.getDashboardId()); + descriptor.setPublicId(response.getPublicId()); + descriptor.setMainDashboardPublic(response.isMainDashboardPublic()); + descriptor.setDetails(response.getDetails()); + return descriptor; + } + + @Override + public UpdateItemVersionResult updateItemVersion(SecurityUser user, IotHubInstalledItemId installedItemId, String versionId, boolean force, HttpServletRequest request) { + TenantId tenantId = user.getTenantId(); + log.info("[{}] Updating IoT Hub installed item {} to version: {}", tenantId, installedItemId, versionId); + + try { + IotHubInstalledItem installedItem = iotHubInstalledItemService.findById(tenantId, installedItemId); + if (installedItem == null) { + throw new IllegalArgumentException("Installed item not found"); + } + + String itemType = installedItem.getItemType(); + IotHubInstalledItemDescriptor descriptor = installedItem.getDescriptor(); + + // Skip checksum validation for solution templates + if (!"SOLUTION_TEMPLATE".equals(itemType)) { + JsonNode installedVersionInfo = iotHubRestClient.getVersionInfo(installedItem.getItemVersionId().toString()); + if (installedVersionInfo == null) { + throw new IllegalArgumentException("Failed to get installed version info from IoT Hub"); + } + String installedChecksum = installedVersionInfo.has("checksum") ? installedVersionInfo.get("checksum").asText() : null; + log.info("[{}] Installed version info: name={}, version={}, checksum={}", tenantId, installedItem.getItemName(), installedItem.getVersion(), installedChecksum); + + if (!force) { + String entityChecksum = calculateEntityChecksum(tenantId, installedItem); + boolean entityModified = installedChecksum != null && !installedChecksum.equals(entityChecksum); + log.info("[{}] Entity checksum: {}, modified: {}", tenantId, entityChecksum, entityModified); + + if (entityModified) { + return UpdateItemVersionResult.entityModified(); + } + } + } + + JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId); + + if (versionInfo == null) { + throw new IllegalArgumentException("Failed to get version info from IoT Hub"); + } + + String updateItemType = versionInfo.get("type").asText(); + if (!itemType.equals(updateItemType)) { + throw new IllegalArgumentException("Installed item type does not match the new version's item type."); + } + + String itemName = versionInfo.get("name").asText(); + String version = versionInfo.get("version").asText(); + + byte[] fileData = iotHubRestClient.getVersionFileData(versionId); + log.info("[{}] Fetched update file data, size: {} bytes", tenantId, fileData != null ? fileData.length : 0); + + switch (itemType) { + case "WIDGET" -> updateWidget(user, tenantId, (WidgetInstalledItemDescriptor) descriptor, fileData); + case "DASHBOARD" -> updateDashboard(user, tenantId, (DashboardInstalledItemDescriptor) descriptor, fileData); + case "CALCULATED_FIELD" -> updateCalculatedField(user, tenantId, (CalculatedFieldInstalledItemDescriptor) descriptor, fileData); + case "RULE_CHAIN" -> updateRuleChain(tenantId, (RuleChainInstalledItemDescriptor) descriptor, fileData); + case "DEVICE" -> { + DeviceInstalledItemDescriptor dd = (DeviceInstalledItemDescriptor) descriptor; + deleteDevicePackageEntities(tenantId, dd.getCreatedEntityIds(), user); + dd.setCreatedEntityIds(null); + dd.setDashboardId(null); + } + case "SOLUTION_TEMPLATE" -> { + SolutionTemplateInstalledItemDescriptor stDescriptor = (SolutionTemplateInstalledItemDescriptor) descriptor; + solutionService.deleteSolution(tenantId, stDescriptor, user); + SolutionInstallResponse response = solutionService.installSolution(user, tenantId, fileData, request); + if (!response.isSuccess()) { + throw new RuntimeException(response.getDetails()); + } + stDescriptor.setCreatedEntityIds(response.getCreatedEntityIds()); + stDescriptor.setTenantTelemetryKeys(response.getTenantTelemetryKeys()); + stDescriptor.setTenantAttributeKeys(response.getTenantAttributeKeys()); + stDescriptor.setDashboardId(response.getDashboardId()); + stDescriptor.setPublicId(response.getPublicId()); + stDescriptor.setMainDashboardPublic(response.isMainDashboardPublic()); + stDescriptor.setDetails(response.getDetails()); + } + default -> throw new IllegalArgumentException("Unsupported IoT Hub item type: " + itemType); + } + + installedItem.setItemVersionId(UUID.fromString(versionId)); + installedItem.setItemName(itemName); + installedItem.setVersion(version); + iotHubInstalledItemService.save(tenantId, installedItem); + + log.info("[{}] Successfully updated IoT Hub item {} to version: {}", tenantId, itemName, version); + + return UpdateItemVersionResult.success(installedItem.getDescriptor()); + } catch (Exception e) { + log.error("[{}] Failed to update IoT Hub installed item {} to version: {}", tenantId, installedItemId, versionId, e); + return UpdateItemVersionResult.error(e.getMessage()); + } + } + + private void updateWidget(SecurityUser user, TenantId tenantId, WidgetInstalledItemDescriptor descriptor, byte[] fileData) throws Exception { + WidgetTypeDetails newWidgetType; + try { + newWidgetType = JacksonUtil.fromString(new String(fileData), WidgetTypeDetails.class, true); + } catch (Exception e) { + throw parseFailure(ACTION_UPDATE, ITEM_TYPE_WIDGET, e); + } + WidgetTypeDetails existing = widgetTypeService.findWidgetTypeDetailsById(tenantId, descriptor.getWidgetTypeId()); + if (existing == null) { + throw new Exception("Widget not found for update"); + } + existing.setName(newWidgetType.getName()); + existing.setDescriptor(newWidgetType.getDescriptor()); + tbWidgetTypeService.save(existing, false, user); + } + + private void updateDashboard(SecurityUser user, TenantId tenantId, DashboardInstalledItemDescriptor descriptor, byte[] fileData) throws Exception { + Dashboard newDashboard; + try { + newDashboard = JacksonUtil.fromString(new String(fileData), Dashboard.class, true); + } catch (Exception e) { + throw parseFailure(ACTION_UPDATE, ITEM_TYPE_DASHBOARD, e); + } + Dashboard existing = dashboardService.findDashboardById(tenantId, descriptor.getDashboardId()); + if (existing == null) { + throw new Exception("Dashboard not found for update"); + } + existing.setTitle(newDashboard.getTitle()); + existing.setConfiguration(newDashboard.getConfiguration()); + tbDashboardService.save(existing, user); + } + + private void updateCalculatedField(SecurityUser user, TenantId tenantId, CalculatedFieldInstalledItemDescriptor descriptor, byte[] fileData) throws Exception { + CalculatedField newCf; + try { + newCf = JacksonUtil.fromString(new String(fileData), CalculatedField.class, true); + } catch (Exception e) { + throw parseFailure(ACTION_UPDATE, ITEM_TYPE_CALCULATED_FIELD, e); + } + CalculatedField existing = calculatedFieldService.findById(tenantId, descriptor.getCalculatedFieldId()); + if (existing == null) { + throw new Exception("Calculated field not found for update"); + } + existing.setName(newCf.getName()); + existing.setType(newCf.getType()); + existing.setConfiguration(newCf.getConfiguration()); + tbCalculatedFieldService.save(existing, user); + } + + private void updateRuleChain(TenantId tenantId, RuleChainInstalledItemDescriptor descriptor, byte[] fileData) throws Exception { + JsonNode json = JacksonUtil.toJsonNode(new String(fileData)); + RuleChainMetaData metadata; + try { + metadata = JacksonUtil.fromString(json.get("metadata").toString(), RuleChainMetaData.class, true); + } catch (Exception e) { + throw parseFailure(ACTION_UPDATE, ITEM_TYPE_RULE_CHAIN, SECTION_RULE_CHAIN_METADATA, e); + } + RuleChain existing = ruleChainService.findRuleChainById(tenantId, descriptor.getRuleChainId()); + if (existing == null) { + throw new Exception("Rule chain not found for update"); + } + RuleChain newRuleChain; + try { + newRuleChain = JacksonUtil.fromString(json.get("ruleChain").toString(), RuleChain.class, true); + } catch (Exception e) { + throw parseFailure(ACTION_UPDATE, ITEM_TYPE_RULE_CHAIN, e); + } + existing.setName(newRuleChain.getName()); + RuleChain savedRuleChain = ruleChainService.saveRuleChain(existing); + metadata.setRuleChainId(savedRuleChain.getId()); + metadata.setVersion(savedRuleChain.getVersion()); + ruleChainService.saveRuleChainMetaData(tenantId, metadata, tbRuleChainService::updateRuleNodeConfiguration); + } + + private String calculateEntityChecksum(TenantId tenantId, IotHubInstalledItem installedItem) { + IotHubInstalledItemDescriptor descriptor = installedItem.getDescriptor(); + if (descriptor instanceof WidgetInstalledItemDescriptor wd) { + return calculateWidgetChecksum(tenantId, wd); + } else if (descriptor instanceof DashboardInstalledItemDescriptor dd) { + return calculateDashboardChecksum(tenantId, dd); + } else if (descriptor instanceof CalculatedFieldInstalledItemDescriptor cd) { + return calculateCalculatedFieldChecksum(tenantId, cd); + } else if (descriptor instanceof RuleChainInstalledItemDescriptor rd) { + return calculateRuleChainChecksum(tenantId, rd); + } + return null; + } + + private String calculateCalculatedFieldChecksum(TenantId tenantId, CalculatedFieldInstalledItemDescriptor descriptor) { + CalculatedField cf = calculatedFieldService.findById(tenantId, descriptor.getCalculatedFieldId()); + if (cf == null) { + return null; + } + String content = (cf.getName() != null ? cf.getName() : "") + + (cf.getType() != null ? cf.getType().name() : "") + + (cf.getConfiguration() != null ? JacksonUtil.valueToTree(cf.getConfiguration()).toString() : ""); + return sha256(content); + } + + private String calculateDashboardChecksum(TenantId tenantId, DashboardInstalledItemDescriptor descriptor) { + Dashboard dashboard = dashboardService.findDashboardById(tenantId, descriptor.getDashboardId()); + if (dashboard == null) { + return null; + } + String content = (dashboard.getTitle() != null ? dashboard.getTitle() : "") + + (dashboard.getConfiguration() != null ? dashboard.getConfiguration().toString() : ""); + return sha256(content); + } + + private String calculateWidgetChecksum(TenantId tenantId, WidgetInstalledItemDescriptor descriptor) { + WidgetTypeDetails widgetType = widgetTypeService.findWidgetTypeDetailsById(tenantId, descriptor.getWidgetTypeId()); + if (widgetType == null) { + return null; + } + String content = (widgetType.getFqn() != null ? widgetType.getFqn() : "") + + (widgetType.getName() != null ? widgetType.getName() : "") + + (widgetType.getDescriptor() != null ? widgetType.getDescriptor().toString() : ""); + return sha256(content); + } + + private String calculateRuleChainChecksum(TenantId tenantId, RuleChainInstalledItemDescriptor descriptor) { + RuleChain ruleChain = ruleChainService.findRuleChainById(tenantId, descriptor.getRuleChainId()); + if (ruleChain == null) { + return null; + } + RuleChainMetaData metadata = ruleChainService.loadRuleChainMetaData(tenantId, descriptor.getRuleChainId()); + StringBuilder content = new StringBuilder(); + content.append(ruleChain.getName() != null ? ruleChain.getName() : ""); + content.append(ruleChain.getType() != null ? ruleChain.getType().name() : ""); + if (metadata.getNodes() != null) { + for (RuleNode node : metadata.getNodes()) { + content.append(node.getType() != null ? node.getType() : ""); + content.append(node.getName() != null ? node.getName() : ""); + content.append(node.getConfiguration() != null ? node.getConfiguration().toString() : ""); + } + } + if (metadata.getConnections() != null) { + for (NodeConnectionInfo conn : metadata.getConnections()) { + content.append(conn.getFromIndex()); + content.append(conn.getToIndex()); + content.append(conn.getType() != null ? conn.getType() : ""); + } + } + return sha256(content.toString()); + } + + private static String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (Exception e) { + throw new RuntimeException("Failed to calculate SHA-256", e); + } + } + + @Override + public InstallItemVersionResult registerDeviceInstall(SecurityUser user, String versionId, DeviceInstalledItemDescriptor descriptor) { + TenantId tenantId = user.getTenantId(); + log.info("[{}] Registering device package install for version: {}", tenantId, versionId); + + try { + JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId); + + if (versionInfo == null) { + throw new IllegalArgumentException("Failed to get version info from IoT Hub"); + } + + String itemName = versionInfo.get("name").asText(); + UUID itemId = UUID.fromString(versionInfo.get("itemId").asText()); + String version = versionInfo.get("version").asText(); + + IotHubInstalledItem installedItem = new IotHubInstalledItem(); + installedItem.setTenantId(tenantId); + installedItem.setItemId(itemId); + installedItem.setItemVersionId(UUID.fromString(versionId)); + installedItem.setItemName(itemName); + installedItem.setItemType("DEVICE"); + installedItem.setVersion(version); + installedItem.setDescriptor(descriptor); + try { + iotHubInstalledItemService.save(tenantId, installedItem); + } catch (Exception e) { + log.error("[{}] Failed to save IoT Hub installed-item tracking row for device package {} (version {}); rolling back created entities", + tenantId, itemName, version, e); + try { + deleteEntityForDescriptor(user, tenantId, descriptor, null); + } catch (Exception rb) { + log.error("[{}] Compensating delete after failed tracking save threw — device package entities may remain orphaned", + tenantId, rb); + } + throw e; + } + + try { + iotHubRestClient.reportVersionInstalled(versionId); + } catch (Exception e) { + // Counter ping is best-effort — do not fail the install if it errors. + log.warn("[{}] Failed to report install counter for version {}: {}", tenantId, versionId, e.getMessage()); + } + log.info("[{}] Registered device package install: {} (version {})", tenantId, itemName, version); + + return InstallItemVersionResult.success(descriptor); + } catch (Exception e) { + log.error("[{}] Failed to register device package install: {}", tenantId, versionId, e); + return InstallItemVersionResult.error(e.getMessage()); + } + } + + private void deleteDevicePackageEntities(TenantId tenantId, List createdEntityIds, SecurityUser user) { + if (createdEntityIds == null || createdEntityIds.isEmpty()) { + return; + } + List reversed = new ArrayList<>(createdEntityIds); + Collections.reverse(reversed); + for (EntityId entityId : reversed) { + try { + deleteDevicePackageEntity(tenantId, entityId, user); + } catch (Exception e) { + log.error("[{}] Failed to delete device package entity: {}", tenantId, entityId, e); + } + } + } + + private void deleteDevicePackageEntity(TenantId tenantId, EntityId entityId, SecurityUser user) { + switch (entityId.getEntityType()) { + case DEVICE -> { + var device = deviceService.findDeviceById(tenantId, new DeviceId(entityId.getId())); + if (device != null) tbDeviceService.delete(device, user); + } + case DEVICE_PROFILE -> { + var profile = deviceProfileService.findDeviceProfileById(tenantId, new DeviceProfileId(entityId.getId())); + if (profile != null) tbDeviceProfileService.delete(profile, user); + } + case DASHBOARD -> { + var dashboard = dashboardService.findDashboardById(tenantId, new DashboardId(entityId.getId())); + if (dashboard != null) tbDashboardService.delete(dashboard, user); + } + case RULE_CHAIN -> { + var ruleChain = ruleChainService.findRuleChainById(tenantId, new RuleChainId(entityId.getId())); + if (ruleChain != null) tbRuleChainService.delete(ruleChain, user); + } + default -> log.warn("[{}] Unsupported entity type for device package delete: {}", tenantId, entityId.getEntityType()); + } + } + + @Override + public void deleteInstalledItem(SecurityUser user, IotHubInstalledItemId installedItemId) { + TenantId tenantId = user.getTenantId(); + IotHubInstalledItem installedItem = iotHubInstalledItemService.findById(tenantId, installedItemId); + if (installedItem == null) { + throw new IllegalArgumentException("Installed item not found"); + } + + deleteEntityForDescriptor(user, tenantId, installedItem.getDescriptor(), installedItemId); + + iotHubInstalledItemService.deleteById(tenantId, installedItemId); + log.info("[{}] Deleted installed IoT Hub item: {}", tenantId, installedItem.getItemName()); + } + + /** + * Per-type dispatcher that removes the local entities a marketplace install created. Shared by + * {@link #deleteInstalledItem} and by the tracking-row compensation path in + * {@link #doInstallVersion} so a failed tracking save does not leave the entity orphaned. + */ + private void deleteEntityForDescriptor(SecurityUser user, TenantId tenantId, + IotHubInstalledItemDescriptor descriptor, + IotHubInstalledItemId installedItemId) { + if (descriptor instanceof WidgetInstalledItemDescriptor wd) { + WidgetTypeDetails widgetType = widgetTypeService.findWidgetTypeDetailsById(tenantId, wd.getWidgetTypeId()); + if (widgetType != null) { + tbWidgetTypeService.delete(widgetType, user); + } + } else if (descriptor instanceof DashboardInstalledItemDescriptor dd) { + Dashboard dashboard = dashboardService.findDashboardById(tenantId, dd.getDashboardId()); + if (dashboard != null) { + tbDashboardService.delete(dashboard, user); + } + } else if (descriptor instanceof CalculatedFieldInstalledItemDescriptor cd) { + CalculatedField calculatedField = calculatedFieldService.findById(tenantId, cd.getCalculatedFieldId()); + if (calculatedField != null) { + tbCalculatedFieldService.delete(calculatedField, user); + } + } else if (descriptor instanceof RuleChainInstalledItemDescriptor rd) { + if (rd.getRuleChainId() != null) { + RuleChain ruleChain = ruleChainService.findRuleChainById(tenantId, rd.getRuleChainId()); + if (ruleChain != null) { + tbRuleChainService.delete(ruleChain, user); + } + } + } else if (descriptor instanceof DeviceInstalledItemDescriptor dd) { + deleteDevicePackageEntities(tenantId, dd.getCreatedEntityIds(), user); + } else if (descriptor instanceof SolutionTemplateInstalledItemDescriptor st) { + try { + solutionService.deleteSolution(tenantId, st, user); + } catch (ThingsboardException e) { + log.error("[{}] Failed to delete solution for installed item {}", tenantId, installedItemId, e); + } + } + } + + @Override + public InstallPlan resolveInstallPlan(SecurityUser user, String versionId) { + TenantId tenantId = user.getTenantId(); + log.debug("[{}] Resolving install plan for version: {}", tenantId, versionId); + + JsonNode rootVersion = iotHubRestClient.getVersionInfo(versionId); + if (rootVersion == null) { + throw new IllegalArgumentException("Marketplace version not found: " + versionId); + } + + // Null-safe: a malformed payload without an itemId must surface the friendly + // IllegalArgumentException thrown by addPlanEntry(root) below, not an NPE here. + String rootItemId = optText(rootVersion, FIELD_ITEM_ID); + JsonNode related = rootVersion.get(FIELD_RELATED_ITEMS); + + // The plan only ever touches the root and its (one-level-deep) related items, so we ask the + // DB whether just those item ids are already installed instead of loading every installed id + // for the tenant — a tenant may have thousands installed while a plan checks a handful. + Set candidateItemIds = new HashSet<>(); + addCandidateItemId(candidateItemIds, rootItemId); + if (related != null && related.isArray()) { + for (JsonNode relatedNode : related) { + addCandidateItemId(candidateItemIds, relatedNode.asText()); + } + } + Set alreadyInstalledItemIds = candidateItemIds.isEmpty() + ? Set.of() + : new HashSet<>(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(tenantId, candidateItemIds)); + + // LinkedHashMap preserves insertion order; we add the related items (deps) before the + // root so a single forward iteration yields the correct install sequence (deps first, + // root last). Related items are leaf dependencies — IoT Hub items are only ever one + // level deep, so there is no need to walk a related item's own related items. + LinkedHashMap entries = new LinkedHashMap<>(); + + if (related != null && related.isArray()) { + for (JsonNode relatedNode : related) { + String relatedItemId = relatedNode.asText(); + if (relatedItemId == null || relatedItemId.isEmpty() + || relatedItemId.equals(rootItemId) || entries.containsKey(relatedItemId)) { + continue; + } + JsonNode relatedVersion; + try { + relatedVersion = iotHubRestClient.getPublishedVersionByItemId(relatedItemId); + } catch (Exception e) { + log.warn("Failed to fetch related item {}: {}", relatedItemId, e.getMessage()); + entries.put(relatedItemId, missingEntry(relatedItemId, e.getMessage())); + continue; + } + if (relatedVersion == null) { + log.warn("Related IoT Hub item {} is missing or unpublished — recording in plan", relatedItemId); + entries.put(relatedItemId, missingEntry(relatedItemId, "Item not found or not published")); + continue; + } + addPlanEntry(relatedVersion, alreadyInstalledItemIds, entries, false); + } + } + + addPlanEntry(rootVersion, alreadyInstalledItemIds, entries, true); + + return new InstallPlan(versionId, new ArrayList<>(entries.values())); + } + + /** + * Build an {@link InstallPlanEntry} for a single marketplace version and append it to the + * plan. No traversal of {@code relatedItems} happens here — related items are resolved one + * level deep by {@link #resolveInstallPlan}. + */ + private void addPlanEntry(JsonNode versionInfo, + Set alreadyInstalledItemIds, + LinkedHashMap entries, + boolean root) { + String itemId = optText(versionInfo, FIELD_ITEM_ID); + String versionId = optText(versionInfo, FIELD_ID); + if (itemId == null || versionId == null) { + // The marketplace payload is missing its identifiers. The root must have them to be + // installable; a related item without them is recorded as missing so the rest of the + // plan can still proceed. + if (root) { + throw new IllegalArgumentException("Marketplace version is missing required '" + + FIELD_ITEM_ID + "'/'" + FIELD_ID + "' fields"); + } + String key = itemId != null ? itemId : versionId; + log.warn("Related IoT Hub item is missing required identifiers — recording in plan: {}", versionInfo); + entries.putIfAbsent(key != null ? key : versionInfo.toString(), + missingEntry(itemId, "Item descriptor is incomplete")); + return; + } + if (entries.containsKey(itemId)) { + return; + } + + InstallPlanEntry entry = new InstallPlanEntry(); + entry.setItemId(itemId); + entry.setVersionId(versionId); + entry.setName(optText(versionInfo, FIELD_NAME)); + entry.setType(optText(versionInfo, FIELD_TYPE)); + entry.setVersion(optText(versionInfo, FIELD_VERSION)); + entry.setRoot(root); + entry.setStatus(isAlreadyInstalled(entry, alreadyInstalledItemIds) + ? InstallPlanEntry.Status.ALREADY_INSTALLED + : InstallPlanEntry.Status.WILL_INSTALL); + entries.put(itemId, entry); + } + + private static InstallPlanEntry missingEntry(String itemId, String errorMessage) { + InstallPlanEntry entry = new InstallPlanEntry(); + entry.setItemId(itemId); + entry.setStatus(InstallPlanEntry.Status.MISSING); + entry.setErrorMessage(errorMessage); + return entry; + } + + private static String optText(JsonNode node, String field) { + return node != null && node.hasNonNull(field) ? node.get(field).asText() : null; + } + + @Override + public InstallPlanResult installPlan(SecurityUser user, InstallPlan plan, JsonNode data, HttpServletRequest request) { + TenantId tenantId = user.getTenantId(); + if (plan == null || plan.getEntries() == null || plan.getEntries().isEmpty()) { + return new InstallPlanResult(false, false, "Install plan is empty", null, new ArrayList<>(), new ArrayList<>()); + } + + // The plan is resolved by a separate request, so by the time it is submitted an item may + // already have been installed (stale or replayed plan). Re-check the current state for only + // the items this plan would install, so we never install the same item twice. + Set candidateItemIds = new HashSet<>(); + for (InstallPlanEntry entry : plan.getEntries()) { + if (entry.getStatus() == InstallPlanEntry.Status.WILL_INSTALL) { + addCandidateItemId(candidateItemIds, entry.getItemId()); + } + } + // Mutable on purpose: as the cascade installs each item we add its id below, so a later + // duplicate entry for the same item in this plan is recognised as installed and skipped + // instead of installed twice. A client-supplied plan is not de-duplicated the way a freshly + // resolved one is, so the loop has to guard against duplicates itself. + Set alreadyInstalledItemIds = new HashSet<>(); + if (!candidateItemIds.isEmpty()) { + alreadyInstalledItemIds.addAll(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(tenantId, candidateItemIds)); + } + + InstallPlanResult result = new InstallPlanResult(); + List resultEntries = new ArrayList<>(); + List missingItemIds = new ArrayList<>(); + List rollbackIds = new ArrayList<>(); + IotHubInstalledItemDescriptor rootDescriptor = null; + + for (InstallPlanEntry entry : plan.getEntries()) { + InstallPlanEntry resultEntry = cloneEntry(entry); + switch (entry.getStatus()) { + case MISSING -> { + missingItemIds.add(entry.getItemId()); + resultEntries.add(resultEntry); + } + case ALREADY_INSTALLED -> resultEntries.add(resultEntry); + case WILL_INSTALL -> { + if (isAlreadyInstalled(entry, alreadyInstalledItemIds)) { + // Installed since the plan was resolved — skip rather than create a duplicate. + resultEntry.setStatus(InstallPlanEntry.Status.ALREADY_INSTALLED); + resultEntries.add(resultEntry); + break; + } + try { + // Only the root entry receives the user's install data (target profile entityId); + // transitive deps install with defaults. + JsonNode entryData = entry.isRoot() ? data : null; + IotHubInstalledItem installed = doInstallVersion(user, entry.getVersionId(), entryData, request); + rollbackIds.add(installed.getId()); + // Record it as installed so a duplicate entry for the same item later in this + // plan is skipped rather than installed a second time. + addCandidateItemId(alreadyInstalledItemIds, entry.getItemId()); + if (entry.isRoot()) { + rootDescriptor = installed.getDescriptor(); + } + resultEntries.add(resultEntry); + } catch (Exception e) { + log.error("[{}] Cascade install failed at entry {} ({}): {}", tenantId, + entry.getName(), entry.getVersionId(), e.getMessage(), e); + resultEntry.setErrorMessage(e.getMessage()); + resultEntries.add(resultEntry); + boolean rolledBack = rollbackInstalledItems(user, rollbackIds); + result.setSuccess(false); + result.setRolledBack(rolledBack); + result.setErrorMessage("Failed to install '" + entry.getName() + "': " + e.getMessage()); + result.setEntries(resultEntries); + result.setMissingItemIds(missingItemIds); + return result; + } + } + } + } + + result.setSuccess(true); + result.setRolledBack(false); + result.setRootDescriptor(rootDescriptor); + result.setEntries(resultEntries); + result.setMissingItemIds(missingItemIds); + return result; + } + + /** + * Deletes the supplied installed items in reverse install order. Returns {@code true} only if + * every deletion succeeded — a {@code false} result means some entities were left behind and + * the rollback is partial. + */ + private boolean rollbackInstalledItems(SecurityUser user, List installedIds) { + boolean fullyRolledBack = true; + for (int i = installedIds.size() - 1; i >= 0; i--) { + IotHubInstalledItemId id = installedIds.get(i); + try { + deleteInstalledItem(user, id); + } catch (Exception e) { + fullyRolledBack = false; + log.error("[{}] Failed to roll back installed item {}: {}", user.getTenantId(), id, e.getMessage(), e); + } + } + return fullyRolledBack; + } + + private static void addCandidateItemId(Set candidates, String itemId) { + if (itemId == null || itemId.isEmpty()) { + return; + } + try { + candidates.add(UUID.fromString(itemId)); + } catch (IllegalArgumentException ignored) { + // A non-UUID item id can never match an installed record — leave it out of the lookup. + } + } + + private static boolean isAlreadyInstalled(InstallPlanEntry entry, Set alreadyInstalledItemIds) { + if (entry == null || entry.getItemId() == null) { + return false; + } + if (entry.isRoot() && (!"WIDGET".equals(entry.getType()) && !"SOLUTION_TEMPLATE".equals(entry.getType()))) { + return false; + } + try { + return alreadyInstalledItemIds.contains(UUID.fromString(entry.getItemId())); + } catch (IllegalArgumentException ex) { + return false; + } + } + + private static InstallPlanEntry cloneEntry(InstallPlanEntry src) { + return new InstallPlanEntry(src.getItemId(), src.getVersionId(), src.getName(), src.getType(), + src.getVersion(), src.getStatus(), src.isRoot(), src.getErrorMessage()); + } + + private static Exception parseFailure(String action, String itemTypeName, Exception cause) { + return parseFailure(action, itemTypeName, SECTION_PACKAGE_DATA, cause); + } + + private static Exception parseFailure(String action, String itemTypeName, String section, Exception cause) { + return new Exception("Unable to " + action + " this " + itemTypeName + " — the " + section + " could not be parsed. " + + "The package may be corrupted or in an unexpected format; please contact the creator to have it re-published.", cause); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallItemVersionResult.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallItemVersionResult.java new file mode 100644 index 0000000000..f91085cfd6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallItemVersionResult.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.service.iot_hub; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemDescriptor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class InstallItemVersionResult { + + private boolean success; + private String errorMessage; + private IotHubInstalledItemDescriptor descriptor; + + public static InstallItemVersionResult success(IotHubInstalledItemDescriptor descriptor) { + return new InstallItemVersionResult(true, null, descriptor); + } + + public static InstallItemVersionResult error(String errorMessage) { + return new InstallItemVersionResult(false, errorMessage, null); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlan.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlan.java new file mode 100644 index 0000000000..12c268a6b4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlan.java @@ -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. + */ +package org.thingsboard.server.service.iot_hub; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class InstallPlan { + + private String rootVersionId; + private List entries; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanEntry.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanEntry.java new file mode 100644 index 0000000000..c073ff8e0e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanEntry.java @@ -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. + */ +package org.thingsboard.server.service.iot_hub; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class InstallPlanEntry { + + public enum Status { + WILL_INSTALL, + ALREADY_INSTALLED, + MISSING + } + + private String itemId; + private String versionId; + private String name; + private String type; + private String version; + private Status status; + private boolean root; + private String errorMessage; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanResult.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanResult.java new file mode 100644 index 0000000000..2c3f49e071 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanResult.java @@ -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. + */ +package org.thingsboard.server.service.iot_hub; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemDescriptor; + +import java.util.ArrayList; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class InstallPlanResult { + + private boolean success; + private boolean rolledBack; + private String errorMessage; + private IotHubInstalledItemDescriptor rootDescriptor; + private List entries = new ArrayList<>(); + private List missingItemIds = new ArrayList<>(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java new file mode 100644 index 0000000000..c67fef55a8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java @@ -0,0 +1,128 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.iot_hub; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; + +@Component +@TbCoreComponent +@Slf4j +public class IotHubRestClient { + + @Value("${iot-hub.base-url:https://iot-hub.thingsboard.io}") + private String baseUrl; + + @Value("${iot-hub.connect-timeout-sec:5}") + private int connectTimeoutSec; + + @Value("${iot-hub.read-timeout-sec:10}") + private int readTimeoutSec; + + @Value("${iot-hub.max-file-data-size-bytes:104857600}") + private long maxFileDataSizeBytes; + + private RestTemplate restTemplate; + + @PostConstruct + public void initRestClient() { + restTemplate = new RestTemplateBuilder() + .connectTimeout(Duration.ofSeconds(connectTimeoutSec)) + .readTimeout(Duration.ofSeconds(readTimeoutSec)) + .build(); + } + + public JsonNode getVersionInfo(String versionId) { + String url = baseUrl + "/api/versions/" + versionId; + log.debug("Fetching IoT Hub version info: {}", url); + return restTemplate.getForObject(url, JsonNode.class); + } + + /** + * Returns the latest published version of an item, or {@code null} if the marketplace + * responds with 404 (item missing or never published). Other 4xx/5xx still propagate. + */ + public JsonNode getPublishedVersionByItemId(String itemId) { + String url = baseUrl + "/api/items/" + itemId + "/published"; + log.debug("Fetching IoT Hub published version for item: {}", url); + try { + return restTemplate.getForObject(url, JsonNode.class); + } catch (HttpClientErrorException e) { + if (e.getStatusCode() == HttpStatus.NOT_FOUND) { + return null; + } + throw e; + } + } + + public byte[] getVersionFileData(String versionId) { + String url = baseUrl + "/api/versions/" + versionId + "/fileData"; + log.debug("Fetching IoT Hub version file data: {}", url); + // Stream the response so we can reject oversized payloads before + // they get loaded fully into memory: + // 1) refuse early if the server advertises a Content-Length + // larger than the configured cap; + // 2) refuse mid-stream once we have already buffered more than + // the cap (for chunked transfers without Content-Length). + final long limit = maxFileDataSizeBytes; + return restTemplate.execute(url, HttpMethod.GET, null, response -> { + long contentLength = response.getHeaders().getContentLength(); + if (contentLength > limit) { + throw new IllegalStateException("IoT Hub file data size " + contentLength + + " bytes exceeds the configured limit of " + limit + " bytes"); + } + int initialCapacity = contentLength > 0 && contentLength <= Integer.MAX_VALUE + ? (int) contentLength : 8192; + try (InputStream in = response.getBody(); + ByteArrayOutputStream out = new ByteArrayOutputStream(initialCapacity)) { + byte[] buffer = new byte[8192]; + long total = 0; + int read; + while ((read = in.read(buffer)) != -1) { + total += read; + if (total > limit) { + throw new IllegalStateException("IoT Hub file data stream exceeded the configured limit of " + + limit + " bytes"); + } + out.write(buffer, 0, read); + } + return out.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException("Failed to read IoT Hub file data", e); + } + }); + } + + public void reportVersionInstalled(String versionId) { + String url = baseUrl + "/api/versions/" + versionId + "/install"; + log.debug("Reporting IoT Hub version installed: {}", url); + restTemplate.postForObject(url, null, Void.class); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java new file mode 100644 index 0000000000..fac0f94b10 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.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.service.iot_hub; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.servlet.http.HttpServletRequest; +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; +import org.thingsboard.server.common.data.iot_hub.DeviceInstalledItemDescriptor; +import org.thingsboard.server.service.security.model.SecurityUser; + +public interface IotHubService { + + InstallItemVersionResult installItemVersion(SecurityUser user, String versionId, JsonNode data, HttpServletRequest request); + + UpdateItemVersionResult updateItemVersion(SecurityUser user, IotHubInstalledItemId installedItemId, String versionId, boolean force, HttpServletRequest request); + + InstallItemVersionResult registerDeviceInstall(SecurityUser user, String versionId, DeviceInstalledItemDescriptor descriptor); + + void deleteInstalledItem(SecurityUser user, IotHubInstalledItemId installedItemId); + + InstallPlan resolveInstallPlan(SecurityUser user, String versionId); + + InstallPlanResult installPlan(SecurityUser user, InstallPlan plan, JsonNode data, HttpServletRequest request); +} diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/UpdateItemVersionResult.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/UpdateItemVersionResult.java new file mode 100644 index 0000000000..1ba7b0a7b5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/UpdateItemVersionResult.java @@ -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. + */ +package org.thingsboard.server.service.iot_hub; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemDescriptor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateItemVersionResult { + + private boolean success; + private boolean entityModified; + private String errorMessage; + private IotHubInstalledItemDescriptor descriptor; + + public static UpdateItemVersionResult success(IotHubInstalledItemDescriptor descriptor) { + return new UpdateItemVersionResult(true, false, null, descriptor); + } + + public static UpdateItemVersionResult entityModified() { + return new UpdateItemVersionResult(false, true, null, null); + } + + public static UpdateItemVersionResult error(String errorMessage) { + return new UpdateItemVersionResult(false, false, errorMessage, null); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index de2e65723e..9eacd83e62 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -465,10 +465,10 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService 0 ? RpcError.values()[proto.getError()] : null; + void forwardToCoreRpcService(FromDeviceRPCResponseProto proto, TbCallback callback) { + RpcError error = RpcError.fromProtoErrorCode(proto.getError()); FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()) - , proto.getResponse(), error); + , proto.hasResponse() ? proto.getResponse() : null, error); tbCoreDeviceRpcService.processRpcResponseFromRuleEngine(response); callback.onSuccess(); } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java index 22e2f05cb6..01ac9a3665 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java @@ -180,9 +180,9 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo callback.onSuccess(); } else if (nfMsg.hasFromDeviceRpcResponse()) { TransportProtos.FromDeviceRPCResponseProto proto = nfMsg.getFromDeviceRpcResponse(); - RpcError error = proto.getError() > 0 ? RpcError.values()[proto.getError()] : null; + RpcError error = RpcError.fromProtoErrorCode(proto.getError()); FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()) - , proto.getResponse(), error); + , proto.hasResponse() ? proto.getResponse() : null, error); tbDeviceRpcService.processRpcResponseFromDevice(response); callback.onSuccess(); } else if (nfMsg.getQueueUpdateMsgsCount() > 0) { diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java b/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java new file mode 100644 index 0000000000..71020ae47e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java @@ -0,0 +1,1710 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.gson.JsonParser; +import jakarta.annotation.PreDestroy; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.adaptor.JsonConverter; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DashboardInfo; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.HasName; +import org.thingsboard.server.common.data.alarm.AlarmInfo; +import org.thingsboard.server.common.data.alarm.AlarmQuery; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.iot_hub.SolutionTemplateInstalledItemDescriptor; +import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery; +import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.configuration.BaseCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.debug.DebugSettings; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.AlarmId; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.UserId; +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.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.cf.CalculatedFieldService; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.device.DeviceConnectivityService; +import org.thingsboard.server.dao.device.DockerComposeParams; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.action.EntityActionService; +import org.thingsboard.server.service.entitiy.asset.TbAssetService; +import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService; +import org.thingsboard.server.service.entitiy.device.TbDeviceService; +import org.thingsboard.server.service.entitiy.edge.TbEdgeService; +import org.thingsboard.server.service.entitiy.entity.relation.TbEntityRelationService; +import org.thingsboard.server.service.rule.TbRuleChainService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.system.SystemSecurityService; +import org.thingsboard.server.service.solutions.data.CreatedAlarmRuleInfo; +import org.thingsboard.server.service.solutions.data.CreatedCalculatedFieldInfo; +import org.thingsboard.server.service.solutions.data.CreatedEntityInfo; +import org.thingsboard.server.service.solutions.data.DashboardLinkInfo; +import org.thingsboard.server.service.solutions.data.DeviceCredentialsInfo; +import org.thingsboard.server.service.solutions.data.EdgeLinkInfo; +import org.thingsboard.server.service.solutions.data.SolutionInstallContext; +import org.thingsboard.server.service.solutions.data.UserCredentialsInfo; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.thingsboard.server.service.solutions.data.definition.AssetDefinition; +import org.thingsboard.server.service.solutions.data.definition.AssetProfileDefinition; +import org.thingsboard.server.service.solutions.data.definition.DeviceDefinition; +import org.thingsboard.server.service.solutions.data.definition.CustomerDefinition; +import org.thingsboard.server.service.solutions.data.definition.DashboardUserDetailsDefinition; +import org.thingsboard.server.service.solutions.data.definition.CalculatedFieldDefinition; +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; +import org.thingsboard.server.service.solutions.data.definition.UserDefinition; +import org.thingsboard.server.service.solutions.data.definition.DashboardDefinition; +import org.thingsboard.server.service.solutions.data.definition.DeviceProfileDefinition; +import org.thingsboard.server.service.solutions.data.definition.EdgeDefinition; +import org.thingsboard.server.service.solutions.data.definition.RelationDefinition; +import org.thingsboard.server.service.solutions.data.definition.ReferenceableEntityDefinition; +import org.thingsboard.server.service.solutions.data.definition.TenantDefinition; +import org.thingsboard.server.service.solutions.data.emulator.AssetEmulatorLauncher; +import org.thingsboard.server.service.solutions.data.emulator.DeviceEmulatorLauncher; +import org.thingsboard.server.service.solutions.data.names.RandomNameData; +import org.thingsboard.server.service.solutions.data.names.RandomNameUtil; +import org.thingsboard.server.service.solutions.data.solution.SolutionInstallResponse; +import org.thingsboard.server.service.solutions.data.solution.TenantSolutionTemplateInstructions; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ExecutorService; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +@Slf4j +public class DefaultSolutionService implements SolutionService { + + @Value("${ui.solution_templates.docs_base_url:https://thingsboard.io/docs}") + private String docsBaseUrl; + + @Value("${iot-hub.max-uncompressed-archive-bytes:209715200}") + private long maxUncompressedArchiveBytes; + + @Value("${iot-hub.max-uncompressed-entry-bytes:52428800}") + private long maxUncompressedEntryBytes; + + @Value("${iot-hub.max-archive-entry-count:10000}") + private int maxArchiveEntryCount; + + @Value("${iot-hub.max-install-timeout-ms:60000}") + private long maxInstallTimeoutMs; + + private final RuleChainService ruleChainService; + private final TbRuleChainService tbRuleChainService; + private final DeviceProfileService deviceProfileService; + private final DeviceService deviceService; + private final TbDeviceService tbDeviceService; + private final DeviceCredentialsService deviceCredentialsService; + private final AssetProfileService assetProfileService; + private final AssetService assetService; + private final TbAssetService tbAssetService; + private final CustomerService customerService; + private final UserService userService; + private final DashboardService dashboardService; + private final EdgeService edgeService; + private final TbEdgeService tbEdgeService; + private final TbEntityRelationService relationService; + private final AlarmService alarmService; + private final CalculatedFieldService calculatedFieldService; + private final TbCalculatedFieldService tbCalculatedFieldService; + private final AttributesService attributesService; + private final TimeseriesService tsService; + private final EntityActionService entityActionService; + private final SystemSecurityService systemSecurityService; + private final TbClusterService tbClusterService; + private final PartitionService partitionService; + private final TbQueueProducerProvider tbQueueProducerProvider; + private final TbServiceInfoProvider serviceInfoProvider; + private final TelemetrySubscriptionService tsSubService; + private final BCryptPasswordEncoder passwordEncoder; + private final DeviceConnectivityService deviceConnectivityService; + + private final ExecutorService emulatorExecutor = ThingsBoardExecutors.newWorkStealingPool(10, "solution-emulators-executor"); + + @PreDestroy + private void destroy() { + emulatorExecutor.shutdownNow(); + } + + @Override + public SolutionInstallResponse installSolution(SecurityUser user, TenantId tenantId, byte[] zipData, HttpServletRequest request) throws Exception { + Path tempDir = Files.createTempDirectory("iot-hub-solution-"); + try { + try { + extractZip(zipData, tempDir); + } catch (Throwable e) { + log.error("[{}] Failed to extract solution template zip", tenantId, e); + deleteDirectory(tempDir); + TenantSolutionTemplateInstructions instructions = new TenantSolutionTemplateInstructions(); + instructions.setDetails(e.getMessage()); + return new SolutionInstallResponse(instructions, false, List.of()); + } + + String solutionId = loadSolutionId(tempDir); + if (solutionId == null) { + throw new IllegalArgumentException("Solution template is missing solution.json or its 'title' field"); + } + + SolutionInstallResponse validateResult = validateSolution(tenantId, tempDir); + if (validateResult != null && !validateResult.isSuccess()) { + return validateResult; + } + return doInstallSolution(user, tenantId, solutionId, tempDir, request); + } finally { + deleteDirectory(tempDir); + } + } + + @Override + public void deleteSolution(TenantId tenantId, SolutionTemplateInstalledItemDescriptor descriptor, SecurityUser user) throws ThingsboardException { + try { + if (descriptor.getCreatedEntityIds() != null && !descriptor.getCreatedEntityIds().isEmpty()) { + List entityIds = new ArrayList<>(descriptor.getCreatedEntityIds()); + // Delete in the descending order of creation to avoid dependency issues. + Collections.reverse(entityIds); + for (EntityId entityId : entityIds) { + try { + deleteEntity(tenantId, entityId, user); + } catch (RuntimeException e) { + log.error("[{}] Failed to delete the entity: {}", tenantId, entityId, e); + } + } + } + List tsKeys = descriptor.getTenantTelemetryKeys(); + if (tsKeys != null && !tsKeys.isEmpty()) { + List queries = new ArrayList<>(tsKeys.size()); + for (String tsKey : tsKeys) { + queries.add(new BaseDeleteTsKvQuery(tsKey, 0, System.currentTimeMillis(), false)); + } + tsService.remove(tenantId, tenantId, queries).get(); + } + List attrKeys = descriptor.getTenantAttributeKeys(); + if (attrKeys != null && !attrKeys.isEmpty()) { + attributesService.removeAll(tenantId, tenantId, AttributeScope.SERVER_SCOPE, attrKeys).get(); + } + } catch (Exception e) { + log.error("[{}] Failed to delete the solution", tenantId, e); + throw new ThingsboardException(e, ThingsboardErrorCode.GENERAL); + } + } + + private SolutionInstallResponse validateSolution(TenantId tenantId, Path tempDir) { + Map> alreadyExistingEntities = new HashMap<>(); + + //TODO: check other entities. + + List ruleChains = loadListOfEntitiesIfFileExists(tempDir, "rule_chains.json", new TypeReference<>() { + }); + if (!ruleChains.isEmpty()) { + for (ReferenceableEntityDefinition ruleChain : ruleChains) { + List savedRuleChains = ruleChainService.findTenantRuleChainsByType(tenantId, RuleChainType.CORE, new PageLink(1, 0, ruleChain.getName())).getData(); + if (savedRuleChains != null && !savedRuleChains.isEmpty()) { + alreadyExistingEntities.computeIfAbsent(EntityType.RULE_CHAIN, key -> new ArrayList<>()).add(savedRuleChains.get(0)); + } + } + } + + List deviceProfiles = loadListOfEntitiesIfFileExists(tempDir, "device_profiles.json", new TypeReference<>() { + }); + deviceProfiles.addAll(loadListOfEntitiesFromDirectory(tempDir, "device_profiles", DeviceProfileDefinition.class)); + // Validate that entities with such name does not exist entities + if (!deviceProfiles.isEmpty()) { + for (DeviceProfile deviceProfile : deviceProfiles) { + DeviceProfile savedProfile = deviceProfileService.findDeviceProfileByName(tenantId, deviceProfile.getName()); + if (savedProfile != null) { + alreadyExistingEntities.computeIfAbsent(EntityType.DEVICE_PROFILE, key -> new ArrayList<>()).add(savedProfile); + } + } + } + + List assetProfiles = loadListOfEntitiesIfFileExists(tempDir, "asset_profiles.json", new TypeReference<>() { + }); + assetProfiles.addAll(loadListOfEntitiesFromDirectory(tempDir, "asset_profiles", AssetProfileDefinition.class)); + // Validate that entities with such name does not exist entities + if (!assetProfiles.isEmpty()) { + for (AssetProfile assetProfile : assetProfiles) { + AssetProfile savedProfile = assetProfileService.findAssetProfileByName(tenantId, assetProfile.getName()); + if (savedProfile != null) { + alreadyExistingEntities.computeIfAbsent(EntityType.ASSET_PROFILE, key -> new ArrayList<>()).add(savedProfile); + } + } + } + + List dashboards = loadListOfEntitiesIfFileExists(tempDir, "dashboards.json", new TypeReference<>() { + }); + if (!dashboards.isEmpty()) { + for (DashboardDefinition dashboard : dashboards) { + List savedDashboards = dashboardService.findDashboardsByTenantId(tenantId, new PageLink(1, 0, dashboard.getName())).getData(); + if (savedDashboards != null && !savedDashboards.isEmpty()) { + alreadyExistingEntities.computeIfAbsent(EntityType.DASHBOARD, key -> new ArrayList<>()).add(savedDashboards.get(0)); + } + } + } + if (!alreadyExistingEntities.isEmpty()) { + SolutionInstallResponse solutionInstallResponse = new SolutionInstallResponse(); + StringBuilder detailsBuilder = new StringBuilder(); + detailsBuilder.append("## Validation failed").append(System.lineSeparator()).append(System.lineSeparator()); + alreadyExistingEntities.forEach((type, list) -> detailsBuilder.append("The following **").append(getTypeLabel(type)).append("** entities already exist: ") + .append(list.stream().map(HasName::getName).map(name -> "'" + name + "'").collect(Collectors.joining(","))).append(";") + .append(System.lineSeparator()).append(System.lineSeparator())); + solutionInstallResponse.setSuccess(false); + solutionInstallResponse.setDetails(detailsBuilder.toString()); + return solutionInstallResponse; + } else { + return null; + } + } + + private SolutionInstallResponse doInstallSolution(User user, TenantId tenantId, String solutionId, Path tempDir, HttpServletRequest request) { + SolutionInstallContext ctx = new SolutionInstallContext(tenantId, solutionId, tempDir, user, new TenantSolutionTemplateInstructions()); + + try { + + registerEmulatorsAndComputeOldestTelemetryTs(ctx); + + provisionTenantDetails(ctx); + + provisionRuleChains(ctx); + + provisionDeviceProfiles(ctx); + + provisionAssetProfiles(ctx); + + List customers = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "customers.json", new TypeReference<>() {}); + + provisionCustomers(ctx, customers); + + var assets = provisionAssets(ctx); + + var devices = provisionDevices(ctx); + + provisionDashboards(ctx); + + provisionCustomerUsers(ctx, customers); + + provisionRelations(ctx); + + updateRuleChains(ctx); + + provisionEdges(ctx); + + provisionAlarmRules(ctx); + + Set> telemetryLoading = launchEmulators(ctx, devices, assets); + + waitForTelemetryCompletion(telemetryLoading); + + provisionCalculatedFields(ctx); + + ctx.getSolutionInstructions().setDetails(prepareInstructions(ctx, request)); + + List ruleChainDefs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "rule_chains.json", new TypeReference<>() {}); + if (ruleChainDefs.stream().anyMatch(r -> StringUtils.isNotEmpty(r.getUpdate()))) { + long timeout = Math.min(loadInstallTimeoutMs(ctx.getTempDir()), maxInstallTimeoutMs); + if (timeout > 0) { + Thread.sleep(timeout); + } + finalUpdateRuleChains(ctx); + } + + log.info("[{}] Solution template installed, created {} entities", tenantId, ctx.getCreatedEntitiesList().size()); + + return new SolutionInstallResponse( + new TenantSolutionTemplateInstructions(ctx.getSolutionInstructions()), + true, + ctx.getCreatedEntitiesList(), + loadTenantTelemetryKeys(ctx.getTempDir()), + loadTenantAttributeKeys(ctx.getTempDir()) + ); + } catch (Throwable e) { + log.error("[{}][{}] Failed to install solution template", tenantId, solutionId, e); + rollback(tenantId, solutionId, ctx, e); + return new SolutionInstallResponse( + new TenantSolutionTemplateInstructions(ctx.getSolutionInstructions()), + false, + ctx.getCreatedEntitiesList() + ); + } + } + + private void waitForTelemetryCompletion(Set> futures) throws InterruptedException { + CompletableFuture all = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); + try { + all.get(); + Thread.sleep(futures.size() * 100L); + } catch (ExecutionException e) { + throw new RuntimeException("Telemetry processing failed", e.getCause()); + } + } + + private void rollback(TenantId tenantId, String solutionId, SolutionInstallContext ctx, Throwable e) { + List createdEntities = new ArrayList<>(ctx.getCreatedEntitiesList()); + Collections.reverse(createdEntities); + for (EntityId entityId : createdEntities) { + try { + deleteEntity(tenantId, entityId, ctx.getUser()); + } catch (RuntimeException re) { + log.error("[{}][{}] Failed to delete the entity: {}", tenantId, solutionId, entityId, re); + } + } + ctx.getCreatedEntitiesList().clear(); + ctx.getSolutionInstructions().setDetails(e.getMessage()); + } + + private String prepareInstructions(SolutionInstallContext ctx, HttpServletRequest request) { + + Path instructionsFile = ctx.getTempDir().resolve("instructions.md"); + if (!Files.exists(instructionsFile)) { + return null; + } + String template; + try { + template = Files.readString(instructionsFile); + } catch (IOException e) { + log.warn("[{}] Failed to read instructions.md", ctx.getTenantId(), e); + return null; + } + + String baseUrl = systemSecurityService.getBaseUrl(ctx.getTenantId(), null, request); + + // Inject edge instructions first, then run the full replacement logic on the combined string + if (template.contains("${edge_instructions}")) { + if (ctx.getCreatedEdges().isEmpty()) { + template = template.replace("${edge_instructions}", ""); + } else { + Path edgeFile = ctx.getTempDir().resolve("edge_instructions.md"); + String edgeTemplate = Files.exists(edgeFile) ? readFileContent(edgeFile) : ""; + template = template.replace("${edge_instructions}", edgeTemplate); + } + } + + template = template.replace("${DOCS_BASE_URL}", docsBaseUrl); + template = template.replace("${BASE_URL}", baseUrl); + + TenantSolutionTemplateInstructions solutionInstructions = ctx.getSolutionInstructions(); + + if (solutionInstructions.getDashboardId() != null) { + template = template.replace("${MAIN_DASHBOARD_URL}", + getDashboardLink(solutionInstructions, solutionInstructions.getDashboardId(), false)); + if (solutionInstructions.isMainDashboardPublic()) { + template = template.replace("${MAIN_DASHBOARD_PUBLIC_URL}", + getDashboardLink(solutionInstructions, solutionInstructions.getDashboardId(), true)); + } + } + + for (DashboardLinkInfo dashboardLinkInfo : ctx.getDashboardLinks()) { + template = template.replace("${" + dashboardLinkInfo.getName() + "DASHBOARD_URL}", + getDashboardLink(solutionInstructions, dashboardLinkInfo.getDashboardId(), false)); + if (dashboardLinkInfo.isPublic()) { + template = template.replace("${" + dashboardLinkInfo.getName() + "DASHBOARD_PUBLIC_URL}", + getDashboardLink(solutionInstructions, dashboardLinkInfo.getDashboardId(), true)); + } + } + + if (template.contains("${GATEWAYS_URL}")) { + template = template.replace("${GATEWAYS_URL}", "/gateways"); + } + + // Device list and credentials + StringBuilder devList = new StringBuilder(); + devList.append("| Device name | Access token | Owner |"); + devList.append(System.lineSeparator()); + devList.append("| :--- | :--- | :--- |"); + devList.append(System.lineSeparator()); + + for (DeviceCredentialsInfo credentialsInfo : ctx.getCreatedDevices().values()) { + devList.append("|").append(credentialsInfo.getName()) + .append("|").append(credentialsInfo.getCredentials().getCredentialsId()).append("{:copy-code}") + .append("|").append(credentialsInfo.getCustomerName() != null ? credentialsInfo.getCustomerName() : "Tenant"); + devList.append(System.lineSeparator()); + + template = template.replace("${" + credentialsInfo.getName() + "ACCESS_TOKEN}", credentialsInfo.getCredentials().getCredentialsId()); + + if (credentialsInfo.isGateway()) { + template = template.replace("${DOCKER_CONFIG}", + prepareDockerComposeFile(ctx.getTenantId(), ctx.getSolutionId(), baseUrl, credentialsInfo.getCredentials().getDeviceId())); + } + } + + template = template.replace("${device_list_and_credentials}", devList.toString()); + + // User list (without user group column) + StringBuilder userList = new StringBuilder(); + userList.append("| Name | Login | Password | Customer name |"); + userList.append(System.lineSeparator()); + userList.append("| :--- | :--- | :--- | :--- |"); + userList.append(System.lineSeparator()); + + for (UserCredentialsInfo credentialsInfo : ctx.getCreatedUsers().values()) { + userList.append("|").append(credentialsInfo.getName()) + .append("|").append(credentialsInfo.getLogin()).append("{:copy-code}") + .append("|").append(credentialsInfo.getPassword()).append("{:copy-code}") + .append("|").append(credentialsInfo.getCustomerName() != null ? credentialsInfo.getCustomerName() : ""); + userList.append(System.lineSeparator()); + } + + template = template.replace("${user_list}", userList.toString()); + + // Edge detail URLs + for (Map.Entry edgeLinkInfoEntry : ctx.getCreatedEdges().entrySet()) { + EdgeLinkInfo edgeLinkInfo = edgeLinkInfoEntry.getValue(); + StringBuilder edgeDetailsUrl = new StringBuilder(); + if (EntityType.CUSTOMER.equals(edgeLinkInfo.getOwnerId().getEntityType())) { + edgeDetailsUrl.append("/customers/").append(edgeLinkInfo.getOwnerId().getId()); + edgeDetailsUrl.append("/edgeInstances/").append(edgeLinkInfo.getEdgeId().getId()); + } else { + edgeDetailsUrl.append("/edgeManagement/instances/").append(edgeLinkInfo.getEdgeId().getId()); + } + String edgeName = edgeLinkInfoEntry.getKey(); + String edgeDetailsPlaceholder = "${" + edgeName + "EDGE_DETAILS_URL}"; + template = template.replace(edgeDetailsPlaceholder, edgeDetailsUrl.toString()); + } + + template = replaceAlarmRules(ctx, template); + template = replaceCalculatedFields(ctx, template); + template = replaceCreatedEntities(ctx, template); + + return template; + } + + private static String replaceAlarmRules(SolutionInstallContext ctx, String template) { + StringBuilder alarmRules = new StringBuilder(); + + alarmRules.append("| Entity Profile Name | Alarm Type | Severities |").append(System.lineSeparator()); + alarmRules.append("| :--- | :--- | :--- |").append(System.lineSeparator()); + + ctx.getCreatedAlarmRules().entrySet().stream() + .sorted(Map.Entry.comparingByValue(Comparator.comparing(CreatedAlarmRuleInfo::entityName, String.CASE_INSENSITIVE_ORDER) + .thenComparing(CreatedAlarmRuleInfo::alarmType, String.CASE_INSENSITIVE_ORDER))) + .forEach(entry -> { + UUID key = entry.getKey(); + var alarmRuleInfo = entry.getValue(); + + String alarmType = alarmRuleInfo.alarmType(); + String link = alarmRuleInfo.getCfPageLink(key); + + String alarmTypeWithLink = "" + alarmType + ""; + + String profileName = alarmRuleInfo.entityId() != null ? + "" + alarmRuleInfo.entityName() + "" + : alarmRuleInfo.entityName(); + + alarmRules.append("|") + .append(profileName).append("|") + .append(alarmTypeWithLink).append("|") + .append(alarmRuleInfo.severities()).append("|") + .append(System.lineSeparator()); + }); + + return template.replace("${alarm_rules}", alarmRules.toString()); + } + + private static String replaceCalculatedFields(SolutionInstallContext ctx, String template) { + StringBuilder calculatedFields = new StringBuilder(); + + calculatedFields.append("| Entity Profile Name | Field Name | Field Type |").append(System.lineSeparator()); + calculatedFields.append("| :--- | :--- | :--- |").append(System.lineSeparator()); + + ctx.getCreatedCalculatedFields().entrySet().stream() + .sorted(Map.Entry.comparingByValue( + Comparator.comparing(CreatedCalculatedFieldInfo::entityName, String.CASE_INSENSITIVE_ORDER) + .thenComparing(CreatedCalculatedFieldInfo::name, String.CASE_INSENSITIVE_ORDER) + )) + .forEach(entry -> { + UUID key = entry.getKey(); + var cfInfo = entry.getValue(); + + String cfTitle = cfInfo.name(); + String link = cfInfo.getCfPageLink(key); + + String cfTitleWithLink = "" + cfTitle + ""; + + String profileName = cfInfo.entityId() != null ? + "" + cfInfo.entityName() + "" + : cfInfo.entityName(); + + calculatedFields.append("|") + .append(profileName).append("|") + .append(cfTitleWithLink).append("|") + .append(cfInfo.type()).append("|") + .append(System.lineSeparator()); + }); + + return template.replace("${calculated_fields}", calculatedFields.toString()); + } + + private static String replaceCreatedEntities(SolutionInstallContext ctx, String template) { + StringBuilder entityList = new StringBuilder(); + + entityList.append("| Name | Type | Owner |").append(System.lineSeparator()); + entityList.append("| :--- | :--- | :--- |").append(System.lineSeparator()); + + for (Map.Entry entry : ctx.getCreatedEntities().entrySet()) { + UUID key = entry.getKey(); + var entityInfo = entry.getValue(); + String link = entityInfo.getEntityPageLink(key); + String entityName = entityInfo.getName(); + + String name = link != null ? + "" + entityName + "" + : entityName; + + entityList.append("|") + .append(name).append("|") + .append(entityInfo.getType().getNormalName()).append("|") + .append(entityInfo.getOwner()).append("|") + .append(System.lineSeparator()); + } + return template.replace("${all_entities}", entityList.toString()); + } + + private String getDashboardLink(TenantSolutionTemplateInstructions solutionInstructions, DashboardId dashboardId, boolean isPublic) { + if (isPublic && solutionInstructions.getPublicId() != null) { + return "/dashboard/" + dashboardId.getId() + "?publicId=" + solutionInstructions.getPublicId(); + } + return "/dashboards/" + dashboardId.getId(); + } + + private String prepareDockerComposeFile(TenantId tenantId, String solutionId, String baseUrl, DeviceId deviceId) { + Device device = new Device(deviceId); + device.setTenantId(tenantId); + String containerName = "tb-gateway-" + solutionId.replace('_', '-'); + DockerComposeParams params = new DockerComposeParams(false, containerName, false, true, false, false); + try (InputStream inputStream = deviceConnectivityService.createGatewayDockerComposeFile(baseUrl, device, params).getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)) + ) { + return reader.lines().collect(Collectors.joining("\n")); + } catch (Exception e) { + throw new RuntimeException("Failed to read or process the docker-compose.yml file.", e); + } + } + + private void provisionRuleChains(SolutionInstallContext ctx) { + List ruleChainDefs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "rule_chains.json", new TypeReference<>() {}); + for (ReferenceableEntityDefinition entityDef : ruleChainDefs) { + Path ruleChainPath = ctx.getTempDir().resolve("rule_chains").resolve(entityDef.getFile()); + if (!Files.exists(ruleChainPath)) { + log.warn("[{}] Rule chain file not found: {}", ctx.getTenantId(), entityDef.getFile()); + continue; + } + JsonNode ruleChainJson = replaceIds(ctx, JacksonUtil.toJsonNode(ruleChainPath)); + + RuleChain ruleChain = JacksonUtil.treeToValue(ruleChainJson.get("ruleChain"), RuleChain.class); + ruleChain.setId(null); + ruleChain.setTenantId(ctx.getTenantId()); + String metadataStr = JacksonUtil.toString(ruleChainJson.get("metadata")); + RuleChainMetaData metadata = JacksonUtil.treeToValue(JacksonUtil.toJsonNode(metadataStr), RuleChainMetaData.class); + + RuleChain savedRuleChain = ruleChainService.saveRuleChain(ruleChain); + metadata.setRuleChainId(savedRuleChain.getId()); + metadata.setVersion(savedRuleChain.getVersion()); + ruleChainService.saveRuleChainMetaData(ctx.getTenantId(), metadata, tbRuleChainService::updateRuleNodeConfiguration); + if (ruleChain.isRoot()) { + ruleChainService.setRootRuleChain(ctx.getTenantId(), savedRuleChain.getId()); + } + + ctx.register(entityDef.getJsonId(), savedRuleChain); + log.debug("[{}] Rule chain provisioned: {}", ctx.getTenantId(), savedRuleChain.getName()); + } + } + + private void updateRuleChains(SolutionInstallContext ctx) { + List ruleChainDefs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "rule_chains.json", new TypeReference<>() {}); + for (ReferenceableEntityDefinition entityDef : ruleChainDefs) { + Path ruleChainPath = ctx.getTempDir().resolve("rule_chains").resolve(entityDef.getFile()); + if (!Files.exists(ruleChainPath)) { + continue; + } + String realId = ctx.getRealIds().get(entityDef.getJsonId()); + if (realId == null) { + continue; + } + RuleChainId ruleChainId = new RuleChainId(UUID.fromString(realId)); + RuleChain savedRuleChain = ruleChainService.findRuleChainById(ctx.getTenantId(), ruleChainId); + if (savedRuleChain == null) { + continue; + } + JsonNode ruleChainJson = JacksonUtil.toJsonNode(ruleChainPath); + String metadataStr = JacksonUtil.toString(ruleChainJson.get("metadata")); + String oldMetadataStr = metadataStr; + for (var entry : ctx.getRealIds().entrySet()) { + metadataStr = metadataStr.replace(entry.getKey(), entry.getValue()); + } + if (metadataStr.equals(oldMetadataStr)) { + continue; + } + RuleChainMetaData metadata = JacksonUtil.treeToValue(JacksonUtil.toJsonNode(metadataStr), RuleChainMetaData.class); + metadata.setRuleChainId(ruleChainId); + metadata.setVersion(savedRuleChain.getVersion()); + ruleChainService.saveRuleChainMetaData(ctx.getTenantId(), metadata, tbRuleChainService::updateRuleNodeConfiguration); + } + } + + private void finalUpdateRuleChains(SolutionInstallContext ctx) { + List ruleChains = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "rule_chains.json", new TypeReference<>() {}); + for (ReferenceableEntityDefinition entityDefinition : ruleChains) { + if (StringUtils.isEmpty(entityDefinition.getUpdate())) { + continue; + } + Path ruleChainPath = ctx.getTempDir().resolve("rule_chains").resolve(entityDefinition.getUpdate()); + JsonNode ruleChainJson = JacksonUtil.toJsonNode(ruleChainPath); + RuleChain ruleChain = JacksonUtil.treeToValue(ruleChainJson.get("ruleChain"), RuleChain.class); + ruleChain.setTenantId(ctx.getTenantId()); + String metadataStr = JacksonUtil.toString(ruleChainJson.get("metadata")); + for (var entry : ctx.getRealIds().entrySet()) { + metadataStr = metadataStr.replace(entry.getKey(), entry.getValue()); + } + RuleChainMetaData ruleChainMetaData = JacksonUtil.treeToValue(JacksonUtil.toJsonNode(metadataStr), RuleChainMetaData.class); + + String realRuleChainId = ctx.getRealIds().get(entityDefinition.getJsonId()); + if (StringUtils.isEmpty(realRuleChainId)) { + continue; + } + RuleChainId ruleChainId = new RuleChainId(UUID.fromString(realRuleChainId)); + RuleChain savedRuleChain = ruleChainService.findRuleChainById(ctx.getTenantId(), ruleChainId); + ruleChainMetaData.setRuleChainId(savedRuleChain.getId()); + ruleChainMetaData.setVersion(savedRuleChain.getVersion()); + ruleChainService.saveRuleChainMetaData(ctx.getTenantId(), ruleChainMetaData, tbRuleChainService::updateRuleNodeConfiguration); + } + } + + private void provisionDeviceProfiles(SolutionInstallContext ctx) { + List deviceProfiles = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "device_profiles.json", new TypeReference<>() {}); + deviceProfiles.addAll(loadListOfEntitiesFromDirectory(ctx.getTempDir(), "device_profiles", DeviceProfileDefinition.class)); + deviceProfiles.forEach(deviceProfile -> { + deviceProfile.setId(null); + deviceProfile.setCreatedTime(0L); + deviceProfile.setTenantId(ctx.getTenantId()); + if (deviceProfile.getDefaultRuleChainId() != null) { + String newId = ctx.getRealIds().get(deviceProfile.getDefaultRuleChainId().getId().toString()); + if (newId != null) { + deviceProfile.setDefaultRuleChainId(new RuleChainId(UUID.fromString(newId))); + } else { + log.error("[{}] Device profile: {} references non existing rule chain.", ctx.getTenantId(), deviceProfile.getName()); + throw new RuntimeException("Device profile: " + deviceProfile.getName() + " references non existing rule chain."); + } + } + if (deviceProfile.getDefaultEdgeRuleChainId() != null) { + String newId = ctx.getRealIds().get(deviceProfile.getDefaultEdgeRuleChainId().getId().toString()); + if (StringUtils.isEmpty(newId)) { + deviceProfile.setDefaultEdgeRuleChainId(null); + } else { + deviceProfile.setDefaultEdgeRuleChainId(new RuleChainId(UUID.fromString(newId))); + } + } + if (deviceProfile.getDefaultDashboardId() != null) { + String newId = ctx.getRealIds().get(deviceProfile.getDefaultDashboardId().getId().toString()); + if (newId != null) { + deviceProfile.setDefaultDashboardId(new DashboardId(UUID.fromString(newId))); + } + } + }); + + deviceProfiles.forEach(deviceProfileDefinition -> { + DeviceProfile deviceProfile = new DeviceProfile(deviceProfileDefinition); + deviceProfile = deviceProfileService.saveDeviceProfile(deviceProfile); + ctx.register(deviceProfileDefinition, deviceProfile); + }); + } + + private void provisionAssetProfiles(SolutionInstallContext ctx) { + List assetProfiles = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "asset_profiles.json", new TypeReference<>() {}); + assetProfiles.addAll(loadListOfEntitiesFromDirectory(ctx.getTempDir(), "asset_profiles", AssetProfileDefinition.class)); + assetProfiles.forEach(assetProfile -> { + assetProfile.setId(null); + assetProfile.setCreatedTime(0L); + assetProfile.setTenantId(ctx.getTenantId()); + if (assetProfile.getDefaultRuleChainId() != null) { + String newId = ctx.getRealIds().get(assetProfile.getDefaultRuleChainId().getId().toString()); + if (newId != null) { + assetProfile.setDefaultRuleChainId(new RuleChainId(UUID.fromString(newId))); + } else { + log.error("[{}] Asset profile: {} references non existing rule chain.", ctx.getTenantId(), assetProfile.getName()); + throw new RuntimeException("Asset profile: " + assetProfile.getName() + " references non existing rule chain."); + } + } + if (assetProfile.getDefaultEdgeRuleChainId() != null) { + String newId = ctx.getRealIds().get(assetProfile.getDefaultEdgeRuleChainId().getId().toString()); + if (StringUtils.isEmpty(newId)) { + assetProfile.setDefaultEdgeRuleChainId(null); + } else { + assetProfile.setDefaultEdgeRuleChainId(new RuleChainId(UUID.fromString(newId))); + } + } + }); + + assetProfiles.forEach(assetProfileDefinition -> { + AssetProfile assetProfile = new AssetProfile(assetProfileDefinition); + assetProfile = assetProfileService.saveAssetProfile(assetProfile); + ctx.register(assetProfileDefinition, assetProfile); + }); + } + + private CustomerId getPublicCustomerId(SolutionInstallContext ctx) { + CustomerId publicId = ctx.getSolutionInstructions().getPublicId(); + if (publicId != null) { + return publicId; + } + Customer publicCustomer = customerService.findOrCreatePublicCustomer(ctx.getTenantId()); + ctx.getSolutionInstructions().setPublicId(publicCustomer.getId()); + return publicCustomer.getId(); + } + + private void provisionDashboards(SolutionInstallContext ctx) { + List dashboardDefs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "dashboards.json", new TypeReference<>() {}); + for (DashboardDefinition entityDef : dashboardDefs) { + CustomerId customerId = entityDef.isMakePublic() ? getPublicCustomerId(ctx) : ctx.getIdFromMap(EntityType.CUSTOMER, entityDef.getCustomer()); + Path dashboardPath = ctx.getTempDir().resolve("dashboards").resolve(entityDef.getFile()); + if (!Files.exists(dashboardPath)) { + log.warn("[{}] Dashboard file not found: {}", ctx.getTenantId(), entityDef.getFile()); + continue; + } + JsonNode dashboardJson = replaceIds(ctx, JacksonUtil.toJsonNode(dashboardPath)); + Dashboard dashboardTemplate = JacksonUtil.treeToValue(dashboardJson, Dashboard.class); + + Dashboard dashboard = new Dashboard(); + dashboard.setTenantId(ctx.getTenantId()); + dashboard.setTitle(entityDef.getName()); + dashboard.setConfiguration(dashboardTemplate.getConfiguration()); + dashboard.setImage(dashboardTemplate.getImage()); + dashboard.setResources(dashboardTemplate.getResources()); + if (dashboardJson.has("mobileHide") && dashboardJson.get("mobileHide").isBoolean()) { + dashboard.setMobileHide(dashboardJson.get("mobileHide").asBoolean()); + } + if (dashboardJson.has("mobileOrder") && dashboardJson.get("mobileOrder").isInt()) { + dashboard.setMobileOrder(dashboardJson.get("mobileOrder").asInt()); + } + + dashboard = dashboardService.saveDashboard(dashboard); + if (customerId != null) { + dashboardService.assignDashboardToCustomer(ctx.getTenantId(), dashboard.getId(), customerId); + } + ctx.register(entityDef, dashboard); + ctx.putIdToMap(EntityType.DASHBOARD, entityDef.getName(), dashboard.getId()); + + if (entityDef.isMain()) { + ctx.getSolutionInstructions().setDashboardId(dashboard.getId()); + ctx.getSolutionInstructions().setMainDashboardPublic(entityDef.isMakePublic()); + } + ctx.getDashboardLinks().add(new DashboardLinkInfo(dashboard.getTitle(), dashboard.getId(), entityDef.isMakePublic())); + + log.debug("[{}] Dashboard provisioned: {}", ctx.getTenantId(), dashboard.getTitle()); + } + } + + private void provisionRelations(SolutionInstallContext ctx) { + ctx.getRelationDefinitions().forEach((id, relations) -> { + for (RelationDefinition relationDef : relations) { + log.info("[{}] Saving relation: {}", id, relationDef); + EntityRelation entityRelation = new EntityRelation(); + EntityId otherId = resolveRelatedEntityId(relationDef, ctx); + if (EntitySearchDirection.FROM.equals(relationDef.getDirection())) { + entityRelation.setFrom(otherId); + entityRelation.setTo(id); + } else { + entityRelation.setFrom(id); + entityRelation.setTo(otherId); + } + entityRelation.setTypeGroup(RelationTypeGroup.COMMON); + entityRelation.setType(relationDef.getType()); + try { + relationService.save(ctx.getTenantId(), null, entityRelation, null); + } catch (Exception e) { + log.info("[{}] Failed to save relation: {}, cause: {}", id, relationDef, e.getMessage()); + } + } + }); + } + + private EntityId resolveRelatedEntityId(RelationDefinition relationDef, SolutionInstallContext ctx) { + if (relationDef.getEntityType() == EntityType.TENANT) { + return ctx.getTenantId(); + } + return ctx.getIdFromMap(relationDef.getEntityType(), relationDef.getEntityName()); + } + + private Map provisionDevices(SolutionInstallContext ctx) { + Map result = new HashMap<>(); + Set deviceTypeSet = new HashSet<>(); + List devices = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "devices.json", new TypeReference<>() {}); + for (DeviceDefinition entityDef : devices) { + CustomerId customerId = entityDef.isMakePublic() ? getPublicCustomerId(ctx) : ctx.getIdFromMap(EntityType.CUSTOMER, entityDef.getCustomer()); + Device entity = new Device(); + entity.setTenantId(ctx.getTenantId()); + entity.setName(entityDef.getName()); + entity.setLabel(entityDef.getLabel()); + ensureDeviceProfileExists(ctx, deviceTypeSet, entityDef); + entity.setType(entityDef.getType()); + entity.setCustomerId(customerId); + entity.setAdditionalInfo(entityDef.getAdditionalInfo()); + entity = deviceService.saveDevice(entity); + entityActionService.logEntityAction(ctx.getUser(), entity.getId(), entity, customerId, ActionType.ADDED, null); + ctx.register(entityDef, entity); + log.info("[{}] Saved device: {}", entity.getId(), entity); + DeviceId entityId = entity.getId(); + ctx.putIdToMap(entityDef, entityId); + saveServerSideAttributes(ctx, entityId, entityDef.getAttributes()); + saveSharedAttributes(ctx, entityId, entityDef.getSharedAttributes()); + ctx.put(entityId, entityDef.getRelations()); + + DeviceCredentialsInfo deviceCredentialsInfo = new DeviceCredentialsInfo(); + deviceCredentialsInfo.setName(entity.getName()); + deviceCredentialsInfo.setType(entity.getType()); + deviceCredentialsInfo.setCustomerName(entityDef.getCustomer()); + deviceCredentialsInfo.setCredentials(deviceCredentialsService.findDeviceCredentialsByDeviceId(ctx.getTenantId(), entityId)); + JsonNode additionalInfo = entity.getAdditionalInfo(); + boolean isGateway = additionalInfo != null && additionalInfo.hasNonNull("gateway") && additionalInfo.get("gateway").asBoolean(); + deviceCredentialsInfo.setGateway(isGateway); + ctx.addDeviceCredentials(deviceCredentialsInfo); + + result.put(entity, entityDef); + } + return result; + } + + private void ensureDeviceProfileExists(SolutionInstallContext ctx, Set deviceTypeSet, DeviceDefinition entityDef) { + if (!deviceTypeSet.contains(entityDef.getType())) { + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByName(ctx.getTenantId(), entityDef.getType()); + if (deviceProfile == null) { + DeviceProfile created = deviceProfileService.findOrCreateDeviceProfile(ctx.getTenantId(), entityDef.getType()); + ctx.register(created.getId()); + log.info("Saved device profile: {}", created.getId()); + } + deviceTypeSet.add(entityDef.getType()); + } + } + + private void registerEmulatorsAndComputeOldestTelemetryTs(SolutionInstallContext ctx) { + List emulatorDefinitions = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "device_emulators.json", new TypeReference<>() { + }); + Map deviceEmulators = emulatorDefinitions.stream().collect(Collectors.toMap(EmulatorDefinition::getName, Function.identity())); + emulatorDefinitions.stream().filter(ed -> StringUtils.isNotEmpty(ed.getExtendz())) + .forEach(ed -> { + EmulatorDefinition parent = deviceEmulators.get(ed.getExtendz()); + if (parent != null) { + ed.enrich(parent); + } + }); + Map assetEmulators = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "asset_emulators.json", new TypeReference>() { + }).stream().collect(Collectors.toMap(EmulatorDefinition::getName, Function.identity())); + + ctx.setDeviceEmulators(deviceEmulators); + ctx.setAssetEmulators(assetEmulators); + + long solutionInstallTs = ctx.getInstallTs(); + long oldestDeviceEmulatorsTs = deviceEmulators.values().stream() + .mapToLong(value -> value.getOldestTs(solutionInstallTs)) + .min().orElse(solutionInstallTs); + long oldestAssetEmulatorsTs = assetEmulators.values().stream() + .mapToLong(value -> value.getOldestTs(solutionInstallTs)) + .min().orElse(solutionInstallTs); + long solutionOldestTs = Math.min(oldestDeviceEmulatorsTs, oldestAssetEmulatorsTs); + + ctx.setOldestTelemetryTs(solutionOldestTs); + } + + private Set> launchEmulators(SolutionInstallContext ctx, Map devicesMap, Map assets) throws Exception { + Set> results = new HashSet<>(); + + for (var entry : devicesMap.entrySet().stream().filter(e -> StringUtils.isNotBlank(e.getValue().getEmulator())).collect(Collectors.toSet())) { + results.add(DeviceEmulatorLauncher.builder() + .entity(entry.getKey()) + .emulatorDefinition(ctx.getDeviceEmulators().get(entry.getValue().getEmulator())) + .oldTelemetryExecutor(emulatorExecutor) + .tbClusterService(tbClusterService) + .partitionService(partitionService) + .tbQueueProducerProvider(tbQueueProducerProvider) + .serviceInfoProvider(serviceInfoProvider) + .tsSubService(tsSubService) + .build().launch()); + } + + for (var entry : assets.entrySet().stream().filter(e -> StringUtils.isNotBlank(e.getValue().getEmulator())).collect(Collectors.toSet())) { + results.add(AssetEmulatorLauncher.builder() + .entity(entry.getKey()) + .emulatorDefinition(ctx.getAssetEmulators().get(entry.getValue().getEmulator())) + .oldTelemetryExecutor(emulatorExecutor) + .tbClusterService(tbClusterService) + .partitionService(partitionService) + .tbQueueProducerProvider(tbQueueProducerProvider) + .serviceInfoProvider(serviceInfoProvider) + .tsSubService(tsSubService) + .build().launch()); + } + + return results; + } + + private void provisionTenantDetails(SolutionInstallContext ctx) { + TenantDefinition tenant = loadEntityIfFileExists(ctx.getTempDir(), "tenant.json", TenantDefinition.class); + if (tenant != null) { + saveServerSideAttributes(ctx, ctx.getTenantId(), tenant.getAttributes()); + ctx.put(ctx.getTenantId(), tenant.getRelations()); + } + } + + private Map provisionAssets(SolutionInstallContext ctx) { + Map result = new HashMap<>(); + Set assetTypeSet = new HashSet<>(); + List assets = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "assets.json", new TypeReference<>() {}); + for (AssetDefinition entityDef : assets) { + Asset entity = new Asset(); + entity.setTenantId(ctx.getTenantId()); + entity.setName(entityDef.getName()); + entity.setLabel(entityDef.getLabel()); + entity.setType(entityDef.getType()); + if (entityDef.isMakePublic()) { + entity.setCustomerId(getPublicCustomerId(ctx)); + } else { + entity.setCustomerId(ctx.getIdFromMap(EntityType.CUSTOMER, entityDef.getCustomer())); + } + ensureAssetProfileExists(ctx, assetTypeSet, entityDef); + entity = assetService.saveAsset(entity); + ctx.register(entityDef, entity); + log.info("[{}] Saved asset: {}", entity.getId(), entity); + AssetId entityId = entity.getId(); + ctx.putIdToMap(entityDef, entityId); + saveServerSideAttributes(ctx, entityId, entityDef.getAttributes()); + ctx.put(entityId, entityDef.getRelations()); + result.put(entity, entityDef); + } + return result; + } + + private void ensureAssetProfileExists(SolutionInstallContext ctx, Set assetTypeSet, AssetDefinition entityDef) { + if (!assetTypeSet.contains(entityDef.getType())) { + AssetProfile assetProfile = assetProfileService.findAssetProfileByName(ctx.getTenantId(), entityDef.getType()); + if (assetProfile == null) { + AssetProfile created = assetProfileService.findOrCreateAssetProfile(ctx.getTenantId(), entityDef.getType()); + ctx.register(created.getId()); + log.info("Saved asset profile: {}", created.getId()); + } + assetTypeSet.add(entityDef.getType()); + } + } + + private void provisionCustomers(SolutionInstallContext ctx, List customers) { + for (CustomerDefinition entityDef : customers) { + entityDef.setRandomNameData(generateRandomName(ctx)); + Customer customer = new Customer(); + customer.setTenantId(ctx.getTenantId()); + customer.setTitle(randomize(entityDef.getName(), entityDef.getRandomNameData())); + customer.setEmail(randomize(entityDef.getEmail(), entityDef.getRandomNameData())); + customer.setCountry(entityDef.getCountry()); + customer.setCity(entityDef.getCity()); + customer.setState(entityDef.getState()); + customer.setZip(entityDef.getZip()); + customer.setAddress(entityDef.getAddress()); + customer = customerService.saveCustomer(customer); + log.info("[{}] Saved customer: {}", customer.getId(), customer); + ctx.register(entityDef, customer); + CustomerId entityId = customer.getId(); + ctx.putIdToMap(entityDef, entityId); + saveServerSideAttributes(ctx, entityId, entityDef.getAttributes(), entityDef.getRandomNameData()); + ctx.put(entityId, entityDef.getRelations()); + entityDef.setName(customer.getName()); + } + } + + private void provisionCustomerUsers(SolutionInstallContext ctx, List customers) { + for (CustomerDefinition entityDef : customers) { + Customer customer = customerService.findCustomerByTenantIdAndTitle(ctx.getTenantId(), entityDef.getName()).get(); + for (UserDefinition uDef : entityDef.getUsers()) { + String originalName = uDef.getName(); + User user = createUser(ctx, customer, uDef, entityDef); + + UserCredentials credentials = userService.findUserCredentialsByUserId(ctx.getTenantId(), user.getId()); + credentials.setEnabled(true); + credentials.setActivateToken(null); + credentials.setPassword(passwordEncoder.encode(uDef.getPassword())); + userService.saveUserCredentials(ctx.getTenantId(), credentials); + + DashboardUserDetailsDefinition dd = uDef.getDashboard(); + if (dd != null) { + DashboardId dashboardId = ctx.getIdFromMap(EntityType.DASHBOARD, dd.getName()); + ObjectNode additionalInfo = JacksonUtil.newObjectNode(); + additionalInfo.put("defaultDashboardId", dashboardId.getId().toString()); + additionalInfo.put("defaultDashboardFullscreen", dd.isFullScreen()); + user.setAdditionalInfo(additionalInfo); + userService.saveUser(ctx.getTenantId(), user); + log.info("[{}] Added default dashboard for user {}", customer.getId(), user.getEmail()); + } + + UserCredentialsInfo credentialsInfo = new UserCredentialsInfo(); + credentialsInfo.setName(user.getFirstName() + " " + user.getLastName()); + credentialsInfo.setLogin(uDef.getName()); + credentialsInfo.setPassword(uDef.getPassword()); + credentialsInfo.setCustomerName(entityDef.getName()); + ctx.addUserCredentials(credentialsInfo); + ctx.register(entityDef, user); + ctx.put(user.getId(), uDef.getRelations()); + ctx.putIdToMap(EntityType.USER, originalName, user.getId()); + ctx.putIdToMap(EntityType.USER, uDef.getName(), user.getId()); + saveServerSideAttributes(ctx, user.getId(), uDef.getAttributes()); + } + } + } + + private User createUser(SolutionInstallContext ctx, Customer customer, UserDefinition uDef, CustomerDefinition cDef) { + int maxAttempts = 10; + int attempts = 0; + Exception finalE = null; + while (attempts < maxAttempts) { + try { + boolean lastAttempt = maxAttempts == (attempts + 1); + var randomName = lastAttempt ? RandomNameUtil.nextSuperRandom() : RandomNameUtil.next(); + User user = new User(); + if (!StringUtils.isEmpty(uDef.getFirstname())) { + user.setFirstName(randomize(uDef.getFirstname(), randomName, cDef.getRandomNameData())); + } else { + user.setFirstName(randomName.getFirstName()); + } + if (!StringUtils.isEmpty(uDef.getLastname())) { + user.setLastName(randomize(uDef.getLastname(), randomName, cDef.getRandomNameData())); + } else { + user.setLastName(randomName.getLastName()); + } + user.setAuthority(Authority.CUSTOMER_USER); + user.setEmail(randomize(uDef.getName(), randomName, cDef.getRandomNameData())); + user.setCustomerId(customer.getId()); + user.setTenantId(ctx.getTenantId()); + log.info("[{}] Saving user: {}", customer.getId(), user); + user = userService.saveUser(ctx.getTenantId(), user); + uDef.setName(user.getEmail()); + return user; + } catch (Exception e) { + finalE = e; + attempts++; + } + } + throw new RuntimeException(finalE); + } + + private void provisionEdges(SolutionInstallContext ctx) throws Exception { + List edges = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "edges.json", new TypeReference<>() {}); + RuleChain edgeTemplateRootRuleChain = ruleChainService.getEdgeTemplateRootRuleChain(ctx.getTenantId()); + for (EdgeDefinition entityDef : edges) { + CustomerId customerId = entityDef.isMakePublic() ? getPublicCustomerId(ctx) : ctx.getIdFromMap(EntityType.CUSTOMER, entityDef.getCustomer()); + Edge entity = new Edge(); + entity.setTenantId(ctx.getTenantId()); + entity.setName(entityDef.getName()); + entity.setLabel(entityDef.getLabel()); + entity.setType(entityDef.getType()); + entity.setCustomerId(customerId); + entity.setRoutingKey(UUID.randomUUID().toString()); + entity.setSecret(StringUtils.randomAlphanumeric(20)); + RuleChainId rootRuleChainId = edgeTemplateRootRuleChain.getId(); + if (StringUtils.isNotBlank(entityDef.getRootRuleChainId())) { + String newId = ctx.getRealIds().get(entityDef.getRootRuleChainId()); + if (newId != null) { + rootRuleChainId = new RuleChainId(UUID.fromString(newId)); + } else { + log.error("[{}] Edge: {} references non existing rule chain.", ctx.getTenantId(), entity.getName()); + throw new RuntimeException("Edge: " + entity.getName() + " references non existing rule chain."); + } + } + entity.setRootRuleChainId(rootRuleChainId); + RuleChain rootRuleChain = ruleChainService.findRuleChainById(ctx.getTenantId(), rootRuleChainId); + entity = tbEdgeService.save(entity, rootRuleChain, ctx.getUser()); + ctx.register(entityDef, entity); + assignRuleChainsToEdge(ctx, entityDef.getRuleChainIds(), entity); + assignAssetsToEdge(ctx, entityDef.getAssetIds(), entity); + assignDevicesToEdge(ctx, entityDef.getDeviceIds(), entity); + assignDashboardsToEdge(ctx, entityDef.getDashboardIds(), entity); + log.info("[{}] Saved edge: {}", entity.getId(), entity); + EdgeId entityId = entity.getId(); + ctx.putIdToMap(entityDef, entityId); + saveServerSideAttributes(ctx, entityId, entityDef.getAttributes()); + ctx.put(entityId, entityDef.getRelations()); + ctx.addEdgeLinkInfo(entity.getName(), new EdgeLinkInfo(entity.getId(), customerId != null ? customerId : ctx.getTenantId())); + } + } + + private void assignRuleChainsToEdge(SolutionInstallContext ctx, List ruleChainIds, Edge entity) { + if (ruleChainIds == null || ruleChainIds.isEmpty()) { + return; + } + for (String strRuleChainId : ruleChainIds) { + String newId = ctx.getRealIds().get(strRuleChainId); + if (newId != null) { + RuleChainId ruleChainId = new RuleChainId(UUID.fromString(newId)); + ruleChainService.assignRuleChainToEdge(ctx.getTenantId(), ruleChainId, entity.getId()); + } else { + log.error("[{}] Edge: {} references non existing edge rule chain.", ctx.getTenantId(), entity.getName()); + throw new RuntimeException("Edge: " + entity.getName() + " references non existing edge rule chain."); + } + } + } + + private void assignAssetsToEdge(SolutionInstallContext ctx, List assetIds, Edge entity) { + if (assetIds == null || assetIds.isEmpty()) { + return; + } + for (String strAssetId : assetIds) { + String newId = ctx.getRealIds().get(strAssetId); + if (newId != null) { + AssetId assetId = new AssetId(UUID.fromString(newId)); + assetService.assignAssetToEdge(ctx.getTenantId(), assetId, entity.getId()); + } else { + log.error("[{}] Edge: {} references non existing asset.", ctx.getTenantId(), entity.getName()); + throw new RuntimeException("Edge: " + entity.getName() + " references non existing asset."); + } + } + } + + private void assignDevicesToEdge(SolutionInstallContext ctx, List deviceIds, Edge entity) { + if (deviceIds == null || deviceIds.isEmpty()) { + return; + } + for (String strDeviceId : deviceIds) { + String newId = ctx.getRealIds().get(strDeviceId); + if (newId != null) { + DeviceId deviceId = new DeviceId(UUID.fromString(newId)); + deviceService.assignDeviceToEdge(ctx.getTenantId(), deviceId, entity.getId()); + } else { + log.error("[{}] Edge: {} references non existing device.", ctx.getTenantId(), entity.getName()); + throw new RuntimeException("Edge: " + entity.getName() + " references non existing device."); + } + } + } + + private void assignDashboardsToEdge(SolutionInstallContext ctx, List dashboardIds, Edge entity) { + if (dashboardIds == null || dashboardIds.isEmpty()) { + return; + } + for (String strDashboardId : dashboardIds) { + String newId = ctx.getRealIds().get(strDashboardId); + if (newId != null) { + DashboardId dashboardId = new DashboardId(UUID.fromString(newId)); + dashboardService.assignDashboardToEdge(ctx.getTenantId(), dashboardId, entity.getId()); + } else { + log.error("[{}] Edge: {} references non existing dashboard.", ctx.getTenantId(), entity.getName()); + throw new RuntimeException("Edge: " + entity.getName() + " references non existing dashboard."); + } + } + } + + private void provisionAlarmRules(SolutionInstallContext ctx) { + List cfs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "alarm_rules.json", new TypeReference<>() {}); + cfs.addAll(loadListOfEntitiesFromDirectory(ctx.getTempDir(), "alarm_rules", CalculatedField.class)); + cfs.forEach(cf -> ctx.register(createCalculatedField(cf, ctx))); + } + + private void provisionCalculatedFields(SolutionInstallContext ctx) { + List cfs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "calculated_fields.json", new TypeReference<>() { + }); + cfs.addAll(loadListOfEntitiesFromDirectory(ctx.getTempDir(), "calculated_fields", CalculatedFieldDefinition.class)); + + List createOnly = new ArrayList<>(); + TreeMap> ordered = new TreeMap<>(); + + for (CalculatedFieldDefinition cf : cfs) { + if (cf.getReprocessingOrder() == null || cf.getReprocessingOrder() < 0) { + createOnly.add(cf); + } else { + ordered.computeIfAbsent(cf.getReprocessingOrder(), integer -> new ArrayList<>()).add(cf); + } + } + + createOnly.forEach(cf -> ctx.register(createCalculatedField(cf, ctx))); + + for (Map.Entry> entry : ordered.entrySet()) { + List cfDefs = entry.getValue(); + for (CalculatedFieldDefinition cfDef : cfDefs) { + CalculatedField calculatedField = createCalculatedField(cfDef, ctx); + ctx.register(calculatedField); + } + } + } + + private CalculatedField createCalculatedField(CalculatedField cf, SolutionInstallContext ctx) { + cf.setId(null); + cf.setCreatedTime(0L); + cf.setTenantId(ctx.getTenantId()); + cf.setDebugSettings(new DebugSettings(true, System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(15))); + + Map realIds = ctx.getRealIds(); + + EntityId entityId = cf.getEntityId(); + if (entityId != null) { + String newEntityId = realIds.get(entityId.getId().toString()); + if (newEntityId != null) { + cf.setEntityId(EntityIdFactory.getByTypeAndUuid(entityId.getEntityType(), newEntityId)); + } else { + log.error("[{}] Calculated field: {} references non existing entity.", ctx.getTenantId(), cf.getName()); + throw new RuntimeException("Calculated field: " + cf.getName() + " references non existing entity."); + } + } + if (cf.getConfiguration() instanceof BaseCalculatedFieldConfiguration baseCfg) { + baseCfg.getArguments().forEach((key, argument) -> { + EntityId refEntityId = argument.getRefEntityId(); + if (refEntityId != null) { + if (refEntityId.getEntityType() == EntityType.TENANT) { + argument.setRefEntityId(ctx.getTenantId()); + } else { + String newId = realIds.get(refEntityId.getId().toString()); + if (newId != null) { + argument.setRefEntityId(EntityIdFactory.getByTypeAndUuid(refEntityId.getEntityType(), newId)); + } else { + log.error("[{}] Calculated field: {} references non existing entity.", ctx.getTenantId(), cf.getName()); + throw new RuntimeException("Calculated field: " + cf.getName() + " references non existing entity."); + } + } + } + }); + } + + CalculatedField calculatedField = new CalculatedField(cf); + return calculatedFieldService.save(calculatedField); + } + + private RandomNameData generateRandomName(SolutionInstallContext ctx) { + int i = 0; + while (i < 10) { + var randomName = RandomNameUtil.next(); + var user = userService.findUserByEmail(ctx.getTenantId(), randomName.getEmail()); + if (user == null) { + return randomName; + } else { + i++; + } + } + String firstName = StringUtils.randomAlphanumeric(5); + String lastName = StringUtils.randomAlphanumeric(5); + return new RandomNameData(firstName, lastName, firstName + "." + lastName + "@thingsboard.io"); + } + + private String randomize(String src, RandomNameData name) { + return randomize(src, name, null); + } + + private String randomize(String src, RandomNameData name, RandomNameData customer) { + if (src == null) { + return null; + } else { + String result = src + .replace("$randomFirstName", name.getFirstName()) + .replace("$randomLastName", name.getLastName()) + .replace("$randomEmail", name.getEmail()); + if (customer != null) { + result = result + .replace("$customerFirstName", customer.getFirstName()) + .replace("$customerLastName", customer.getLastName()) + .replace("$customerEmail", customer.getEmail()); + } + return result.replace("$random", StringUtils.randomAlphanumeric(10).toLowerCase()); + } + } + + private void saveServerSideAttributes(SolutionInstallContext ctx, EntityId entityId, JsonNode attributes) { + saveServerSideAttributes(ctx, entityId, attributes, null); + } + + private void saveServerSideAttributes(SolutionInstallContext ctx, EntityId entityId, JsonNode attributes, RandomNameData randomNameData) { + saveAttributes(ctx, entityId, attributes, randomNameData, AttributeScope.SERVER_SCOPE); + } + + private void saveSharedAttributes(SolutionInstallContext ctx, EntityId entityId, JsonNode attributes) { + if (!EntityType.DEVICE.equals(entityId.getEntityType())) { + throw new IllegalArgumentException(entityId.getEntityType() + " cannot have shared attributes."); + } + saveAttributes(ctx, entityId, attributes, null, AttributeScope.SHARED_SCOPE); + } + + private void saveAttributes(SolutionInstallContext ctx, EntityId entityId, JsonNode attributes, RandomNameData randomNameData, AttributeScope attributeScope) { + if (attributes != null && !attributes.isNull() && !attributes.isEmpty()) { + attributes = prepareAttributes(attributes); + log.info("[{}] Saving attributes: {}", entityId, attributes); + if (randomNameData != null) { + attributes = JacksonUtil.toJsonNode(randomize(JacksonUtil.toString(attributes), randomNameData, null)); + } + attributesService.save(ctx.getTenantId(), entityId, attributeScope, + new ArrayList<>(JsonConverter.convertToAttributes(JsonParser.parseString(JacksonUtil.toString(attributes)), ctx.getOldestTelemetryTs()))); + } + } + + private JsonNode prepareAttributes(JsonNode attributes) { + ObjectNode attributesObj = (ObjectNode) attributes; + attributes.fields().forEachRemaining(entry -> { + JsonNode value = entry.getValue(); + if (value.isTextual() && isTimeExpression(value.asText())) { + value = JacksonUtil.toJsonNode(parseTimeExpression(value.asText())); + } + attributesObj.set(entry.getKey(), value); + }); + return attributesObj; + } + + private boolean isTimeExpression(String text) { + return Pattern.matches("\\$\\{currentTime(?:([+-])(\\d+)([mwdh]|min))?}", text); + } + + private String parseTimeExpression(String timeExpression) { + Matcher matcher = Pattern.compile("\\$\\{currentTime(?:([+-])(\\d+)([mwdh]|min))?}").matcher(timeExpression); + + if (!matcher.matches()) { + return timeExpression; + } + + String operator = matcher.group(1); + String amountStr = matcher.group(2); + String unit = matcher.group(3); + + ZonedDateTime now = ZonedDateTime.now(); + + if (operator != null && amountStr != null && unit != null) { + int amount = Integer.parseInt(amountStr); + now = switch (unit) { + case "m" -> operator.equals("+") ? now.plusMonths(amount) : now.minusMonths(amount); + case "w" -> operator.equals("+") ? now.plusWeeks(amount) : now.minusWeeks(amount); + case "d" -> operator.equals("+") ? now.plusDays(amount) : now.minusDays(amount); + case "h" -> operator.equals("+") ? now.plusHours(amount) : now.minusHours(amount); + case "min" -> operator.equals("+") ? now.plusMinutes(amount) : now.minusMinutes(amount); + default -> now; + }; + } + + return String.valueOf(now.toInstant().toEpochMilli()); + } + + private String getTypeLabel(EntityType type) { + return type.name().toLowerCase().replace('_', ' '); + } + + private T loadEntityIfFileExists(Path tempDir, String fileName, Class clazz) { + Path filePath = tempDir.resolve("entities").resolve(fileName); + if (Files.exists(filePath)) { + return JacksonUtil.readValue(filePath.toFile(), clazz); + } else { + return null; + } + } + + private List loadListOfEntitiesIfFileExists(Path tempDir, String fileName, TypeReference> typeReference) { + Path filePath = tempDir.resolve("entities").resolve(fileName); + if (Files.exists(filePath)) { + try { + return JacksonUtil.readValue(filePath.toFile(), typeReference); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid json file " + fileName + " data structure", e); + } + } else { + return new ArrayList<>(); + } + } + + private List loadListOfEntitiesFromDirectory(Path tempDir, String dirName, Class clazz) { + Path dirPath = tempDir.resolve(dirName); + if (Files.exists(dirPath) && Files.isDirectory(dirPath)) { + List result = new ArrayList<>(); + try { + for (Path filePath : Files.list(dirPath).collect(Collectors.toList())) { + try { + result.add(JacksonUtil.readValue(filePath.toFile(), clazz)); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid json file " + filePath.getFileName() + " data structure", e); + } + } + } catch (IOException e) { + log.warn("Failed to read directory: {}", dirName, e); + throw new RuntimeException(e); + } + return result; + } else { + return new ArrayList<>(); + } + } + + private String loadSolutionId(Path tempDir) { + Path solutionJson = tempDir.resolve("solution.json"); + if (Files.exists(solutionJson)) { + JsonNode node = JacksonUtil.toJsonNode(solutionJson); + if (node != null && node.has("title")) { + String title = node.get("title").asText(""); + return title.trim().toLowerCase().replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", ""); + } + } + return null; + } + + private long loadInstallTimeoutMs(Path tempDir) { + Path solutionJson = tempDir.resolve("solution.json"); + if (Files.exists(solutionJson)) { + JsonNode node = JacksonUtil.toJsonNode(solutionJson); + if (node != null && node.has("installTimeoutMs")) { + return node.get("installTimeoutMs").asLong(0L); + } + } + return 0L; + } + + private List loadTenantTelemetryKeys(Path tempDir) { + Path solutionJson = tempDir.resolve("solution.json"); + if (Files.exists(solutionJson)) { + JsonNode node = JacksonUtil.toJsonNode(solutionJson); + if (node != null && node.has("tenantTelemetryKeys")) { + return JacksonUtil.convertValue(node.get("tenantTelemetryKeys"), new TypeReference<>() {}); + } + } + return Collections.emptyList(); + } + + private List loadTenantAttributeKeys(Path tempDir) { + Path solutionJson = tempDir.resolve("solution.json"); + if (Files.exists(solutionJson)) { + JsonNode node = JacksonUtil.toJsonNode(solutionJson); + if (node != null && node.has("tenantAttributeKeys")) { + return JacksonUtil.convertValue(node.get("tenantAttributeKeys"), new TypeReference<>() {}); + } + } + return Collections.emptyList(); + } + + private static final int EXTRACT_BUFFER_SIZE = 8 * 1024; + + private void extractZip(byte[] zipData, Path destDir) throws IOException { + long totalBytes = 0; + int entryCount = 0; + byte[] buf = new byte[EXTRACT_BUFFER_SIZE]; + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipData))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (++entryCount > maxArchiveEntryCount) { + throw new IOException("Solution template archive exceeds max entry count: " + maxArchiveEntryCount); + } + Path entryPath = destDir.resolve(entry.getName()).normalize(); + if (!entryPath.startsWith(destDir)) { + throw new IOException("ZIP entry outside of target directory: " + entry.getName()); + } + if (entry.isDirectory()) { + Files.createDirectories(entryPath); + } else { + Files.createDirectories(entryPath.getParent()); + try (OutputStream out = Files.newOutputStream(entryPath)) { + long entryBytes = 0; + int n; + while ((n = zis.read(buf)) > 0) { + entryBytes += n; + totalBytes += n; + if (entryBytes > maxUncompressedEntryBytes) { + throw new IOException("Solution template entry exceeds max uncompressed size: " + entry.getName()); + } + if (totalBytes > maxUncompressedArchiveBytes) { + throw new IOException("Solution template archive exceeds max uncompressed size: " + maxUncompressedArchiveBytes + " bytes"); + } + out.write(buf, 0, n); + } + } + } + zis.closeEntry(); + } + } + } + + private static void deleteDirectory(Path dir) { + try { + Files.walkFileTree(dir, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path d, IOException exc) throws IOException { + Files.delete(d); + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + log.warn("Failed to clean up temp directory: {}", dir, e); + } + } + + private void deleteEntity(TenantId tenantId, EntityId entityId, User user) { + try { + List alarmIds = alarmService.findAlarms(tenantId, new AlarmQuery(entityId, new TimePageLink(Integer.MAX_VALUE), null, null, null, false)) + .getData().stream().map(AlarmInfo::getId).collect(Collectors.toList()); + Set typesToRemove = new HashSet<>(); + alarmIds.forEach(alarmId -> { + var result = alarmService.delAlarm(tenantId, alarmId, false); + if (result.isSuccessful()) { + typesToRemove.add(result.getAlarm().getType()); + } + }); + alarmService.delAlarmTypes(tenantId, typesToRemove); + } catch (Exception e) { + log.error("[{}] Failed to delete alarms for entity", entityId.getId(), e); + } + switch (entityId.getEntityType()) { + case CALCULATED_FIELD: + CalculatedField cf = calculatedFieldService.findById(tenantId, new CalculatedFieldId(entityId.getId())); + if (cf != null) { + tbCalculatedFieldService.delete(cf, user); + } + break; + case RULE_CHAIN: + ruleChainService.deleteRuleChainById(tenantId, new RuleChainId(entityId.getId())); + break; + case DEVICE: + Device device = deviceService.findDeviceById(tenantId, new DeviceId(entityId.getId())); + if (device != null) { + tbDeviceService.delete(device, user); + } + break; + case DEVICE_PROFILE: + deviceProfileService.deleteDeviceProfile(tenantId, new DeviceProfileId(entityId.getId())); + break; + case ASSET: + Asset asset = assetService.findAssetById(tenantId, new AssetId(entityId.getId())); + if (asset != null) { + tbAssetService.delete(asset, user); + } + break; + case ASSET_PROFILE: + assetProfileService.deleteAssetProfile(tenantId, new AssetProfileId(entityId.getId())); + break; + case CUSTOMER: + customerService.deleteCustomer(tenantId, new CustomerId(entityId.getId())); + break; + case USER: + User userToDelete = userService.findUserById(tenantId, new UserId(entityId.getId())); + if (userToDelete != null) { + userService.deleteUser(tenantId, userToDelete); + } + break; + case EDGE: + Edge edge = edgeService.findEdgeById(tenantId, new EdgeId(entityId.getId())); + if (edge != null) { + tbEdgeService.delete(edge, user); + } + break; + case DASHBOARD: + dashboardService.deleteDashboard(tenantId, new DashboardId(entityId.getId())); + break; + default: + log.warn("[{}] Unsupported entity type for deletion: {}", tenantId, entityId.getEntityType()); + } + } + + private JsonNode replaceIds(SolutionInstallContext ctx, JsonNode json) { + String jsonStr = JacksonUtil.toString(json); + for (var e : ctx.getRealIds().entrySet()) { + jsonStr = jsonStr.replace(e.getKey(), e.getValue()); + } + return JacksonUtil.toJsonNode(jsonStr); + } + + private String readFileContent(Path path) { + try { + return Files.readString(path); + } catch (IOException e) { + log.warn("Failed to read file: {}", path, e); + return ""; + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/SolutionService.java b/application/src/main/java/org/thingsboard/server/service/solutions/SolutionService.java new file mode 100644 index 0000000000..58c47d1dc2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/SolutionService.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions; + +import jakarta.servlet.http.HttpServletRequest; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.iot_hub.SolutionTemplateInstalledItemDescriptor; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.solutions.data.solution.SolutionInstallResponse; + +import java.util.List; + +public interface SolutionService { + + SolutionInstallResponse installSolution(SecurityUser user, TenantId tenantId, byte[] zipData, HttpServletRequest request) throws Exception; + + void deleteSolution(TenantId tenantId, SolutionTemplateInstalledItemDescriptor descriptor, SecurityUser user) throws ThingsboardException; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedAlarmRuleInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedAlarmRuleInfo.java new file mode 100644 index 0000000000..90e5fc9f66 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedAlarmRuleInfo.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.service.solutions.data; + +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.UUID; + +public record CreatedAlarmRuleInfo(EntityId entityId, String entityName, String alarmType, String severities) { + + public String getCfPageLink(UUID cfId) { + return "/alarms/alarm-rules/" + cfId; + } + + public String getEntityPageLink() { + if (entityId == null) { + return null; + } + return switch (entityId.getEntityType()) { + case DEVICE_PROFILE -> "/profiles/deviceProfiles/" + entityId.getId(); + case ASSET_PROFILE -> "/profiles/assetProfiles/" + entityId.getId(); + default -> null; + }; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedCalculatedFieldInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedCalculatedFieldInfo.java new file mode 100644 index 0000000000..9616d738af --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedCalculatedFieldInfo.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.service.solutions.data; + +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.UUID; + +public record CreatedCalculatedFieldInfo(EntityId entityId, String entityName, String type, String name) { + + public String getCfPageLink(UUID cfId) { + return "/calculatedFields/" + cfId; + } + + public String getEntityPageLink() { + if (entityId == null) { + return null; + } + return switch (entityId.getEntityType()) { + case DEVICE_PROFILE -> "/profiles/deviceProfiles/" + entityId.getId(); + case ASSET_PROFILE -> "/profiles/assetProfiles/" + entityId.getId(); + default -> null; + }; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedEntityInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedEntityInfo.java new file mode 100644 index 0000000000..53105a6e31 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedEntityInfo.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; + +import java.util.UUID; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CreatedEntityInfo { + + private String name; + private EntityType type; + private String owner; + + public String getEntityPageLink(UUID id) { + return switch (type) { + case CUSTOMER -> "/customers/" + id; + case USER -> "/users/" + id; + case ASSET -> "/entities/assets/" + id; + case DEVICE -> "/entities/devices/" + id; + case DEVICE_PROFILE -> "/profiles/deviceProfiles/" + id; + case ASSET_PROFILE -> "/profiles/assetProfiles/" + id; + case DASHBOARD -> "/dashboards/" + id; + case RULE_CHAIN -> "/ruleChains/" + id; + case EDGE -> "/edgeManagement/instances/" + id; + default -> null; + }; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedRuleChainInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedRuleChainInfo.java new file mode 100644 index 0000000000..9a785242b2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedRuleChainInfo.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.service.solutions.data; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.rule.RuleChainType; + +import java.util.UUID; + +@Data +@EqualsAndHashCode(callSuper = true) +public class CreatedRuleChainInfo extends CreatedEntityInfo { + + private RuleChainType ruleChainType; + + public CreatedRuleChainInfo(String name, RuleChainType ruleChainType, String owner) { + super(name, EntityType.RULE_CHAIN, owner); + this.ruleChainType = ruleChainType; + } + + @Override + public String getEntityPageLink(UUID id) { + return ruleChainType == RuleChainType.EDGE ? "/edgeManagement/ruleChains/" + id : "/ruleChains/" + id; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/DashboardLinkInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/DashboardLinkInfo.java new file mode 100644 index 0000000000..63eb0e3757 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/DashboardLinkInfo.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data; + +import lombok.Data; +import org.thingsboard.server.common.data.id.DashboardId; + +@Data +public class DashboardLinkInfo { + + private final String name; + private final DashboardId dashboardId; + private final boolean isPublic; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/DeviceCredentialsInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/DeviceCredentialsInfo.java new file mode 100644 index 0000000000..e93104729c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/DeviceCredentialsInfo.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data; + +import lombok.Data; +import org.thingsboard.server.common.data.security.DeviceCredentials; + +@Data +public class DeviceCredentialsInfo { + + String name; + String type; + DeviceCredentials credentials; + String customerName; + boolean gateway; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/EdgeLinkInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/EdgeLinkInfo.java new file mode 100644 index 0000000000..e5f057636d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/EdgeLinkInfo.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data; + +import lombok.Data; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; + +@Data +public class EdgeLinkInfo { + + private final EdgeId edgeId; + private final EntityId ownerId; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/SolutionInstallContext.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/SolutionInstallContext.java new file mode 100644 index 0000000000..9ccf136678 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/SolutionInstallContext.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data; + +import lombok.Data; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +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.StringUtils; +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.edge.Edge; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.service.solutions.data.definition.AssetDefinition; +import org.thingsboard.server.service.solutions.data.definition.AssetProfileDefinition; +import org.thingsboard.server.service.solutions.data.definition.CustomerDefinition; +import org.thingsboard.server.service.solutions.data.definition.DashboardDefinition; +import org.thingsboard.server.service.solutions.data.definition.DeviceDefinition; +import org.thingsboard.server.service.solutions.data.definition.EdgeDefinition; +import org.thingsboard.server.service.solutions.data.definition.DeviceProfileDefinition; +import org.thingsboard.server.service.solutions.data.definition.EntityDefinition; +import org.thingsboard.server.service.solutions.data.definition.EntitySearchKey; +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; +import org.thingsboard.server.service.solutions.data.definition.RelationDefinition; +import org.thingsboard.server.service.solutions.data.solution.TenantSolutionTemplateInstructions; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Data +public class SolutionInstallContext { + + private final TenantId tenantId; + private final Path tempDir; + private final User user; + private final TenantSolutionTemplateInstructions solutionInstructions; + private final List createdEntitiesList = new ArrayList<>(); + private final Map realIds = new HashMap<>(); + private final Map entityIdMap = new HashMap<>(); + private final Map> relationDefinitions = new LinkedHashMap<>(); + private final List dashboardLinks = new ArrayList<>(); + + // For instructions + private final Map createdDevices = new LinkedHashMap<>(); + private final Map createdUsers = new LinkedHashMap<>(); + private final Map createdEdges = new LinkedHashMap<>(); + private final Map createdEntities = new LinkedHashMap<>(); + private final Map createdAlarmRules = new LinkedHashMap<>(); + private final Map createdCalculatedFields = new LinkedHashMap<>(); + private final String solutionId; + private final long installTs; + private long oldestTelemetryTs; + private Map deviceEmulators; + private Map assetEmulators; + + public SolutionInstallContext(TenantId tenantId, String solutionId, Path tempDir, User user, TenantSolutionTemplateInstructions solutionInstructions) { + this.tenantId = tenantId; + this.solutionId = solutionId; + this.tempDir = tempDir; + this.user = user; + this.solutionInstructions = solutionInstructions; + this.installTs = System.currentTimeMillis(); + put(new EntitySearchKey(tenantId, EntityType.TENANT, null), tenantId); + } + + public void registerReferenceOnly(String referenceId, EntityId entityId) { + if (!StringUtils.isEmpty(referenceId)) { + realIds.put(referenceId, entityId.getId().toString()); + } + } + + public void register(String referenceId, EntityId entityId) { + registerReferenceOnly(referenceId, entityId); + register(entityId); + } + + public void register(EntityId entityId) { + createdEntitiesList.add(entityId); + } + + public void register(String referenceId, RuleChain ruleChain) { + register(referenceId, ruleChain.getId()); + createdEntities.put(ruleChain.getUuidId(), new CreatedRuleChainInfo(ruleChain.getName(), ruleChain.getType(), "Tenant")); + } + + public void register(DeviceProfileDefinition definition, DeviceProfile deviceProfile) { + register(definition.getJsonId(), deviceProfile.getId()); + createdEntities.put(deviceProfile.getUuidId(), new CreatedEntityInfo(deviceProfile.getName(), EntityType.DEVICE_PROFILE, "Tenant")); + } + + public void register(AssetProfileDefinition definition, AssetProfile assetProfile) { + register(definition.getJsonId(), assetProfile.getId()); + createdEntities.put(assetProfile.getUuidId(), new CreatedEntityInfo(assetProfile.getName(), EntityType.ASSET_PROFILE, "Tenant")); + } + + public void register(AssetDefinition definition, Asset asset) { + register(definition.getJsonId(), asset.getId()); + createdEntities.put(asset.getUuidId(), new CreatedEntityInfo(asset.getName(), EntityType.ASSET, + StringUtils.isEmpty(definition.getCustomer()) ? "Tenant" : definition.getCustomer())); + } + + public void register(DeviceDefinition definition, Device device) { + register(definition.getJsonId(), device.getId()); + createdEntities.put(device.getUuidId(), new CreatedEntityInfo(device.getName(), EntityType.DEVICE, + StringUtils.isEmpty(definition.getCustomer()) ? "Tenant" : definition.getCustomer())); + } + + public void register(CustomerDefinition definition, Customer customer) { + register(definition.getJsonId(), customer.getId()); + createdEntities.put(customer.getUuidId(), new CreatedEntityInfo(customer.getName(), EntityType.CUSTOMER, "Tenant")); + } + + public void register(CustomerDefinition customerDef, User user) { + register((String) null, user.getId()); + createdEntities.put(user.getUuidId(), new CreatedEntityInfo(user.getName(), EntityType.USER, + StringUtils.isEmpty(customerDef.getName()) ? "Tenant" : customerDef.getName())); + } + + public void register(EdgeDefinition definition, Edge edge) { + register(definition.getJsonId(), edge.getId()); + createdEntities.put(edge.getUuidId(), new CreatedEntityInfo(edge.getName(), EntityType.EDGE, + StringUtils.isEmpty(definition.getCustomer()) ? "Tenant" : definition.getCustomer())); + } + + public void register(DashboardDefinition definition, Dashboard dashboard) { + register(definition.getJsonId(), dashboard.getId()); + createdEntities.put(dashboard.getUuidId(), new CreatedEntityInfo(dashboard.getTitle(), EntityType.DASHBOARD, + StringUtils.isEmpty(definition.getCustomer()) ? "Tenant" : definition.getCustomer())); + } + + public void register(CalculatedField calculatedField) { + register(calculatedField.getId()); + EntityId entityId = calculatedField.getEntityId(); + CreatedEntityInfo entityInfo = createdEntities.get(entityId.getId()); + boolean alarmRule = false; // TODO: CE doesn't have ALARM calculated field type yet + if (entityInfo == null) { + String target = alarmRule ? "Alarm rule" : "Calculated field"; + throw new IllegalStateException("Failed to register " + target + " with name: " + + calculatedField.getName() + " for non-existing entity with id: " + entityId); + } + if (alarmRule) { + createdAlarmRules.put(calculatedField.getUuidId(), new CreatedAlarmRuleInfo(entityId, entityInfo.getName(), calculatedField.getName(), null)); + return; + } + createdCalculatedFields.put(calculatedField.getUuidId(), new CreatedCalculatedFieldInfo(entityId, entityInfo.getName(), calculatedField.getType().name(), calculatedField.getName())); + } + + public void putIdToMap(EntityDefinition entityDefinition, EntityId entityId) { + putIdToMap(entityDefinition.getEntityType(), entityDefinition.getName(), entityId); + } + + public void putIdToMap(EntityType entityType, String entityName, EntityId entityId) { + putIdToMap(tenantId, entityType, entityName, entityId); + } + + public void putIdToMap(EntityId ownerId, EntityType entityType, String entityName, EntityId entityId) { + put(new EntitySearchKey(ownerId, entityType, entityName), entityId); + } + + public void put(EntitySearchKey entitySearchKey, EntityId entityId) { + entityIdMap.put(entitySearchKey, entityId); + } + + @SuppressWarnings("unchecked") + public T getIdFromMap(EntityType entityType, String entityName) { + return (T) entityIdMap.get(new EntitySearchKey(tenantId, entityType, entityName)); + } + + public void put(EntityId entityId, List relations) { + relationDefinitions.put(entityId, relations); + } + + public void addDeviceCredentials(DeviceCredentialsInfo deviceCredentialsInfo) { + createdDevices.put(deviceCredentialsInfo.getName(), deviceCredentialsInfo); + } + + public void addUserCredentials(UserCredentialsInfo userCredentialsInfo) { + createdUsers.put(userCredentialsInfo.getName(), userCredentialsInfo); + } + + public void addEdgeLinkInfo(String edgeName, EdgeLinkInfo edgeLinkInfo) { + createdEdges.put(edgeName, edgeLinkInfo); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/UserCredentialsInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/UserCredentialsInfo.java new file mode 100644 index 0000000000..d35900c5b4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/UserCredentialsInfo.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data; + +import lombok.Data; + +@Data +public class UserCredentialsInfo { + + String name; + String login; + String password; + String customerName; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetDefinition.java new file mode 100644 index 0000000000..2554dbafb0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetDefinition.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.service.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public class AssetDefinition extends CustomerEntityDefinition { + + private String type; + private String label; + private String emulator; + + @Override + public EntityType getEntityType() { + return EntityType.ASSET; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetProfileDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetProfileDefinition.java new file mode 100644 index 0000000000..9f95294fa7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetProfileDefinition.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.asset.AssetProfile; + +@Data +@EqualsAndHashCode(callSuper = true) +public class AssetProfileDefinition extends AssetProfile { + + private String jsonId; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/BaseEntityDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/BaseEntityDefinition.java new file mode 100644 index 0000000000..84ec880380 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/BaseEntityDefinition.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.definition; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Collections; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public abstract class BaseEntityDefinition implements EntityDefinition { + + @JsonProperty("name") + private String name; + @JsonProperty("attributes") + private JsonNode attributes; + @JsonProperty("sharedAttributes") + private JsonNode sharedAttributes; + @JsonProperty("relations") + private List relations = Collections.emptyList(); + @JsonProperty("jsonId") + private String jsonId; + + public void setRelations(List relations) { + if (relations != null) { + this.relations = relations; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CalculatedFieldDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CalculatedFieldDefinition.java new file mode 100644 index 0000000000..1dcc7a6bbd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CalculatedFieldDefinition.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.cf.CalculatedField; + +@Data +@EqualsAndHashCode(callSuper = true) +public class CalculatedFieldDefinition extends CalculatedField { + + private Integer reprocessingOrder; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerDefinition.java new file mode 100644 index 0000000000..3cc40c658c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerDefinition.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.service.solutions.data.names.RandomNameData; + +import java.util.Collections; +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +public class CustomerDefinition extends BaseEntityDefinition { + + private String email; + private String country; + private String city; + private String state; + private String zip; + private String address; + private List users = Collections.emptyList(); + + @JsonIgnore + private RandomNameData randomNameData; + + @Override + public EntityType getEntityType() { + return EntityType.CUSTOMER; + } + + public void setUsers(List users) { + if (users != null) { + this.users = users; + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerEntityDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerEntityDefinition.java new file mode 100644 index 0000000000..d2da2f266c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerEntityDefinition.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public abstract class CustomerEntityDefinition extends BaseEntityDefinition { + + private String customer; + private boolean makePublic; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardDefinition.java new file mode 100644 index 0000000000..a5a9e9c22b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardDefinition.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public class DashboardDefinition extends CustomerEntityDefinition { + + private String file; + private boolean main; + + @Override + public EntityType getEntityType() { + return EntityType.DASHBOARD; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardUserDetailsDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardUserDetailsDefinition.java new file mode 100644 index 0000000000..2ec4701829 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardUserDetailsDefinition.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.definition; + +import lombok.Data; + +@Data +public class DashboardUserDetailsDefinition { + + private String name; + private boolean fullScreen; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceDefinition.java new file mode 100644 index 0000000000..81239c46dd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceDefinition.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.definition; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public class DeviceDefinition extends CustomerEntityDefinition { + + private String type; + private String label; + private String emulator; + private JsonNode additionalInfo; + + @Override + public EntityType getEntityType() { + return EntityType.DEVICE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceProfileDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceProfileDefinition.java new file mode 100644 index 0000000000..3dee5c179a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceProfileDefinition.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.DeviceProfile; + +@Data +@EqualsAndHashCode(callSuper = true) +public class DeviceProfileDefinition extends DeviceProfile { + + private String jsonId; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EdgeDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EdgeDefinition.java new file mode 100644 index 0000000000..5e8e7aa203 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EdgeDefinition.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; + +import java.util.Collections; +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public class EdgeDefinition extends CustomerEntityDefinition { + + private String type; + private String label; + private String rootRuleChainId; + private List ruleChainIds = Collections.emptyList(); + private List deviceIds = Collections.emptyList(); + private List assetIds = Collections.emptyList(); + private List dashboardIds = Collections.emptyList(); + + @Override + public EntityType getEntityType() { + return EntityType.EDGE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EmulatorDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EmulatorDefinition.java new file mode 100644 index 0000000000..37c726e091 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EmulatorDefinition.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.service.solutions.data.definition; + +import lombok.Data; +import org.thingsboard.server.common.data.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Data +public class EmulatorDefinition { + private String name; + private String extendz; + private String clazz; + private int publishPeriodInDays; + private int publishFrequencyInSeconds; + private int publishPauseInMillis; + private long activityPeriodInMillis; + private List telemetryProfiles = Collections.emptyList(); + + public void enrich(EmulatorDefinition parent) { + if (StringUtils.isEmpty(clazz)) { + clazz = parent.getClazz(); + } + if (publishPeriodInDays == 0) { + publishPeriodInDays = parent.getPublishPeriodInDays(); + } + if (publishFrequencyInSeconds == 0) { + publishFrequencyInSeconds = parent.getPublishFrequencyInSeconds(); + } + if (publishPauseInMillis == 0) { + publishPauseInMillis = parent.getPublishPauseInMillis(); + } + if (activityPeriodInMillis == 0L) { + activityPeriodInMillis = parent.getActivityPeriodInMillis(); + } + var profilesMap = telemetryProfiles.stream().collect(Collectors.toMap(TelemetryProfile::getKey, Function.identity())); + parent.getTelemetryProfiles().forEach(tp -> profilesMap.putIfAbsent(tp.getKey(), tp)); + telemetryProfiles = new ArrayList<>(profilesMap.values()); + } + + public long getOldestTs(long startTs) { + return startTs - TimeUnit.DAYS.toMillis(publishPeriodInDays) - publishFrequencyInSeconds; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntityDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntityDefinition.java new file mode 100644 index 0000000000..c76d183bda --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntityDefinition.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.definition; + +import org.thingsboard.server.common.data.EntityType; + +public interface EntityDefinition { + + String getName(); + + EntityType getEntityType(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntitySearchKey.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntitySearchKey.java new file mode 100644 index 0000000000..f880e909b0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntitySearchKey.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.service.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityId; + +@Data +@AllArgsConstructor +public class EntitySearchKey { + + private final EntityId ownerId; + private final EntityType entityType; + private final String entityName; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/ReferenceableEntityDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/ReferenceableEntityDefinition.java new file mode 100644 index 0000000000..13dabb08cc --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/ReferenceableEntityDefinition.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ReferenceableEntityDefinition { + + private String name; + private String file; + private String update; + private String jsonId; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/RelationDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/RelationDefinition.java new file mode 100644 index 0000000000..670d3f1ab4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/RelationDefinition.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RelationDefinition { + + private EntityType entityType; + private String entityName; + private EntitySearchDirection direction; + private String type; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TelemetryProfile.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TelemetryProfile.java new file mode 100644 index 0000000000..259744a2f0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TelemetryProfile.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.service.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.thingsboard.server.service.solutions.data.values.ValueStrategyDefinition; + +@Data +@AllArgsConstructor +public class TelemetryProfile { + + private String key; + private ValueStrategyDefinition valueStrategy; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TenantDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TenantDefinition.java new file mode 100644 index 0000000000..957547cff6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TenantDefinition.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.service.solutions.data.definition; + +import lombok.Data; +import org.thingsboard.server.common.data.EntityType; + +@Data +public class TenantDefinition extends BaseEntityDefinition { + + @Override + public EntityType getEntityType() { + return EntityType.TENANT; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/UserDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/UserDefinition.java new file mode 100644 index 0000000000..1815cc6e57 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/UserDefinition.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.service.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public class UserDefinition extends BaseEntityDefinition { + + private String firstname; + private String lastname; + private String password; + private DashboardUserDetailsDefinition dashboard; + + @Override + public EntityType getEntityType() { + return EntityType.USER; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AbstractEmulatorLauncher.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AbstractEmulatorLauncher.java new file mode 100644 index 0000000000..abed74af3e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AbstractEmulatorLauncher.java @@ -0,0 +1,195 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.emulator; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.HasName; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgDataType; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +@Data +public abstract class AbstractEmulatorLauncher & HasTenantId & HasName> { + + protected static final TbQueueCallback EMPTY_CALLBACK = new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + + } + + @Override + public void onFailure(Throwable t) { + + } + }; + protected final T entity; + protected final EmulatorDefinition emulatorDefinition; + protected final ExecutorService oldTelemetryExecutor; + protected final TbClusterService tbClusterService; + protected final PartitionService partitionService; + protected final TbQueueProducerProvider tbQueueProducerProvider; + protected final TbServiceInfoProvider serviceInfoProvider; + protected final TelemetrySubscriptionService tsSubService; + protected final long publishFrequency; + + private final Emulator emulator; + private ScheduledFuture scheduledFuture; + + public AbstractEmulatorLauncher(T entity, + EmulatorDefinition emulatorDefinition, + ExecutorService oldTelemetryExecutor, + TbClusterService tbClusterService, + PartitionService partitionService, + TbQueueProducerProvider tbQueueProducerProvider, + TbServiceInfoProvider serviceInfoProvider, + TelemetrySubscriptionService tsSubService) throws Exception { + this.entity = entity; + this.emulatorDefinition = emulatorDefinition; + this.oldTelemetryExecutor = oldTelemetryExecutor; + this.tbClusterService = tbClusterService; + this.partitionService = partitionService; + this.tbQueueProducerProvider = tbQueueProducerProvider; + this.serviceInfoProvider = serviceInfoProvider; + this.tsSubService = tsSubService; + this.publishFrequency = TimeUnit.SECONDS.toMillis(Math.max(emulatorDefinition.getPublishFrequencyInSeconds(), 1)); + if (StringUtils.isEmpty(emulatorDefinition.getClazz())) { + emulator = new BasicEmulator(); + } else { + emulator = (Emulator) Class.forName(emulatorDefinition.getClazz()).getDeclaredConstructor().newInstance(); + } + emulator.init(emulatorDefinition); + } + + public CompletableFuture launch() { + final long oldestTs = emulatorDefinition.getOldestTs(System.currentTimeMillis()); + CompletableFuture future = new CompletableFuture<>(); + oldTelemetryExecutor.submit(() -> { + AtomicInteger pending = new AtomicInteger(1); + try { + if (emulator instanceof SimpleEmulator) { + if (oldestTs < (System.currentTimeMillis() - publishFrequency)) { + pushOldTelemetry(oldestTs, pending, future); + } + } else if (emulator instanceof CustomEmulator customEmulator) { + Pair telemetry = customEmulator.getNextValue(); + while (telemetry != null) { + pending.incrementAndGet(); + publishTelemetry(telemetry.getFirst(), telemetry.getSecond(), pending, future); + telemetry = customEmulator.getNextValue(); + } + } + } catch (Exception e) { + log.warn("Telemetry upload failed for {}", entity.getName(), e); + future.completeExceptionally(e); + } finally { + if (pending.decrementAndGet() == 0 && !future.isDone()) { + log.trace("[{}] Telemetry emulation finished. Producer and all callbacks completed successfully", entity.getName()); + future.complete(null); + } + } + + future.whenComplete((v, t) -> { + try { + postProcessEntity(entity); + } catch (Exception e) { + log.warn("Post-processing failed for {}", entity.getName(), e); + } + }); + + }); + return future; + } + + protected void postProcessEntity(T entity) { + } + + private void pushOldTelemetry(long oldestTs, AtomicInteger pending, CompletableFuture future) throws InterruptedException { + for (long ts = oldestTs; ts < System.currentTimeMillis(); ts += publishFrequency) { + pending.incrementAndGet(); + publishTelemetry(ts, pending, future); + } + } + + private void publishTelemetry(long ts, AtomicInteger pending, CompletableFuture future) throws InterruptedException { + ObjectNode values = ((SimpleEmulator) emulator).getValue(ts); + publishTelemetry(ts, values, pending, future); + Thread.sleep(emulatorDefinition.getPublishPauseInMillis()); + } + + public void stop() { + scheduledFuture.cancel(true); + } + + private void publishTelemetry(long ts, ObjectNode value, AtomicInteger pending, CompletableFuture future) { + String msgData = JacksonUtil.toString(value); + log.debug("[{}] Publishing telemetry: {}", entity.getName(), msgData); + TbMsgMetaData md = new TbMsgMetaData(); + md.putValue("ts", Long.toString(ts)); + TbMsg tbMsg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(entity.getId()) + .copyMetaData(md) + .dataType(TbMsgDataType.JSON) + .data(msgData) + .build(); + + TbQueueCallback callback = new TbQueueCallback() { + + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + log.debug("[{}] Successfully pushed message to Rule Engine for ts {}", entity.getName(), ts); + if (pending.decrementAndGet() == 0 && !future.isDone()) { + log.trace("[{}] Completing emulation future from callback for msg with ts: {}", entity.getName(), ts); + future.complete(null); + } + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}] Telemetry upload failed for ts {}", entity.getName(), ts, t); + if (pending.decrementAndGet() == 0 && !future.isDone()) { + future.completeExceptionally(t); + } + } + }; + + tbClusterService.pushMsgToRuleEngine(entity.getTenantId(), entity.getId(), tbMsg, callback); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AssetEmulatorLauncher.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AssetEmulatorLauncher.java new file mode 100644 index 0000000000..1f2d144b99 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AssetEmulatorLauncher.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.emulator; + +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import java.util.concurrent.ExecutorService; + +@Slf4j +public class AssetEmulatorLauncher extends AbstractEmulatorLauncher { + + @Builder + public AssetEmulatorLauncher(Asset entity, EmulatorDefinition emulatorDefinition, ExecutorService oldTelemetryExecutor, TbClusterService tbClusterService, + PartitionService partitionService, + TbQueueProducerProvider tbQueueProducerProvider, + TbServiceInfoProvider serviceInfoProvider, + TelemetrySubscriptionService tsSubService) throws Exception { + super(entity, emulatorDefinition, oldTelemetryExecutor, tbClusterService, partitionService, tbQueueProducerProvider, serviceInfoProvider, tsSubService); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/BasicEmulator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/BasicEmulator.java new file mode 100644 index 0000000000..c81aed3c02 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/BasicEmulator.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.emulator; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; +import org.thingsboard.server.service.solutions.data.values.TelemetryGenerator; +import org.thingsboard.server.service.solutions.data.values.TelemetryGeneratorFactory; + +import java.util.HashMap; +import java.util.Map; + +public class BasicEmulator implements SimpleEmulator { + + private final Map tsGenerators = new HashMap<>(); + + @Override + public void init(EmulatorDefinition emulatorDefinition) { + emulatorDefinition.getTelemetryProfiles().forEach(tp -> tsGenerators.put(tp.getKey(), TelemetryGeneratorFactory.create(tp))); + } + + @Override + public ObjectNode getValue(long ts) { + ObjectNode values = JacksonUtil.newObjectNode(); + tsGenerators.values().forEach(gen -> gen.addValue(ts, values)); + return values; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/CustomEmulator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/CustomEmulator.java new file mode 100644 index 0000000000..627fe4e94d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/CustomEmulator.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.emulator; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.data.util.Pair; + +public interface CustomEmulator extends Emulator { + + Pair getNextValue(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/DeviceEmulatorLauncher.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/DeviceEmulatorLauncher.java new file mode 100644 index 0000000000..62fe03aa31 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/DeviceEmulatorLauncher.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.emulator; + +import com.google.common.util.concurrent.FutureCallback; +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.thingsboard.rule.engine.api.AttributesSaveRequest; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; +import org.thingsboard.server.service.state.DefaultDeviceStateService; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import java.util.UUID; +import java.util.concurrent.ExecutorService; + +@Slf4j +public class DeviceEmulatorLauncher extends AbstractEmulatorLauncher { + + @Builder + public DeviceEmulatorLauncher(Device entity, + EmulatorDefinition emulatorDefinition, + ExecutorService oldTelemetryExecutor, + TbClusterService tbClusterService, + PartitionService partitionService, + TbQueueProducerProvider tbQueueProducerProvider, + TbServiceInfoProvider serviceInfoProvider, + TelemetrySubscriptionService tsSubService) throws Exception { + super(entity, emulatorDefinition, oldTelemetryExecutor, tbClusterService, partitionService, tbQueueProducerProvider, serviceInfoProvider, tsSubService); + } + + @Override + protected void postProcessEntity(Device entity) { + if (this.emulatorDefinition.getActivityPeriodInMillis() > 0) { + tsSubService.saveAttributes(AttributesSaveRequest.builder() + .tenantId(entity.getTenantId()) + .entityId(entity.getId()) + .scope(AttributeScope.SERVER_SCOPE) + .entry(new LongDataEntry(DefaultDeviceStateService.INACTIVITY_TIMEOUT, this.emulatorDefinition.getActivityPeriodInMillis())) + .callback(new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Void unused) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, entity.getTenantId(), entity.getId()); + UUID sessionId = UUID.randomUUID(); + TransportProtos.TransportToDeviceActorMsg msg = TransportProtos.TransportToDeviceActorMsg.newBuilder() + .setSessionInfo(TransportProtos.SessionInfoProto.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setDeviceIdMSB(entity.getId().getId().getMostSignificantBits()) + .setDeviceIdLSB(entity.getId().getId().getLeastSignificantBits()) + .setDeviceProfileIdMSB(entity.getId().getId().getMostSignificantBits()) + .setDeviceProfileIdLSB(entity.getId().getId().getLeastSignificantBits()) + .setDeviceName(entity.getName()) + .setDeviceType(entity.getType()) + .setTenantIdMSB(entity.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(entity.getTenantId().getId().getLeastSignificantBits()) + .setNodeId(serviceInfoProvider.getServiceId()) + .build()) + .setSubscriptionInfo(TransportProtos.SubscriptionInfoProto.newBuilder().setLastActivityTime(System.currentTimeMillis()).build()) + .build(); + tbQueueProducerProvider.getTbCoreMsgProducer().send(tpi, + new TbProtoQueueMsg<>(entity.getUuidId(), + TransportProtos.ToCoreMsg.newBuilder().setToDeviceActorMsg(msg).build()), EMPTY_CALLBACK + ); + } + + @Override + public void onFailure(Throwable throwable) { + } + }) + .build()); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/Emulator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/Emulator.java new file mode 100644 index 0000000000..45a9a029b8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/Emulator.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.emulator; + +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; + +public interface Emulator { + + void init(EmulatorDefinition emulatorDefinition); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/FieldIrrigationStateEmulator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/FieldIrrigationStateEmulator.java new file mode 100644 index 0000000000..44c9eb550f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/FieldIrrigationStateEmulator.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.emulator; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.data.util.Pair; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; +import java.util.TimeZone; + +public class FieldIrrigationStateEmulator implements CustomEmulator { + + private static final int START_HOUR = 6; + private int idx; + private List> data = new ArrayList<>(); + + @Override + public void init(EmulatorDefinition emulatorDefinition) { + idx = 0; + var tz = TimeZone.getTimeZone("America/New_York"); + Calendar c = Calendar.getInstance(); + c.setTimeInMillis(System.currentTimeMillis()); + c.setTimeZone(tz); + int curHour = c.get(Calendar.HOUR_OF_DAY); + c.add(Calendar.DAY_OF_MONTH, curHour < START_HOUR ? -7 : -6); + c.set(Calendar.HOUR_OF_DAY, START_HOUR); + c.set(Calendar.MINUTE, 0); + + add(c, JacksonUtil.newObjectNode() + .put("startTs", c.getTimeInMillis()) + .put("durationThreshold", 30 * 60000) + .put("consumption", 423) + .put("duration", 30 * 60000) + ); + add(c, JacksonUtil.newObjectNode() + .put("startTs", c.getTimeInMillis()) + .put("durationThreshold", 30 * 60000) + .put("consumption", 407) + .put("duration", 30 * 60000) + ); + add(c, JacksonUtil.newObjectNode() + .put("startTs", c.getTimeInMillis()) + .put("consumptionThreshold", 1000) + .put("consumption", 1000) + .put("duration", 63 * 60000) + ); + add(c, JacksonUtil.newObjectNode() + .put("startTs", c.getTimeInMillis()) + .put("consumptionThreshold", 500) + .put("consumption", 500) + .put("duration", 36 * 60000) + ); + add(c, JacksonUtil.newObjectNode() + .put("startTs", c.getTimeInMillis()) + .put("consumptionThreshold", 1000) + .put("consumption", 1000) + .put("duration", 63 * 60000) + ); + add(c, JacksonUtil.newObjectNode() + .put("startTs", c.getTimeInMillis()) + .put("durationThreshold", 30 * 60000) + .put("duration", 30 * 60000) + .put("consumption", 452) + ); + add(c, JacksonUtil.newObjectNode() + .put("startTs", c.getTimeInMillis()) + .put("durationThreshold", 30 * 60000) + .put("duration", 30 * 60000) + .put("consumption", 447) + ); + } + + private void add(Calendar c, ObjectNode objectNode) { + ObjectNode startIrrigationMsg = JacksonUtil.newObjectNode(); + startIrrigationMsg.put("irrigationState", "DONE"); + startIrrigationMsg.set("irrigationTask", objectNode); + data.add(Pair.of(c.getTimeInMillis(), startIrrigationMsg)); + c.add(Calendar.DAY_OF_MONTH, 1); + } + + @Override + public Pair getNextValue() { + if (idx < data.size()) { + var result = data.get(idx); + idx++; + return result; + } else { + return null; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/SimpleEmulator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/SimpleEmulator.java new file mode 100644 index 0000000000..144a5f7f26 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/SimpleEmulator.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.emulator; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +public interface SimpleEmulator extends Emulator { + + ObjectNode getValue(long ts); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameData.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameData.java new file mode 100644 index 0000000000..ca66c37199 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameData.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.names; + +import lombok.Data; + +@Data +public class RandomNameData { + + private final String firstName; + private final String lastName; + private final String email; +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameUtil.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameUtil.java new file mode 100644 index 0000000000..50bc3b9dc5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameUtil.java @@ -0,0 +1,100 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.names; + +import org.thingsboard.server.common.data.StringUtils; + +import java.util.Random; + +public class RandomNameUtil { + + private static final Random RANDOM = new Random(System.currentTimeMillis()); + + private static String[] FIRST_NAMES = new String[]{ + "Nadia", "Lynne", "Lera", "Holly", "Zina", "Mandi", "Kasie", "Josef", "Liane", "Man", + "Karan", "Nga", "Tesha", "Elva", "Bree", "Ida", "Mimi", "Altha", "Omar", "Ollie", + "Nicol", "Ned", "Teri", "Sabra", "Kari", "Eva", "Penni", "Simon", "Kaci", "Jess", + "Bea", "Ron", "Melba", "Vella", "Nada", "Cyndi", "Audry", "Helga", "Chana", "Lola", + "Terry", "Tami", "Dedra", "Erwin", "Anne", "Zoila", "Nelia", "Hyman", "Deane", "Erica", + "Amos", "Maura", "Gwenn", "Evan", "Lelia", "Grant", "Abe", "Fanny", "Cindi", "Pilar", + "Darcy", "Len", "Casie", "Jose", "Kylie", "Cami", "Casey", "Kerri", "Bruno", "Theda", + "Ardis", "Carin", "Belia", "Jeff", "Yetta", "Ola", "Lyla", "Megan", "Zita", "Rocky", + "Darci", "Dale", "Mirta", "Tanja", "Stacy", "Julie", "Aisha", "Avis", "Hugh", "Anh", + "Bud", "Earle", "Ossie", "Odell", "Dovie", "Afton", "Joel", "Aida", "Vina", "Emiko", + "Tori", "Keena", "Addie", "Nancy", "Tonda", "Josue", "Dina", "Mitch", "Thea", "Cole", + "Lyman", "Donna", "Roma", "Deena", "Lue", "Bette", "Ilene", "Vera", "Kirby", "Lenna", + "Pat", "Grace", "Gus", "Frank", "Lena", "Adele", "Kerry", "Santo", "Wade", "Trudi", + "Bruce", "Raul", "Katy", "Heidi", "Marna", "Faith", "Ronna", "Kylee", "Emma", "Luna", + "Ilda", "Mose", "Juan", "Cori", "Gayla", "Otha", "Jung", "Bambi", "Joi", "Sibyl", + "Reid", "Bonny", "Roni", "Joy", "Farah", "Jeane", "Jill", "Tisha", "Leon", "Leigh", + "Inge", "Zella", "Rubin", "Carri", "Nana", "Sofia", "Lucio", "Eboni", "Adam", "Lino", + "Elvis", "Marci", "Adina", "Nanci", "Joyce", "James", "Louis", "Aurea", "Chi", "Kathy", + "Nina", "Lise", "Reda", "Candi", "Ralph", "Velva", "Mari", "Mavis", "Kory", "Tammi", + "Lucy", "Lavon", "Ana", "Lin", "Alena", "Haley", "Myra", "Amy", "Marlo", "Ami" + }; + + private static String[] LAST_NAMES = new String[]{ + "Vinson", "Rogers", "Burkett", "Gamboa", "Gross", "Toledo", "Kiser", "Harman", "Pierce", "Lovett", + "Sexton", "Coates", "Seymour", "Holland", "Finley", "Hagen", "Schmitt", "Beach", "Rea", "Peck", + "Noel", "Archer", "Zamora", "Wood", "Rivera", "Lentz", "Alonso", "Rider", "Story", "Schwab", + "Mercado", "Caruso", "Bynum", "Spears", "Bingham", "Tyler", "Ponce", "Skaggs", "Matos", "Stinson", + "Burgos", "Coles", "Helton", "Joyce", "Galvan", "Grant", "Stahl", "Daigle", "Hayden", "Quiroz", + "Horne", "Shaw", "Addison", "Harvey", "Watkins", "Romano", "Ahmed", "Lind", "Swenson", "Kemp", + "Barnes", "Horton", "Dugan", "Farr", "Swanson", "Hale", "Hidalgo", "Schulte", "Ervin", "Osorio", + "Lincoln", "Zhang", "Oakes", "Stiles", "Lu", "Culver", "Singer", "Knott", "Bullard", "Butts", + "Coley", "Roth", "Cobb", "Darnell", "Vaughan", "Conrad", "Austin", "Rico", "Hobson", "Brunner", + "Mccray", "Mattson", "Barber", "Clement", "Ly", "Dejesus", "Painter", "Macias", "Healy", "Duncan", + "Forrest", "Hurley", "Torres", "Blue", "Rucker", "Ferrell", "Maddox", "Osborne", "Holt", "Briggs", + "Irvin", "Bassett", "Urban", "Overton", "Sloan", "Guzman", "Estrada", "Redding", "Gold", "Paz", + "Lucero", "Reyes", "Conner", "Keith", "Hays", "Hutton", "Moore", "Tuttle", "Powers", "William", + "Reeder", "Castro", "Vela", "Baca", "Frazier", "Kline", "Huggins", "Zepeda", "Pate", "Marion", + "Hollis", "Ybarra", "Cuellar", "Gallo", "Gomes", "Louis", "Bolden", "Liu", "Hayes", "Simpson", + "Otero", "Conway", "Peoples", "Mohr", "Loomis", "Engel", "Hughes", "Chapman", "Bower", "Schmidt", + "Mcmahon", "Booth", "Ward", "Drake", "Cutler", "Gordon", "Black", "Clifton", "Curran", "Pierre", + "Workman", "Tilley", "Moody", "Kuhn", "Eastman", "Scott", "Vang", "Tillman", "Cleary", "Yi", + "Griffin", "Mendoza", "Mason", "Pittman", "Landis", "Gay", "Rowland", "Jordan", "Lo", "Kumar", + "Vernon", "Boyd", "Rocha", "Mcqueen", "Cornett", "Deaton", "Borden", "Lacey", "Gaston", "Miles" + }; + + public static String nextFirstName() { + return randomElement(FIRST_NAMES); + } + + public static String nextLastName() { + return randomElement(LAST_NAMES); + } + + private static String toEmail(String firstName, String lastName) { + return firstName.toLowerCase() + "." + lastName.toLowerCase() + "@thingsboard.io"; + } + + public static RandomNameData next() { + var firstName = nextFirstName(); + var lastName = nextLastName(); + return new RandomNameData(firstName, lastName, toEmail(firstName, lastName)); + } + + public static RandomNameData nextSuperRandom() { + var firstName = nextFirstName() + StringUtils.randomAlphanumeric(10).toLowerCase(); + var lastName = nextLastName() + StringUtils.randomAlphanumeric(10).toLowerCase(); + return new RandomNameData(firstName, lastName, toEmail(firstName, lastName)); + } + + private static String randomElement(String[] array) { + return array[RANDOM.nextInt(array.length)]; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/SolutionInstallResponse.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/SolutionInstallResponse.java new file mode 100644 index 0000000000..a122a562a9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/SolutionInstallResponse.java @@ -0,0 +1,54 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.solution; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.Collections; +import java.util.List; + +@Schema +@Data +public class SolutionInstallResponse extends TenantSolutionTemplateInstructions { + + @Schema(description = "Indicates that template was installed successfully") + private boolean success; + @Schema(description = "List of entity IDs created during solution installation") + private List createdEntityIds; + @Schema(description = "What keys to delete during template uninstall") + private List tenantTelemetryKeys; + @Schema(description = "What attributes to delete during template uninstall") + private List tenantAttributeKeys; + + public SolutionInstallResponse(TenantSolutionTemplateInstructions instructions, boolean success, List createdEntityIds) { + this(instructions, success, createdEntityIds, Collections.emptyList(), Collections.emptyList()); + } + + public SolutionInstallResponse(TenantSolutionTemplateInstructions instructions, boolean success, List createdEntityIds, + List tenantTelemetryKeys, List tenantAttributeKeys) { + super(instructions); + this.success = success; + this.createdEntityIds = createdEntityIds; + this.tenantTelemetryKeys = tenantTelemetryKeys; + this.tenantAttributeKeys = tenantAttributeKeys; + } + + public SolutionInstallResponse() { + super(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/TenantSolutionTemplateInstructions.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/TenantSolutionTemplateInstructions.java new file mode 100644 index 0000000000..fabd89d072 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/TenantSolutionTemplateInstructions.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.solution; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; + +@Schema +@Data +@AllArgsConstructor +@NoArgsConstructor +public class TenantSolutionTemplateInstructions { + + @Schema(description = "Id of the main dashboard of the solution") + private DashboardId dashboardId; + @Schema(description = "Id of the public customer if solution has public entities") + private CustomerId publicId; + @Schema(description = "Is the main dashboard public") + private boolean mainDashboardPublic; + @Schema(description = "Markdown with solution usage instructions") + private String details; + + public TenantSolutionTemplateInstructions(TenantSolutionTemplateInstructions instructions) { + this.dashboardId = instructions.getDashboardId(); + this.publicId = instructions.getPublicId(); + this.mainDashboardPublic = instructions.isMainDashboardPublic(); + this.details = instructions.getDetails(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyDefinition.java new file mode 100644 index 0000000000..ba803faf87 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyDefinition.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.values; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class CompositeValueStrategyDefinition implements ValueStrategyDefinition { + + private ValueStrategyDefinition defaultHours; + private ValueStrategyDefinition workHours; + private ValueStrategyDefinition nightHours; + private ValueStrategyDefinition holidayHours; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.COMPOSITE; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyGenerator.java new file mode 100644 index 0000000000..6cbfaf550e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyGenerator.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +public class CompositeValueStrategyGenerator extends TelemetryGenerator { + + TelemetryGenerator defaultGenerator; + TelemetryGenerator whGenerator; + TelemetryGenerator nhGenerator; + TelemetryGenerator hhGenerator; + + public CompositeValueStrategyGenerator(TelemetryProfile tp) { + super(tp); + var def = (CompositeValueStrategyDefinition) tp.getValueStrategy(); + defaultGenerator = TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), def.getDefaultHours())); + if (def.getWorkHours() != null) { + whGenerator = TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), def.getWorkHours())); + } + if (def.getNightHours() != null) { + nhGenerator = TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), def.getNightHours())); + } + if (def.getHolidayHours() != null) { + hhGenerator = TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), def.getHolidayHours())); + } + } + + @Override + public void addValue(long ts, ObjectNode values) { + if (hhGenerator != null && GeneratorTools.isHoliday(ts)) { + hhGenerator.addValue(ts, values); + } else if (nhGenerator != null && GeneratorTools.isNightHour(ts)) { + nhGenerator.addValue(ts, values); + } else if (whGenerator != null && GeneratorTools.isWorkHour(ts)) { + whGenerator.addValue(ts, values); + } else { + defaultGenerator.addValue(ts, values); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantTelemetryGenerator.java new file mode 100644 index 0000000000..59ca319f67 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantTelemetryGenerator.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.values; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +public class ConstantTelemetryGenerator extends TelemetryGenerator { + + private final ConstantValueStrategyDefinition strategy; + private JsonNode value; + + public ConstantTelemetryGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + this.strategy = (ConstantValueStrategyDefinition) telemetryProfile.getValueStrategy(); + this.value = strategy.getValue(); + } + + @Override + public void addValue(long ts, ObjectNode values) { + values.set(key, value); + } + + @Override + public double getValue() { + if (value != null && value.isNumber()) { + return value.doubleValue(); + } else { + return super.getValue(); + } + } + + @Override + public void setValue(double value) { + // do nothing; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantValueStrategyDefinition.java new file mode 100644 index 0000000000..ef18ef2c9b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantValueStrategyDefinition.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.service.solutions.data.values; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class ConstantValueStrategyDefinition implements ValueStrategyDefinition { + private JsonNode value; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.CONSTANT; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterTelemetryGenerator.java new file mode 100644 index 0000000000..544d143893 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterTelemetryGenerator.java @@ -0,0 +1,69 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Getter; +import lombok.Setter; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.getMultiplier; +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomDouble; + +public class CounterTelemetryGenerator extends TelemetryGenerator { + + private final CounterValueStrategyDefinition strategy; + @Getter @Setter + private double value; + + public CounterTelemetryGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + this.strategy = (CounterValueStrategyDefinition) telemetryProfile.getValueStrategy(); + this.value = getRandomStartValue(); + } + + @Override + public void addValue(long ts, ObjectNode values) { + double step = randomDouble(strategy.getMinIncrement(), strategy.getMaxIncrement()); + double multiplier = getMultiplier(ts, strategy.getHolidayMultiplier(), strategy.getWorkHoursMultiplier(), strategy.getNightHoursMultiplier()); + value += step * multiplier; + if (value > getRandomEndValue()) { + value = getRandomStartValue(); + } + put(values, value); + } + + private void put(ObjectNode values, double value) { + if (strategy.getPrecision() == 0) { + values.put(key, (int) value); + } else { + values.put(key, BigDecimal.valueOf(value) + .setScale(strategy.getPrecision(), RoundingMode.HALF_UP) + .doubleValue()); + } + } + + public double getRandomStartValue() { + return randomDouble(strategy.getMinStartValue(), strategy.getMaxStartValue()); + } + + public double getRandomEndValue() { + return randomDouble(strategy.getMinEndValue(), strategy.getMaxEndValue()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterValueStrategyDefinition.java new file mode 100644 index 0000000000..9010af51bd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterValueStrategyDefinition.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.values; + + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class CounterValueStrategyDefinition implements ValueStrategyDefinition { + + private int precision; + private double minStartValue; + private double maxStartValue; + private double minEndValue; + private double maxEndValue; + private double minIncrement; + private double maxIncrement; + private double holidayMultiplier; + private double workHoursMultiplier; + private double nightHoursMultiplier; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.COUNTER; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementTelemetryGenerator.java new file mode 100644 index 0000000000..bbcb6cedf7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementTelemetryGenerator.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.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomDouble; + +public class DecrementTelemetryGenerator extends IncDecTelemetryGenerator { + + public DecrementTelemetryGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + } + + @Override + public void addValue(long ts, ObjectNode values) { + double step = randomDouble(strategy.getMinDecrement(), strategy.getMaxDecrement()); + double newValue = value - step; + value = Math.max(newValue, endValue); + put(values, value); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementValueStrategyDefinition.java new file mode 100644 index 0000000000..b5f254e2e5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementValueStrategyDefinition.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.values; + + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class DecrementValueStrategyDefinition extends IncDecValueStrategyDefinition { + + private double minDecrement; + private double maxDecrement; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.DECREMENT; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventTelemetryGenerator.java new file mode 100644 index 0000000000..82555f7ed4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventTelemetryGenerator.java @@ -0,0 +1,54 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomLong; + + +public class EventTelemetryGenerator extends TelemetryGenerator { + + private final EventValueStrategyDefinition strategy; + private final long currentAnomaly; + private Object value; + + public EventTelemetryGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + this.strategy = (EventValueStrategyDefinition) telemetryProfile.getValueStrategy(); + currentAnomaly = randomLong(0, strategy.getAnomalyChance()); + } + + @Override + public void addValue(long ts, ObjectNode values) { + boolean anomaly = false; + if (randomLong(0, strategy.getAnomalyChance()) == currentAnomaly) { + anomaly = true; + } + this.value = anomaly ? strategy.getAnomalyValue() : strategy.getNormalValue(); + if (value instanceof Boolean) { + values.put(key, (Boolean) value); + } else if (value instanceof Double) { + values.put(key, ((Double) value)); + } else if (value instanceof Integer) { + values.put(key, ((Integer) value)); + } else { + throw new RuntimeException("Not supported value for event telemetry generator: " + value); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventValueStrategyDefinition.java new file mode 100644 index 0000000000..855004da02 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventValueStrategyDefinition.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.values; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class EventValueStrategyDefinition implements ValueStrategyDefinition { + private Object normalValue; + private Object anomalyValue; + private double anomalyChance; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.EVENT; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/GeneratorTools.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/GeneratorTools.java new file mode 100644 index 0000000000..918379faa6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/GeneratorTools.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.values; + +import java.util.Calendar; +import java.util.Date; +import java.util.Random; +import java.util.TimeZone; + +public class GeneratorTools { + + private static final Random random = new Random(); + + public static long randomLong(double min, double max) { + return (long) randomDouble(min, max); + } + + public static double randomDouble(double min, double max) { + return min + random.nextDouble() * Math.abs(max - min); + } + + public static double getMultiplier(long ts, double holidayMultiplier, double workHoursMultiplier, double nightHoursMultiplier) { + Date date = new Date(ts); + Calendar c = Calendar.getInstance(); + c.setTime(date); + int dayOfWeek = c.get(Calendar.DAY_OF_WEEK); + int hour = c.get(Calendar.HOUR_OF_DAY); + double multiplier = 1.0; + if (dayOfWeek == 1 || dayOfWeek == 7) { + multiplier *= holidayMultiplier; + } + + if (hour > 8 && hour < 18) { + multiplier *= workHoursMultiplier; + } else if (hour < 6 || hour > 22) { + multiplier *= nightHoursMultiplier; + } + return multiplier; + } + + public static int getHour(TimeZone tz, long ts) { + Date date = new Date(ts); + Calendar c = Calendar.getInstance(); + c.setTimeZone(tz); + c.setTime(date); + return c.get(Calendar.HOUR_OF_DAY); + } + + public static int getMinute(TimeZone tz, long ts) { + Date date = new Date(ts); + Calendar c = Calendar.getInstance(); + c.setTimeZone(tz); + c.setTime(date); + return c.get(Calendar.MINUTE); + } + + public static boolean isHoliday(long ts) { + Date date = new Date(ts); + Calendar c = Calendar.getInstance(); + c.setTime(date); + int dayOfWeek = c.get(Calendar.DAY_OF_WEEK); + return dayOfWeek == 1 || dayOfWeek == 7; + } + + public static boolean isWorkHour(long ts) { + Date date = new Date(ts); + Calendar c = Calendar.getInstance(); + c.setTime(date); + int hour = c.get(Calendar.HOUR_OF_DAY); + + return hour > 8 && hour < 18; + } + + public static boolean isNightHour(long ts) { + Date date = new Date(ts); + Calendar c = Calendar.getInstance(); + c.setTime(date); + int hour = c.get(Calendar.HOUR_OF_DAY); + + return hour < 6 || hour >= 22; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecTelemetryGenerator.java new file mode 100644 index 0000000000..09d7e2dce2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecTelemetryGenerator.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.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Getter; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomDouble; + +public abstract class IncDecTelemetryGenerator extends TelemetryGenerator { + + protected final T strategy; + @Getter + protected double value; + protected double endValue; + + @SuppressWarnings("unchecked") + public IncDecTelemetryGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + this.strategy = (T) telemetryProfile.getValueStrategy(); + this.value = getRandomStartValue(); + this.endValue = getRandomEndValue(); + } + + public void setValue(double value) { + this.value = value; + this.endValue = getRandomEndValue(); + } + + protected void put(ObjectNode values, double value) { + if (strategy.getPrecision() == 0) { + values.put(key, (int) value); + } else { + values.put(key, BigDecimal.valueOf(value) + .setScale(strategy.getPrecision(), RoundingMode.HALF_UP) + .doubleValue()); + } + } + + public double getRandomStartValue() { + return randomDouble(strategy.getMinStartValue(), strategy.getMaxStartValue()); + } + + public double getRandomEndValue() { + return randomDouble(strategy.getMinEndValue(), strategy.getMaxEndValue()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecValueStrategyDefinition.java new file mode 100644 index 0000000000..adc3100dab --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecValueStrategyDefinition.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.values; + + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public abstract class IncDecValueStrategyDefinition implements ValueStrategyDefinition { + + private int precision; + private double minStartValue; + private double maxStartValue; + private double minEndValue; + private double maxEndValue; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementTelemetryGenerator.java new file mode 100644 index 0000000000..d18f36017f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementTelemetryGenerator.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomDouble; + +public class IncrementTelemetryGenerator extends IncDecTelemetryGenerator { + + public IncrementTelemetryGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + } + + @Override + public void addValue(long ts, ObjectNode values) { + double step = randomDouble(strategy.getMinIncrement(), strategy.getMaxIncrement()); + double newValue = value + step; + value = Math.min(newValue, endValue); + put(values, value); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementValueStrategyDefinition.java new file mode 100644 index 0000000000..64d7bc0b4c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementValueStrategyDefinition.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.values; + + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class IncrementValueStrategyDefinition extends IncDecValueStrategyDefinition { + + private double minIncrement; + private double maxIncrement; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.INCREMENT; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalTelemetryGenerator.java new file mode 100644 index 0000000000..927eb5a76f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalTelemetryGenerator.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Getter; +import lombok.Setter; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.getMultiplier; +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomDouble; + +public class NaturalTelemetryGenerator extends TelemetryGenerator { + + private final NaturalValueStrategyDefinition strategy; + @Getter @Setter + private double value; + private boolean isIncrement; + private double lowValue; + private double highValue; + + public NaturalTelemetryGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + this.strategy = (NaturalValueStrategyDefinition) telemetryProfile.getValueStrategy(); + this.value = getRandomStartValue(); + this.lowValue = getRandomLowValue(); + this.highValue = getRandomHighValue(); + isIncrement = !strategy.isDecrementOnStart(); + } + + @Override + public void addValue(long ts, ObjectNode values) { + + double multiplier = getMultiplier(ts, strategy.getHolidayMultiplier(), strategy.getWorkHoursMultiplier(), strategy.getNightHoursMultiplier()); + + if (isIncrement) { + double step = randomDouble(strategy.getMinIncrement(), strategy.getMaxIncrement()); + value += step * multiplier; + if (value > highValue) { + value = highValue; + highValue = getRandomHighValue(); + isIncrement = false; + } + } else { + double step = randomDouble(strategy.getMinDecrement(), strategy.getMaxDecrement()); + value -= step * multiplier; + if (value < lowValue) { + value = lowValue; + lowValue = getRandomLowValue(); + isIncrement = true; + } + } + + put(values, value); + } + + private void put(ObjectNode values, double value) { + if (strategy.getPrecision() == 0) { + values.put(key, (int) value); + } else { + values.put(key, BigDecimal.valueOf(value) + .setScale(strategy.getPrecision(), RoundingMode.HALF_UP) + .doubleValue()); + } + } + + public double getRandomStartValue() { + return randomDouble(strategy.getMinStartValue(), strategy.getMaxStartValue()); + } + + public double getRandomLowValue() { + return randomDouble(strategy.getMinLowValue(), strategy.getMaxLowValue()); + } + + public double getRandomHighValue() { + return randomDouble(strategy.getMinHighValue(), strategy.getMaxHighValue()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalValueStrategyDefinition.java new file mode 100644 index 0000000000..3141baa47a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalValueStrategyDefinition.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class NaturalValueStrategyDefinition implements ValueStrategyDefinition { + private int precision; + private double minStartValue; + private double maxStartValue; + private double minLowValue; + private double maxLowValue; + private double minHighValue; + private double maxHighValue; + private double minIncrement; + private double maxIncrement; + private double minDecrement; + private double maxDecrement; + private double holidayMultiplier; + private double workHoursMultiplier; + private double nightHoursMultiplier; + private boolean decrementOnStart; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.NATURAL; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyDefinition.java new file mode 100644 index 0000000000..4804863bd3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyDefinition.java @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.values; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class ScheduleValueStrategyDefinition implements ValueStrategyDefinition { + + private List schedule; + private String timeZone; + private ValueStrategyDefinition defaultDefinition; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.SCHEDULE; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyGenerator.java new file mode 100644 index 0000000000..1d067a3044 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyGenerator.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TimeZone; + +public class ScheduleValueStrategyGenerator extends TelemetryGenerator { + + private TelemetryGenerator defaultGenerator; + private TimeZone timeZone; + private Map scheduleGenerators; + private TelemetryGenerator prevGenerator; + + public ScheduleValueStrategyGenerator(TelemetryProfile tp) { + super(tp); + var def = (ScheduleValueStrategyDefinition) tp.getValueStrategy(); + timeZone = TimeZone.getTimeZone(def.getTimeZone()); + defaultGenerator = TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), def.getDefaultDefinition())); + scheduleGenerators = new LinkedHashMap<>(); + for (ValueStrategySchedule scheduleItem : def.getSchedule()) { + scheduleGenerators.put(scheduleItem, TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), scheduleItem.getDefinition()))); + } + } + + @Override + public void addValue(long ts, ObjectNode values) { + int hour = GeneratorTools.getHour(timeZone, ts); + int minute = GeneratorTools.getMinute(timeZone, ts); + TelemetryGenerator generator = scheduleGenerators.entrySet().stream().filter(pair -> { + var schedule = pair.getKey(); + if (hour == schedule.getStartHour() && hour == schedule.getEndHour()) { + return schedule.getStartMinute() <= minute && minute <= schedule.getEndMinute(); + } else if (hour == schedule.getStartHour() && hour < schedule.getEndHour()) { + return schedule.getStartMinute() <= minute; + } else if (hour > schedule.getStartHour() && hour < schedule.getEndHour()) { + return true; + } else if (hour > schedule.getStartHour() && hour == schedule.getEndHour()) { + return minute <= schedule.getEndHour(); + } else { + return false; + } + }).map(Map.Entry::getValue).findFirst().orElse(defaultGenerator); + + if (prevGenerator != null && !prevGenerator.equals(generator)) { + generator.setValue(prevGenerator.getValue()); + } + + generator.addValue(ts, values); + + prevGenerator = generator; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyDefinition.java new file mode 100644 index 0000000000..d523715d67 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyDefinition.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.values; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; + + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SequenceValueStrategyDefinition implements ValueStrategyDefinition { + private boolean random; + private ObjectNode telemetry; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.SEQUENCE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyGenerator.java new file mode 100644 index 0000000000..d7c75025f4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyGenerator.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import java.util.Random; + +public class SequenceValueStrategyGenerator extends TelemetryGenerator { + + private final SequenceValueStrategyDefinition strategy; + private int max; + private int index; + + public SequenceValueStrategyGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + this.strategy = (SequenceValueStrategyDefinition) telemetryProfile.getValueStrategy(); + max = strategy.getTelemetry().fields().next().getValue().size() - 1; + index = strategy.isRandom() ? new Random().nextInt(max + 1) : 0; + + } + + @Override + public void addValue(long ts, ObjectNode values) { + strategy.getTelemetry().fields().forEachRemaining(entry -> { + String key = entry.getKey(); + values.set(key, entry.getValue().get(index)); + }); + index++; + if (index > max) { + index = 0; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGenerator.java new file mode 100644 index 0000000000..5024f4e590 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGenerator.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +@Data +public abstract class TelemetryGenerator { + protected final TelemetryProfile profile; + protected final String key; + + public TelemetryGenerator(TelemetryProfile telemetryProfile) { + this.profile = telemetryProfile; + this.key = telemetryProfile.getKey(); + } + + public double getValue() { + throw new RuntimeException("Not supported"); + } + + public void setValue(double value) { + throw new RuntimeException("Not supported"); + } + + public abstract void addValue(long ts, ObjectNode values); +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGeneratorFactory.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGeneratorFactory.java new file mode 100644 index 0000000000..17d29ce3e1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGeneratorFactory.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.solutions.data.values; + + +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +public class TelemetryGeneratorFactory { + + public static TelemetryGenerator create(TelemetryProfile tp) { + switch (tp.getValueStrategy().getStrategyType()) { + case COUNTER: + return new CounterTelemetryGenerator(tp); + case NATURAL: + return new NaturalTelemetryGenerator(tp); + case EVENT: + return new EventTelemetryGenerator(tp); + case CONSTANT: + return new ConstantTelemetryGenerator(tp); + case SEQUENCE: + return new SequenceValueStrategyGenerator(tp); + case COMPOSITE: + return new CompositeValueStrategyGenerator(tp); + case SCHEDULE: + return new ScheduleValueStrategyGenerator(tp); + case INCREMENT: + return new IncrementTelemetryGenerator(tp); + case DECREMENT: + return new DecrementTelemetryGenerator(tp); + default: + throw new RuntimeException("Not supported!"); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinition.java new file mode 100644 index 0000000000..34d5f9b5e7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinition.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.service.solutions.data.values; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = CounterValueStrategyDefinition.class, name = "counter"), + @JsonSubTypes.Type(value = NaturalValueStrategyDefinition.class, name = "natural"), + @JsonSubTypes.Type(value = EventValueStrategyDefinition.class, name = "event"), + @JsonSubTypes.Type(value = SequenceValueStrategyDefinition.class, name = "sequence"), + @JsonSubTypes.Type(value = ConstantValueStrategyDefinition.class, name = "constant"), + @JsonSubTypes.Type(value = CompositeValueStrategyDefinition.class, name = "composite"), + @JsonSubTypes.Type(value = ScheduleValueStrategyDefinition.class, name = "schedule"), + @JsonSubTypes.Type(value = IncrementValueStrategyDefinition.class, name = "inc"), + @JsonSubTypes.Type(value = DecrementValueStrategyDefinition.class, name = "dec")}) +public interface ValueStrategyDefinition { + + ValueStrategyDefinitionType getStrategyType(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinitionType.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinitionType.java new file mode 100644 index 0000000000..a561d6469e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinitionType.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data.values; + +public enum ValueStrategyDefinitionType { + + COUNTER, NATURAL, EVENT, SEQUENCE, CONSTANT, COMPOSITE, SCHEDULE, INCREMENT, DECREMENT; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategySchedule.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategySchedule.java new file mode 100644 index 0000000000..0bb2ccf8fa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategySchedule.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.service.solutions.data.values; + +import lombok.Data; + +@Data +public class ValueStrategySchedule { + + private int startHour; + private int startMinute; + + private int endHour; + private int endMinute; + + private ValueStrategyDefinition definition; + +} diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 2c24caa1f2..f678076c57 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1138,6 +1138,8 @@ transport: timeout: "${CLIENT_SIDE_RPC_TIMEOUT:60000}" # Enable/disable http/mqtt/coap/lwm2m transport protocols (has higher priority than certain protocol's 'enabled' property) api_enabled: "${TB_TRANSPORT_API_ENABLED:true}" + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" log: # Enable/Disable log of transport messages to telemetry. For example, logging of LwM2M registration update enabled: "${TB_TRANSPORT_LOG_ENABLED:true}" @@ -2165,3 +2167,12 @@ mqtt: # The actual delay is randomized within a range defined by multiplying the base delay by a factor between (1 - jitter_factor) and (1 + jitter_factor). # For example, a jitter_factor of 0.15 means the actual delay may vary by up to ±15% of the base delay. jitter_factor: "${TB_MQTT_CLIENT_RETRANSMISSION_JITTER_FACTOR:0.15}" +iot-hub: + base-url: "${IOT_HUB_BASE_URL:https://iot-hub.thingsboard.io}" # IoT Hub base URL for fetching published items, resources, etc. + connect-timeout-sec: "${IOT_HUB_CONNECT_TIMEOUT_SEC:5}" # timeout in seconds to connect to IoT Hub server. + read-timeout-sec: "${IOT_HUB_READ_TIMEOUT_SEC:10}" # timeout in seconds to read from IoT Hub server. + max-file-data-size-bytes: "${IOT_HUB_MAX_FILE_DATA_SIZE_BYTES:104857600}" # maximum size in bytes of a file-data payload fetched from IoT Hub. Requests advertising a larger Content-Length, or streams that exceed this size, are rejected to avoid out-of-memory issues on the platform side. + max-uncompressed-archive-bytes: "${IOT_HUB_MAX_UNCOMPRESSED_ARCHIVE_BYTES:209715200}" # maximum cumulative uncompressed size in bytes for a solution template archive. Extraction aborts past this threshold to mitigate zip-bomb attacks. + max-uncompressed-entry-bytes: "${IOT_HUB_MAX_UNCOMPRESSED_ENTRY_BYTES:52428800}" # maximum uncompressed size in bytes for any single entry inside a solution template archive. + max-archive-entry-count: "${IOT_HUB_MAX_ARCHIVE_ENTRY_COUNT:10000}" # maximum number of entries allowed inside a solution template archive. + max-install-timeout-ms: "${IOT_HUB_MAX_INSTALL_TIMEOUT_MS:60000}" # upper clamp in milliseconds for the post-install wait declared in a solution template's solution.json. Prevents a malicious or misconfigured template from pinning an HTTP worker thread. 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 bbf3a3467e..2b70e04a85 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -210,6 +210,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { private static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant"; protected static final String CUSTOMER_USER_EMAIL = "testcustomer@thingsboard.org"; + protected static final String SECOND_CUSTOMER_USER_EMAIL = "testsecondcustomer@thingsboard.org"; private static final String CUSTOMER_USER_PASSWORD = "customer"; protected static final String DIFFERENT_CUSTOMER_USER_EMAIL = "testdifferentcustomer@thingsboard.org"; @@ -247,6 +248,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected CustomerId differentTenantCustomerId; protected UserId customerUserId; + protected UserId secondCustomerUserId; protected UserId differentCustomerUserId; protected UserId differentTenantCustomerUserId; @@ -372,9 +374,17 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { customerUser.setCustomerId(savedCustomer.getId()); customerUser.setEmail(CUSTOMER_USER_EMAIL); - customerUser = createUserAndLogin(customerUser, CUSTOMER_USER_PASSWORD); + customerUser = createUserAndActivate(customerUser, CUSTOMER_USER_PASSWORD); customerUserId = customerUser.getId(); + User secondCustomerUser = new User(); + secondCustomerUser.setAuthority(Authority.CUSTOMER_USER); + secondCustomerUser.setTenantId(tenantId); + secondCustomerUser.setCustomerId(customerId); + secondCustomerUser.setEmail(SECOND_CUSTOMER_USER_EMAIL); + secondCustomerUser = createUserAndActivate(secondCustomerUser, CUSTOMER_USER_PASSWORD); + secondCustomerUserId = secondCustomerUser.getId(); + resetTokens(); log.debug("Executed web test setup"); @@ -472,6 +482,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { login(CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD); } + protected void loginSecondCustomerUser() throws Exception { + login(SECOND_CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD); + } + protected void loginUser(String userName, String password) throws Exception { login(userName, password); } @@ -586,6 +600,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return savedUser; } + protected User createUserAndActivate(User user, String password) throws Exception { + User savedUser = doPost("/api/user", user, User.class); + JsonNode activateRequest = getActivateRequest(password); + doPost("/api/noauth/activate", activateRequest).andExpect(status().isOk()); + return savedUser; + } + protected User createUser(User user, String password) throws Exception { User savedUser = doPost("/api/user", user, User.class); JsonNode activateRequest = getActivateRequest(password); 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 ba86d60852..bebfe832e8 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java @@ -160,6 +160,25 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.UPDATED_COMMENT, 1, updatedAlarmComment); } + @Test + public void testEditOthersAlarmCommentIsProhibited() throws Exception { + loginCustomerUser(); + AlarmComment alarmComment = createAlarmComment(alarm.getId()); + + JsonNode newComment = JacksonUtil.newObjectNode().set("text", new TextNode("Second customer rewrite")); + alarmComment.setComment(newComment); + + loginSecondCustomerUser(); + doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString("User is not allowed to edit other user's comment"))); + + loginTenantAdmin(); + doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString("User is not allowed to edit other user's comment"))); + } + @Test public void testUpdateAlarmViaDifferentTenant() throws Exception { loginTenantAdmin(); @@ -209,12 +228,36 @@ 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", + .comment(JacksonUtil.newObjectNode().put("text", String.format("Comment was deleted by user %s", CUSTOMER_USER_EMAIL))) .build(); testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); } + @Test + public void testDeleteOthersAlarmCommentIsAllowedForAuthorOrTenantAdmin() throws Exception { + loginCustomerUser(); + AlarmComment alarmComment = createAlarmComment(alarm.getId()); + + loginSecondCustomerUser(); + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString("User is not allowed to delete other user's comment"))); + + loginTenantAdmin(); + doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId()) + .andExpect(status().isOk()); + AlarmComment expectedAlarmComment = AlarmComment.builder() + .alarmId(alarm.getId()) + .type(AlarmCommentType.SYSTEM) + .comment(JacksonUtil.newObjectNode().put("text", String.format("Comment was deleted by user %s", + TENANT_ADMIN_EMAIL))) + .build(); + testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); + } + @Test public void testDeleteAlarmViaTenant() throws Exception { loginTenantAdmin(); @@ -234,13 +277,13 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { assertThat(systemComment.getId()).isEqualTo(alarmComment.getId()); assertThat(systemComment.getType()).isEqualTo(AlarmCommentType.SYSTEM); - assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("User %s deleted his comment", + assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("Comment was deleted by user %s", TENANT_ADMIN_EMAIL)); AlarmComment expectedAlarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) - .comment(JacksonUtil.newObjectNode().put("text", String.format("User %s deleted his comment", + .comment(JacksonUtil.newObjectNode().put("text", String.format("Comment was deleted by user %s", 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/HomePageApiTest.java b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java index ee2aefc81a..d1621b29ec 100644 --- a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java @@ -410,7 +410,7 @@ public class HomePageApiTest extends AbstractControllerTest { Assert.assertEquals(1, usageInfo.getCustomers()); Assert.assertEquals(configuration.getMaxCustomers(), usageInfo.getMaxCustomers()); - Assert.assertEquals(2, usageInfo.getUsers()); + Assert.assertEquals(3, usageInfo.getUsers()); Assert.assertEquals(configuration.getMaxUsers(), usageInfo.getMaxUsers()); Assert.assertEquals(DEFAULT_DASHBOARDS_COUNT, usageInfo.getDashboards()); @@ -476,7 +476,7 @@ public class HomePageApiTest extends AbstractControllerTest { } usageInfo = doGet("/api/usage", UsageInfo.class); - Assert.assertEquals(users.size() + 2, usageInfo.getUsers()); + Assert.assertEquals(users.size() + 3, usageInfo.getUsers()); List dashboards = new ArrayList<>(); for (int i = 0; i < 97; i++) { 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 7d7131df96..cae55287b6 100644 --- a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java @@ -682,6 +682,7 @@ public class UserControllerTest extends AbstractControllerTest { String email = "testEmail1"; List expectedCustomerUserIds = new ArrayList<>(); expectedCustomerUserIds.add(customerUserId); + expectedCustomerUserIds.add(secondCustomerUserId); for (int i = 0; i < 45; i++) { User customerUser = createCustomerUser(customerId); customerUser.setEmail(email + StringUtils.randomAlphanumeric((int) (5 + Math.random() * 10)) + "@thingsboard.org"); diff --git a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java index fb9807a2a8..c2f7c39ce5 100644 --- a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java +++ b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java @@ -15,20 +15,29 @@ */ package org.thingsboard.server.service.ai; -import com.google.cloud.vertexai.api.GenerationConfig; +import dev.langchain4j.model.ModelProvider; import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequestParameters; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.ResourceLock; -import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.SsrfProtectionValidator; +import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; @@ -53,18 +62,280 @@ class Langchain4jChatModelConfigurerImplTest { private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl(); - @BeforeEach - void enableSsrfProtection() { - SsrfProtectionValidator.setEnabled(true); - } - @AfterEach - void disableSsrfProtection() { + void resetSsrfProtection() { SsrfProtectionValidator.setEnabled(false); } + // ============================== Configuration correctness (one per provider) ============================== + // For each provider we feed a fully populated config and assert that the returned ChatModel carries the same + // values, using only the public ChatModel surface (provider() and defaultRequestParameters()) — no reflection. + + @Test + void shouldConfigureOpenAiModel_whenGivenOpenAiConfig() { + // GIVEN + var config = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("https://api.openai.com/v1") + .apiKey("test-key") + .build()) + .modelId("gpt-4o") + .temperature(0.7) + .topP(0.9) + .frequencyPenalty(0.5) + .presencePenalty(0.25) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.OPEN_AI); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("gpt-4o"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.frequencyPenalty()).isEqualTo(0.5); + assertThat(params.presencePenalty()).isEqualTo(0.25); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureAzureOpenAiModel_whenGivenAzureOpenAiConfig() { + // GIVEN + var config = AzureOpenAiChatModelConfig.builder() + .providerConfig(new AzureOpenAiProviderConfig( + "https://my-resource.openai.azure.com/", "2024-05-01-preview", "test-key")) + .modelId("gpt-4o") + .temperature(0.7) + .topP(0.9) + .frequencyPenalty(0.5) + .presencePenalty(0.25) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.AZURE_OPEN_AI); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("gpt-4o"); // deployment name maps to modelName + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.frequencyPenalty()).isEqualTo(0.5); + assertThat(params.presencePenalty()).isEqualTo(0.25); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureGoogleAiGeminiModel_whenGivenGoogleAiGeminiConfig() { + // GIVEN + var config = GoogleAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleAiGeminiProviderConfig("test-key")) + .modelId("gemini-2.5-flash") + .temperature(0.7) + .topP(0.9) + .topK(40) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.GOOGLE_GENAI); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("gemini-2.5-flash"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.topK()).isEqualTo(40); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureGoogleVertexAiGeminiModel_whenGivenGoogleVertexAiGeminiConfig() { + // GIVEN + var config = GoogleVertexAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleVertexAiGeminiProviderConfig( + "key.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY)) + .modelId("gemini-2.5-flash") + .temperature(0.7) + .topP(0.9) + .topK(40) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.GOOGLE_GENAI); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("gemini-2.5-flash"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.topK()).isEqualTo(40); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureMistralAiModel_whenGivenMistralAiConfig() { + // GIVEN + var config = MistralAiChatModelConfig.builder() + .providerConfig(new MistralAiProviderConfig("test-key")) + .modelId("mistral-large-latest") + .temperature(0.7) + .topP(0.9) + .frequencyPenalty(0.5) + .presencePenalty(0.25) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.MISTRAL_AI); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("mistral-large-latest"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.frequencyPenalty()).isEqualTo(0.5); + assertThat(params.presencePenalty()).isEqualTo(0.25); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureAnthropicModel_whenGivenAnthropicConfig() { + // GIVEN + var config = AnthropicChatModelConfig.builder() + .providerConfig(new AnthropicProviderConfig("test-key")) + .modelId("claude-opus-4-8") + .temperature(0.7) + .topP(0.9) + .topK(40) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.ANTHROPIC); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("claude-opus-4-8"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.topK()).isEqualTo(40); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + @Test - void configureChatModel_openAi_withPrivateIp_shouldThrow() { + void shouldConfigureAmazonBedrockModel_whenGivenAmazonBedrockConfig() { + // GIVEN + var config = AmazonBedrockChatModelConfig.builder() + .providerConfig(new AmazonBedrockProviderConfig( + "us-east-1", "test-access-key-id", "test-secret-access-key")) + .modelId("anthropic.claude-3-5-sonnet-20240620-v1:0") + .temperature(0.7) + .topP(0.9) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.AMAZON_BEDROCK); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("anthropic.claude-3-5-sonnet-20240620-v1:0"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureGitHubModelsModel_whenGivenGitHubModelsConfig() { + // GIVEN + var config = GitHubModelsChatModelConfig.builder() + .providerConfig(new GitHubModelsProviderConfig("ghp-test-token")) + .modelId("gpt-4o") + .temperature(0.7) + .topP(0.9) + .frequencyPenalty(0.5) + .presencePenalty(0.25) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.GITHUB_MODELS); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("gpt-4o"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.frequencyPenalty()).isEqualTo(0.5); + assertThat(params.presencePenalty()).isEqualTo(0.25); + assertThat(params.maxOutputTokens()).isEqualTo(500); // maxCompletionTokens maps to maxOutputTokens + } + + @Test + void shouldConfigureOllamaModel_whenGivenOllamaConfig() { + // GIVEN + var config = OllamaChatModelConfig.builder() + .providerConfig(new OllamaProviderConfig( + "http://localhost:11434", new OllamaProviderConfig.OllamaAuth.None())) + .modelId("llama3") + .temperature(0.7) + .topP(0.9) + .topK(40) + .contextLength(4096) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.OLLAMA); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("llama3"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.topK()).isEqualTo(40); + assertThat(params.maxOutputTokens()).isEqualTo(500); // numPredict maps to maxOutputTokens + } + + // ============================== Base URL SSRF validation ============================== + // Providers that accept a user-supplied base URL must reject hosts that resolve to private/loopback addresses + // when SSRF protection is enabled. + + @Test + void shouldThrow_whenOpenAiBaseUrlIsPrivateIp() { + // GIVEN + SsrfProtectionValidator.setEnabled(true); var config = OpenAiChatModelConfig.builder() .providerConfig(OpenAiProviderConfig.builder() .baseUrl("http://172.17.0.1:8080/") @@ -73,13 +344,16 @@ class Langchain4jChatModelConfigurerImplTest { .modelId("gpt-4o") .build(); + // WHEN / THEN assertThatThrownBy(() -> configurer.configureChatModel(config)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("URI is invalid"); } @Test - void configureChatModel_openAi_withLocalhostUrl_shouldThrow() { + void shouldThrow_whenOpenAiBaseUrlIsLocalhost() { + // GIVEN + SsrfProtectionValidator.setEnabled(true); var config = OpenAiChatModelConfig.builder() .providerConfig(OpenAiProviderConfig.builder() .baseUrl("http://localhost:22/") @@ -88,57 +362,42 @@ class Langchain4jChatModelConfigurerImplTest { .modelId("gpt-4o") .build(); + // WHEN / THEN assertThatThrownBy(() -> configurer.configureChatModel(config)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("URI is invalid"); } @Test - void configureChatModel_azureOpenAi_withPrivateIp_shouldThrow() { + void shouldThrow_whenAzureOpenAiEndpointIsPrivateIp() { + // GIVEN + SsrfProtectionValidator.setEnabled(true); var config = AzureOpenAiChatModelConfig.builder() .providerConfig(new AzureOpenAiProviderConfig( "http://10.0.0.1:8080/", null, "test-key")) .modelId("gpt-4o") .build(); + // WHEN / THEN assertThatThrownBy(() -> configurer.configureChatModel(config)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("URI is invalid"); } @Test - void configureChatModel_ollama_withPrivateIp_shouldThrow() { + void shouldThrow_whenOllamaBaseUrlIsPrivateIp() { + // GIVEN + SsrfProtectionValidator.setEnabled(true); var config = OllamaChatModelConfig.builder() .providerConfig(new OllamaProviderConfig( "http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None())) .modelId("llama3") .build(); + // WHEN / THEN assertThatThrownBy(() -> configurer.configureChatModel(config)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("URI is invalid"); } - @Test - void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() { - // GIVEN - var providerConfig = new GoogleVertexAiGeminiProviderConfig( - "test.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY - ); - var chatModelConfig = GoogleVertexAiGeminiChatModelConfig.builder() - .providerConfig(providerConfig) - .modelId("gemini-2.0-flash") - .frequencyPenalty(0.3) - .presencePenalty(0.7) - .build(); - - // WHEN - ChatModel chatModel = configurer.configureChatModel(chatModelConfig); - - // THEN - var generationConfig = (GenerationConfig) ReflectionTestUtils.getField(chatModel, "generationConfig"); - assertThat(generationConfig.getFrequencyPenalty()).isEqualTo(0.3f); - assertThat(generationConfig.getPresencePenalty()).isEqualTo(0.7f); - } - } 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 0001566abd..723161ad87 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 @@ -140,7 +140,7 @@ public class SimpleCalculatedFieldStateTest { Output output = getCalculatedFieldConfig().getOutput(); assertThat(result.getType()).isEqualTo(output.getType()); assertThat(result.getScope()).isEqualTo(output.getScope()); - assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49))); + assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49L))); } @Test @@ -170,7 +170,7 @@ public class SimpleCalculatedFieldStateTest { Output output = getCalculatedFieldConfig().getOutput(); assertThat(result.getType()).isEqualTo(output.getType()); assertThat(result.getScope()).isEqualTo(output.getScope()); - assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35))); + assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35L))); } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSessionTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSessionTest.java new file mode 100644 index 0000000000..98931df813 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSessionTest.java @@ -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. + */ +package org.thingsboard.server.service.edge.rpc; + +import io.grpc.stub.StreamObserver; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.gen.edge.v1.ResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.QueueConsumerManager; +import org.thingsboard.server.queue.discovery.TopicService; +import org.thingsboard.server.queue.kafka.KafkaAdmin; +import org.thingsboard.server.queue.provider.TbCoreQueueFactory; +import org.thingsboard.server.service.edge.EdgeContextComponent; + +import java.util.Collections; +import java.util.List; +import java.util.Queue; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class KafkaEdgeGrpcSessionTest { + + private static final long POLL_INTERVAL_MS = 20L; + + private EdgeContextComponent ctx; + private TbCoreQueueFactory tbCoreQueueFactory; + private KafkaEdgeGrpcSession session; + + @BeforeEach + void setUp() { + ctx = mock(EdgeContextComponent.class); + TopicService topicService = mock(TopicService.class); + tbCoreQueueFactory = mock(TbCoreQueueFactory.class); + KafkaAdmin kafkaAdmin = mock(KafkaAdmin.class); + @SuppressWarnings("unchecked") + StreamObserver outputStream = mock(StreamObserver.class); + + session = new KafkaEdgeGrpcSession(ctx, topicService, tbCoreQueueFactory, kafkaAdmin, outputStream, + (edgeId, s) -> {}, (edge, uuid) -> {}, null, 0, 0); + + ReflectionTestUtils.setField(session, "edge", new Edge(new EdgeId(UUID.randomUUID()))); + ReflectionTestUtils.setField(session, "tenantId", TenantId.fromUUID(UUID.randomUUID())); + } + + @AfterEach + void tearDown() { + if (session != null) { + session.destroy(); + } + } + + @Test + void readyOnlyWhenConnectedNotSyncingNotHighPriority() { + setReadinessFlags(true, false, false); + assertThat(isReadyToProcessGeneralEvents()) + .as("connected, not syncing, no high-priority work -> ready") + .isTrue(); + } + + @Test + void notReadyWhenDisconnected() { + setReadinessFlags(false, false, false); + assertThat(isReadyToProcessGeneralEvents()) + .as("disconnected -> not ready") + .isFalse(); + } + + @Test + void notReadyWhileSyncInProgress() { + setReadinessFlags(true, true, false); + assertThat(isReadyToProcessGeneralEvents()) + .as("sync in progress -> not ready (this is the window where events were being dropped)") + .isFalse(); + } + + @Test + void notReadyWhileHighPriorityProcessing() { + setReadinessFlags(true, false, true); + assertThat(isReadyToProcessGeneralEvents()) + .as("high-priority processing -> not ready") + .isFalse(); + } + + @Test + void processEdgeEventsWiresReadinessPredicateIntoConsumerGate() { + // processEdgeEvents() builds the consumer lazily; stub just enough of that path. + EdgeEventStorageSettings storageSettings = new EdgeEventStorageSettings(); + storageSettings.setNoRecordsSleepInterval(1000L); + when(ctx.getEdgeEventStorageSettings()).thenReturn(storageSettings); + + @SuppressWarnings("unchecked") + TbQueueConsumer> queueConsumer = mock(TbQueueConsumer.class); + // Report stopped so the launched consumer loop exits immediately - this test asserts on wiring, not polling. + when(queueConsumer.isStopped()).thenReturn(true); + when(tbCoreQueueFactory.createEdgeEventMsgConsumer(any(), any())).thenReturn(queueConsumer); + + // The consumer is only started when the session is ready. + setReadinessFlags(true, false, false); + session.processEdgeEvents(); + + QueueConsumerManager> manager = session.getConsumer(); + assertThat(manager).as("processEdgeEvents must build the consumer when ready").isNotNull(); + + BooleanSupplier readinessCheck = (BooleanSupplier) ReflectionTestUtils.getField(manager, "readinessCheck"); + assertThat(readinessCheck) + .as("the edge consumer must be wired with a readinessCheck (the .readinessCheck(...) builder line)") + .isNotNull(); + + // It must be the live predicate, not a snapshot: flipping the session's state must flip the gate. + assertThat(readinessCheck.getAsBoolean()).as("ready session -> gate open").isTrue(); + setReadinessFlags(true, true, false); + assertThat(readinessCheck.getAsBoolean()).as("sync starts -> gate closes, consumer pauses polling").isFalse(); + } + + @Test + void eventArrivingDuringSyncIsHeldByTheEdgeConsumerUntilSyncCompletes() { + EdgeEventStorageSettings storageSettings = new EdgeEventStorageSettings(); + storageSettings.setNoRecordsSleepInterval(POLL_INTERVAL_MS); + when(ctx.getEdgeEventStorageSettings()).thenReturn(storageSettings); + + RecordingEdgeEventConsumer queueConsumer = new RecordingEdgeEventConsumer(); + when(tbCoreQueueFactory.createEdgeEventMsgConsumer(any(), any())).thenReturn(queueConsumer); + + // The consumer is launched only while the session is ready - that is how it starts in production. + setReadinessFlags(true, false, false); + session.processEdgeEvents(); + + // Sync starts: the gate closes. Wait until the loop has actually parked on it (poll count stops advancing) + // before enqueuing - otherwise we would race an in-flight poll() and the test would be non-deterministic. + setReadinessFlags(true, true, false); + awaitParkedOnClosedGate(queueConsumer); + + // An event lands in the edge-event topic during the sync window - exactly the case that used to be dropped. + @SuppressWarnings("unchecked") + TbProtoQueueMsg event = mock(TbProtoQueueMsg.class); + int pollsBeforeEvent = queueConsumer.getPollCount(); + queueConsumer.enqueue(List.of(event)); + + // While sync is in progress the consumer stays parked: it neither polls nor consumes the event, + // so the event survives in the queue instead of being read-and-skipped. + sleepQuietly(POLL_INTERVAL_MS * 5); + assertThat(queueConsumer.getPolledEvents()) + .as("event must not be polled while sync is in progress (it must stay queued, not be dropped)") + .isEmpty(); + assertThat(queueConsumer.getPollCount()) + .as("consumer must not poll at all while the gate is closed") + .isEqualTo(pollsBeforeEvent); + + // Sync completes: the gate opens and the held event is finally picked up by the consumer. + // (We assert at the poll boundary - the actual drop point - since the downlink-send path that + // processMsgs drives afterwards is not reachable from a unit test.) + setReadinessFlags(true, false, false); + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(queueConsumer.getPolledEvents()) + .as("event held during sync must be picked up once sync completes, not lost") + .hasSize(1)); + } + + private boolean isReadyToProcessGeneralEvents() { + return Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(session, "isReadyToProcessGeneralEvents")); + } + + private void setReadinessFlags(boolean connected, boolean syncInProgress, boolean highPriorityProcessing) { + ReflectionTestUtils.setField(session, "connected", connected); + ReflectionTestUtils.setField(session, "syncInProgress", syncInProgress); + ReflectionTestUtils.setField(session, "isHighPriorityProcessing", highPriorityProcessing); + } + + private static void awaitParkedOnClosedGate(RecordingEdgeEventConsumer consumer) { + await().atMost(5, TimeUnit.SECONDS).until(() -> { + int before = consumer.getPollCount(); + // Several poll intervals with no new poll means the loop is parked on the readiness gate rather than + // blocked inside poll(), so it is safe to enqueue without racing an in-flight read. + sleepQuietly(POLL_INTERVAL_MS * 5); + return consumer.getPollCount() == before; + }); + } + + private static void sleepQuietly(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** + * Fake edge-event consumer that records what it was actually polled, so a test can prove an event stayed in the + * queue while the readiness gate was closed and was only read once it opened. + */ + private static class RecordingEdgeEventConsumer implements TbQueueConsumer> { + + private final Queue>> pending = new ConcurrentLinkedQueue<>(); + private final List> polledEvents = new CopyOnWriteArrayList<>(); + private final AtomicInteger pollCount = new AtomicInteger(); + private volatile boolean stopped; + + void enqueue(List> batch) { + pending.add(batch); + } + + int getPollCount() { + return pollCount.get(); + } + + List> getPolledEvents() { + return polledEvents; + } + + @Override + public List> poll(long durationInMillis) { + pollCount.incrementAndGet(); + List> batch = pending.poll(); + if (batch != null) { + polledEvents.addAll(batch); + return batch; + } + sleepQuietly(durationInMillis); + return Collections.emptyList(); + } + + @Override + public String getTopic() { + return "test-edge-event-topic"; + } + + @Override + public void subscribe() { + } + + @Override + public void subscribe(Set partitions) { + } + + @Override + public void stop() { + stopped = true; + } + + @Override + public void unsubscribe() { + stopped = true; + } + + @Override + public void commit() { + } + + @Override + public boolean isStopped() { + return stopped; + } + + @Override + public List getFullTopicNames() { + return Collections.emptyList(); + } + + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java new file mode 100644 index 0000000000..81cc325bdb --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java @@ -0,0 +1,141 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.entitiy.queue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.id.QueueId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.discovery.TopicService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbQueueServiceTest { + + @Mock + private QueueService queueServiceMock; + @Mock + private TbClusterService tbClusterServiceMock; + @Mock + private TbQueueAdmin tbQueueAdminMock; + + private TopicService topicService; + private DefaultTbQueueService tbQueueService; + + private final TenantId tenantId = TenantId.SYS_TENANT_ID; + + @BeforeEach + public void setUp() { + topicService = new TopicService(); + tbQueueService = new DefaultTbQueueService(queueServiceMock, tbClusterServiceMock, tbQueueAdminMock, topicService); + } + + private Queue newQueue(int partitions) { + Queue queue = new Queue(); + queue.setTenantId(tenantId); + queue.setName("testQueue"); + queue.setTopic("tb_rule_engine.testQueue"); + queue.setPartitions(partitions); + return queue; + } + + @Test + public void givenQueuePrefix_whenSaveQueue_thenCreatesPrefixedTopics() { + // queue.prefix = "thingsboard" (TB_QUEUE_PREFIX set) + ReflectionTestUtils.setField(topicService, "prefix", "thingsboard"); + + Queue queue = newQueue(2); + when(queueServiceMock.saveQueue(queue)).thenReturn(queue); + + tbQueueService.saveQueue(queue); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean()); + + // All created topics must carry the prefix - this is the fix. + assertThat(topicCaptor.getAllValues()) + .containsExactlyInAnyOrder( + "thingsboard.tb_rule_engine.testQueue.0", + "thingsboard.tb_rule_engine.testQueue.1"); + // No unprefixed (orphan-prone) topic must ever be created. + assertThat(topicCaptor.getAllValues()) + .noneMatch(topic -> topic.equals("tb_rule_engine.testQueue.0") + || topic.equals("tb_rule_engine.testQueue.1")); + } + + @Test + public void givenNoQueuePrefix_whenSaveQueue_thenCreatesUnprefixedTopics() { + // queue.prefix blank (TB_QUEUE_PREFIX not set) - default behavior preserved + ReflectionTestUtils.setField(topicService, "prefix", ""); + + Queue queue = newQueue(2); + when(queueServiceMock.saveQueue(queue)).thenReturn(queue); + + tbQueueService.saveQueue(queue); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean()); + + assertThat(topicCaptor.getAllValues()) + .containsExactlyInAnyOrder( + "tb_rule_engine.testQueue.0", + "tb_rule_engine.testQueue.1"); + } + + @Test + public void givenQueuePrefix_whenIncreasePartitions_thenOnlyNewPartitionsCreatedPrefixed() { + ReflectionTestUtils.setField(topicService, "prefix", "thingsboard"); + + Queue oldQueue = newQueue(2); + oldQueue.setId(new QueueId(UUID.randomUUID())); + Queue updatedQueue = newQueue(4); + updatedQueue.setId(oldQueue.getId()); + + when(queueServiceMock.findQueueById(tenantId, updatedQueue.getId())).thenReturn(oldQueue); + when(queueServiceMock.saveQueue(updatedQueue)).thenReturn(updatedQueue); + + tbQueueService.saveQueue(updatedQueue); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean()); + + assertThat(topicCaptor.getAllValues()) + .containsExactlyInAnyOrder( + "thingsboard.tb_rule_engine.testQueue.2", + "thingsboard.tb_rule_engine.testQueue.3"); + verify(tbQueueAdminMock, never()).createTopicIfNotExists(eq("thingsboard.tb_rule_engine.testQueue.0"), any(), anyBoolean()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/iot_hub/DefaultIotHubServiceTest.java b/application/src/test/java/org/thingsboard/server/service/iot_hub/DefaultIotHubServiceTest.java new file mode 100644 index 0000000000..c252b58103 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/iot_hub/DefaultIotHubServiceTest.java @@ -0,0 +1,369 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.iot_hub; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.iot_hub.DashboardInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; +import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.util.List; +import java.util.UUID; + +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.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultIotHubServiceTest { + + private final TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + private final SecurityUser user = mock(SecurityUser.class); + + @Mock + private IotHubRestClient iotHubRestClient; + @Mock + private IotHubInstalledItemService iotHubInstalledItemService; + + @Spy + @InjectMocks + private DefaultIotHubService service; + + private void mockTenant() { + when(user.getTenantId()).thenReturn(tenantId); + } + + private IotHubInstalledItem installedItem(UUID id) { + IotHubInstalledItem item = new IotHubInstalledItem(); + item.setId(new IotHubInstalledItemId(id)); + item.setTenantId(tenantId); + return item; + } + + private InstallPlanEntry willInstall(String versionId, boolean root) { + InstallPlanEntry entry = new InstallPlanEntry(); + entry.setItemId(UUID.randomUUID().toString()); + entry.setVersionId(versionId); + entry.setName(root ? "Root" : "Dep"); + entry.setStatus(InstallPlanEntry.Status.WILL_INSTALL); + entry.setRoot(root); + return entry; + } + + private ObjectNode version(String id, String itemId, String type, String name, String ver) { + ObjectNode node = JacksonUtil.newObjectNode(); + node.put("id", id); + node.put("itemId", itemId); + node.put("type", type); + node.put("name", name); + node.put("version", ver); + return node; + } + + @Test + void resolveInstallPlan_rootOnly_noRelated_marksWillInstall() { + mockTenant(); + String versionId = UUID.randomUUID().toString(); + String itemId = UUID.randomUUID().toString(); + when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(version(versionId, itemId, "DASHBOARD", "Root", "1.0")); + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of()); + + InstallPlan plan = service.resolveInstallPlan(user, versionId); + + assertThat(plan.getEntries()).hasSize(1); + InstallPlanEntry root = plan.getEntries().get(0); + assertThat(root.isRoot()).isTrue(); + assertThat(root.getStatus()).isEqualTo(InstallPlanEntry.Status.WILL_INSTALL); + assertThat(root.getItemId()).isEqualTo(itemId); + } + + @Test + void resolveInstallPlan_ordersDependenciesBeforeRoot() { + mockTenant(); + String versionId = UUID.randomUUID().toString(); + String rootItemId = UUID.randomUUID().toString(); + String relatedItemId = UUID.randomUUID().toString(); + ObjectNode root = version(versionId, rootItemId, "DASHBOARD", "Root", "1.0"); + root.putArray("relatedItems").add(relatedItemId); + when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(root); + when(iotHubRestClient.getPublishedVersionByItemId(relatedItemId)) + .thenReturn(version(UUID.randomUUID().toString(), relatedItemId, "WIDGET", "Dep", "2.0")); + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of()); + + InstallPlan plan = service.resolveInstallPlan(user, versionId); + + assertThat(plan.getEntries()).hasSize(2); + assertThat(plan.getEntries().get(0).getItemId()).isEqualTo(relatedItemId); + assertThat(plan.getEntries().get(0).isRoot()).isFalse(); + assertThat(plan.getEntries().get(1).getItemId()).isEqualTo(rootItemId); + assertThat(plan.getEntries().get(1).isRoot()).isTrue(); + } + + @Test + void resolveInstallPlan_missingRelated_recordedAsMissing() { + mockTenant(); + String versionId = UUID.randomUUID().toString(); + String rootItemId = UUID.randomUUID().toString(); + String relatedItemId = UUID.randomUUID().toString(); + ObjectNode root = version(versionId, rootItemId, "DASHBOARD", "Root", "1.0"); + root.putArray("relatedItems").add(relatedItemId); + when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(root); + when(iotHubRestClient.getPublishedVersionByItemId(relatedItemId)).thenReturn(null); + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of()); + + InstallPlan plan = service.resolveInstallPlan(user, versionId); + + InstallPlanEntry missing = plan.getEntries().get(0); + assertThat(missing.getStatus()).isEqualTo(InstallPlanEntry.Status.MISSING); + assertThat(missing.getItemId()).isEqualTo(relatedItemId); + assertThat(missing.getErrorMessage()).isNotBlank(); + } + + @Test + void resolveInstallPlan_relatedFetchThrows_recordedAsMissing() { + mockTenant(); + String versionId = UUID.randomUUID().toString(); + String rootItemId = UUID.randomUUID().toString(); + String relatedItemId = UUID.randomUUID().toString(); + ObjectNode root = version(versionId, rootItemId, "DASHBOARD", "Root", "1.0"); + root.putArray("relatedItems").add(relatedItemId); + when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(root); + when(iotHubRestClient.getPublishedVersionByItemId(relatedItemId)).thenThrow(new RuntimeException("boom")); + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of()); + + InstallPlan plan = service.resolveInstallPlan(user, versionId); + + assertThat(plan.getEntries().get(0).getStatus()).isEqualTo(InstallPlanEntry.Status.MISSING); + assertThat(plan.getEntries().get(0).getErrorMessage()).contains("boom"); + } + + @Test + void resolveInstallPlan_alreadyInstalledRoot_marksAlreadyInstalled() { + mockTenant(); + String versionId = UUID.randomUUID().toString(); + UUID rootItemId = UUID.randomUUID(); + when(iotHubRestClient.getVersionInfo(versionId)) + .thenReturn(version(versionId, rootItemId.toString(), "WIDGET", "Root", "1.0")); + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of(rootItemId)); + + InstallPlan plan = service.resolveInstallPlan(user, versionId); + + assertThat(plan.getEntries().get(0).getStatus()).isEqualTo(InstallPlanEntry.Status.ALREADY_INSTALLED); + } + + @Test + void resolveInstallPlan_nullVersion_throws() { + mockTenant(); + String versionId = UUID.randomUUID().toString(); + when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(null); + + assertThatThrownBy(() -> service.resolveInstallPlan(user, versionId)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void installPlan_emptyPlan_returnsFailureWithoutInstalling() { + InstallPlanResult result = service.installPlan(user, new InstallPlan(null, List.of()), null, null); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getErrorMessage()).contains("empty"); + verify(iotHubInstalledItemService, never()).findInstalledItemIdsByTenantIdAndItemIdIn(any(), any()); + } + + @Test + void installPlan_missingEntry_collectedAndNotInstalled() { + mockTenant(); + InstallPlanEntry missing = new InstallPlanEntry(); + missing.setItemId(UUID.randomUUID().toString()); + missing.setStatus(InstallPlanEntry.Status.MISSING); + + InstallPlanResult result = service.installPlan(user, new InstallPlan(null, List.of(missing)), null, null); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.getMissingItemIds()).containsExactly(missing.getItemId()); + assertThat(result.getRootDescriptor()).isNull(); + verify(iotHubRestClient, never()).getVersionInfo(anyString()); + // A plan with nothing to install never hits the DB to check what is already installed. + verify(iotHubInstalledItemService, never()).findInstalledItemIdsByTenantIdAndItemIdIn(any(), any()); + } + + @Test + void installPlan_stalePlan_skipsItemInstalledSinceResolution() { + mockTenant(); + UUID itemId = UUID.randomUUID(); + InstallPlanEntry entry = new InstallPlanEntry(); + entry.setItemId(itemId.toString()); + entry.setVersionId(UUID.randomUUID().toString()); + entry.setType("WIDGET"); + entry.setStatus(InstallPlanEntry.Status.WILL_INSTALL); + entry.setRoot(true); + // The item is reported as already installed at install time, even though the plan says WILL_INSTALL. + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of(itemId)); + + InstallPlanResult result = service.installPlan(user, new InstallPlan(null, List.of(entry)), null, null); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.getEntries().get(0).getStatus()).isEqualTo(InstallPlanEntry.Status.ALREADY_INSTALLED); + // No marketplace fetch happened — the duplicate install was avoided. + verify(iotHubRestClient, never()).getVersionInfo(anyString()); + } + + @Test + void installPlan_duplicateItemId_installsOnce() throws Exception { + mockTenant(); + HttpServletRequest request = mock(HttpServletRequest.class); + UUID itemId = UUID.randomUUID(); + String firstVersionId = UUID.randomUUID().toString(); + String secondVersionId = UUID.randomUUID().toString(); + // Two entries point at the same item — a client-supplied plan is not de-duplicated, so the + // cascade itself must not install the same item twice. + InstallPlanEntry first = new InstallPlanEntry(); + first.setItemId(itemId.toString()); + first.setVersionId(firstVersionId); + first.setStatus(InstallPlanEntry.Status.WILL_INSTALL); + first.setRoot(true); + InstallPlanEntry duplicate = new InstallPlanEntry(); + duplicate.setItemId(itemId.toString()); + duplicate.setVersionId(secondVersionId); + duplicate.setStatus(InstallPlanEntry.Status.WILL_INSTALL); + duplicate.setRoot(false); + + // Nothing is installed yet when the plan is submitted. + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of()); + doReturn(installedItem(UUID.randomUUID())).when(service).doInstallVersion(eq(user), eq(firstVersionId), any(), any()); + + InstallPlanResult result = service.installPlan(user, new InstallPlan(firstVersionId, List.of(first, duplicate)), null, request); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.getEntries()).hasSize(2); + assertThat(result.getEntries().get(0).getStatus()).isEqualTo(InstallPlanEntry.Status.WILL_INSTALL); + // The duplicate is recognised as freshly installed and skipped. + assertThat(result.getEntries().get(1).getStatus()).isEqualTo(InstallPlanEntry.Status.ALREADY_INSTALLED); + verify(service).doInstallVersion(eq(user), eq(firstVersionId), any(), any()); + verify(service, never()).doInstallVersion(eq(user), eq(secondVersionId), any(), any()); + } + + @Test + void installPlan_cascade_installsDependencyBeforeRoot_andRoutesDataToRootOnly() throws Exception { + mockTenant(); + HttpServletRequest request = mock(HttpServletRequest.class); + JsonNode data = JacksonUtil.newObjectNode().put("entityId", UUID.randomUUID().toString()); + String depVersionId = UUID.randomUUID().toString(); + String rootVersionId = UUID.randomUUID().toString(); + InstallPlanEntry dep = willInstall(depVersionId, false); + InstallPlanEntry root = willInstall(rootVersionId, true); + + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of()); + IotHubInstalledItem depItem = installedItem(UUID.randomUUID()); + IotHubInstalledItem rootItem = installedItem(UUID.randomUUID()); + DashboardInstalledItemDescriptor rootDescriptor = new DashboardInstalledItemDescriptor(); + rootItem.setDescriptor(rootDescriptor); + doReturn(depItem).when(service).doInstallVersion(eq(user), eq(depVersionId), any(), any()); + doReturn(rootItem).when(service).doInstallVersion(eq(user), eq(rootVersionId), any(), any()); + + InstallPlanResult result = service.installPlan(user, new InstallPlan(rootVersionId, List.of(dep, root)), data, request); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.isRolledBack()).isFalse(); + assertThat(result.getRootDescriptor()).isSameAs(rootDescriptor); + assertThat(result.getMissingItemIds()).isEmpty(); + assertThat(result.getEntries()).hasSize(2); + + // The dependency installs before the root, and only the root receives the user's install data. + InOrder inOrder = inOrder(service); + inOrder.verify(service).doInstallVersion(eq(user), eq(depVersionId), isNull(), eq(request)); + inOrder.verify(service).doInstallVersion(eq(user), eq(rootVersionId), eq(data), eq(request)); + verify(service, never()).deleteInstalledItem(any(), any()); + } + + @Test + void installPlan_dependencyFails_rollsBackInstalledItemsInReverseOrder() throws Exception { + mockTenant(); + HttpServletRequest request = mock(HttpServletRequest.class); + String dep1VersionId = UUID.randomUUID().toString(); + String dep2VersionId = UUID.randomUUID().toString(); + String rootVersionId = UUID.randomUUID().toString(); + InstallPlanEntry dep1 = willInstall(dep1VersionId, false); + InstallPlanEntry dep2 = willInstall(dep2VersionId, false); + InstallPlanEntry root = willInstall(rootVersionId, true); + + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of()); + IotHubInstalledItem dep1Item = installedItem(UUID.randomUUID()); + IotHubInstalledItem dep2Item = installedItem(UUID.randomUUID()); + doReturn(dep1Item).when(service).doInstallVersion(eq(user), eq(dep1VersionId), any(), any()); + doReturn(dep2Item).when(service).doInstallVersion(eq(user), eq(dep2VersionId), any(), any()); + doThrow(new IllegalStateException("install failed")).when(service).doInstallVersion(eq(user), eq(rootVersionId), any(), any()); + doNothing().when(service).deleteInstalledItem(eq(user), any()); + + InstallPlanResult result = service.installPlan(user, new InstallPlan(rootVersionId, List.of(dep1, dep2, root)), null, request); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.isRolledBack()).isTrue(); + assertThat(result.getErrorMessage()).contains("install failed"); + + // Successfully installed dependencies are rolled back in reverse install order (dep2 before dep1). + InOrder inOrder = inOrder(service); + inOrder.verify(service).deleteInstalledItem(user, dep2Item.getId()); + inOrder.verify(service).deleteInstalledItem(user, dep1Item.getId()); + } + + @Test + void installPlan_rollbackFailure_reportsNotFullyRolledBack() throws Exception { + mockTenant(); + HttpServletRequest request = mock(HttpServletRequest.class); + String depVersionId = UUID.randomUUID().toString(); + String rootVersionId = UUID.randomUUID().toString(); + InstallPlanEntry dep = willInstall(depVersionId, false); + InstallPlanEntry root = willInstall(rootVersionId, true); + + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of()); + IotHubInstalledItem depItem = installedItem(UUID.randomUUID()); + doReturn(depItem).when(service).doInstallVersion(eq(user), eq(depVersionId), any(), any()); + doThrow(new IllegalStateException("install failed")).when(service).doInstallVersion(eq(user), eq(rootVersionId), any(), any()); + // Rolling back the one installed dependency itself fails — the result must report a partial rollback. + doThrow(new RuntimeException("delete failed")).when(service).deleteInstalledItem(user, depItem.getId()); + + InstallPlanResult result = service.installPlan(user, new InstallPlan(rootVersionId, List.of(dep, root)), null, request); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.isRolledBack()).isFalse(); + assertThat(result.getErrorMessage()).contains("install failed"); + } +} 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 86b5ae2cf8..53c855aa4d 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 @@ -27,8 +27,11 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rpc.RpcError; import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.service.rpc.TbCoreDeviceRpcService; import org.thingsboard.server.service.ruleengine.RuleEngineCallService; import org.thingsboard.server.service.state.DeviceStateService; @@ -51,6 +54,8 @@ public class DefaultTbCoreConsumerServiceTest { private TbCoreConsumerStats statsMock; @Mock private RuleEngineCallService ruleEngineCallServiceMock; + @Mock + private TbCoreDeviceRpcService tbCoreDeviceRpcServiceMock; @Mock private TbCallback tbCallbackMock; @@ -638,4 +643,31 @@ public class DefaultTbCoreConsumerServiceTest { then(ruleEngineCallServiceMock).should().onQueueMsg(restApiCallResponseMsgProto, tbCallbackMock); } + @Test + public void givenNotFoundErrorAndNoResponse_whenForwardToCoreRpcService_thenNotFoundAndNullResponseAreRecovered() { + // GIVEN + ReflectionTestUtils.setField(defaultTbCoreConsumerServiceMock, "tbCoreDeviceRpcService", tbCoreDeviceRpcServiceMock); + var requestId = UUID.randomUUID(); + // error = NOT_FOUND.ordinal() (0) and response left unset: the previously broken combination + // ('error > 0' dropped NOT_FOUND, proto3 default collapsed a null response to ""). + var proto = TransportProtos.FromDeviceRPCResponseProto.newBuilder() + .setRequestIdMSB(requestId.getMostSignificantBits()) + .setRequestIdLSB(requestId.getLeastSignificantBits()) + .setError(RpcError.NOT_FOUND.ordinal()) + .build(); + doCallRealMethod().when(defaultTbCoreConsumerServiceMock).forwardToCoreRpcService(proto, tbCallbackMock); + + // WHEN + defaultTbCoreConsumerServiceMock.forwardToCoreRpcService(proto, tbCallbackMock); + + // THEN + var responseCaptor = ArgumentCaptor.forClass(FromDeviceRpcResponse.class); + then(tbCoreDeviceRpcServiceMock).should().processRpcResponseFromRuleEngine(responseCaptor.capture()); + var response = responseCaptor.getValue(); + assertThat(response.getId()).isEqualTo(requestId); + assertThat(response.getError()).contains(RpcError.NOT_FOUND); + assertThat(response.getResponse()).isEmpty(); + then(tbCallbackMock).should().onSuccess(); + } + } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java new file mode 100644 index 0000000000..1cf41eca0c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java @@ -0,0 +1,78 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.rpc.RpcError; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineNotificationMsg; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.service.rpc.TbRuleEngineDeviceRpcService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.doCallRealMethod; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbRuleEngineConsumerServiceTest { + + @Mock + private TbRuleEngineDeviceRpcService tbDeviceRpcServiceMock; + @Mock + private TbCallback tbCallbackMock; + + @Mock + private DefaultTbRuleEngineConsumerService defaultTbRuleEngineConsumerServiceMock; + + @Test + public void givenNotFoundErrorAndNoResponse_whenHandleFromDeviceRpcResponse_thenNotFoundAndNullResponseAreRecovered() { + // GIVEN + ReflectionTestUtils.setField(defaultTbRuleEngineConsumerServiceMock, "tbDeviceRpcService", tbDeviceRpcServiceMock); + var requestId = UUID.randomUUID(); + // error = NOT_FOUND.ordinal() (0) and response left unset: the previously broken combination + // ('error > 0' dropped NOT_FOUND, proto3 default collapsed a null response to ""). + var proto = TransportProtos.FromDeviceRPCResponseProto.newBuilder() + .setRequestIdMSB(requestId.getMostSignificantBits()) + .setRequestIdLSB(requestId.getLeastSignificantBits()) + .setError(RpcError.NOT_FOUND.ordinal()) + .build(); + var nfMsg = ToRuleEngineNotificationMsg.newBuilder().setFromDeviceRpcResponse(proto).build(); + var queueMsg = new TbProtoQueueMsg<>(requestId, nfMsg); + doCallRealMethod().when(defaultTbRuleEngineConsumerServiceMock).handleNotification(requestId, queueMsg, tbCallbackMock); + + // WHEN + defaultTbRuleEngineConsumerServiceMock.handleNotification(requestId, queueMsg, tbCallbackMock); + + // THEN + var responseCaptor = ArgumentCaptor.forClass(FromDeviceRpcResponse.class); + then(tbDeviceRpcServiceMock).should().processRpcResponseFromDevice(responseCaptor.capture()); + var response = responseCaptor.getValue(); + assertThat(response.getId()).isEqualTo(requestId); + assertThat(response.getError()).contains(RpcError.NOT_FOUND); + assertThat(response.getResponse()).isEmpty(); + then(tbCallbackMock).should().onSuccess(); + } + +} diff --git a/common/actor/pom.xml b/common/actor/pom.xml index 02d5883458..61ff41ce80 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/common/cache/pom.xml b/common/cache/pom.xml index abb8f896f6..a9d43c9ec1 100644 --- a/common/cache/pom.xml +++ b/common/cache/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml index a8a599bea8..987e6e2ffe 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml index d1b7729397..c1a83ee870 100644 --- a/common/coap-server/pom.xml +++ b/common/coap-server/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 3b7248ee72..8ffa489f7b 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -85,7 +85,9 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial dtlsSessionsExecutor.shutdownNow(); } log.info("Stopping CoAP server!"); - server.destroy(); + if (server != null) { + server.destroy(); + } log.info("CoAP server stopped!"); } @@ -105,27 +107,47 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial private CoapServer createCoapServer() throws UnknownHostException { Configuration networkConfig = createNetworkConfiguration(); - server = new CoapServer(networkConfig); - - CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder(); - InetAddress addr = InetAddress.getByName(coapServerContext.getHost()); - InetSocketAddress sockAddr = new InetSocketAddress(addr, coapServerContext.getPort()); - noSecCoapEndpointBuilder.setInetSocketAddress(sockAddr); + try { + server = new CoapServer(networkConfig); + CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder(); + InetAddress addr = InetAddress.getByName(coapServerContext.getHost()); + InetSocketAddress sockAddr = new InetSocketAddress(addr, coapServerContext.getPort()); + noSecCoapEndpointBuilder.setInetSocketAddress(sockAddr); + + noSecCoapEndpointBuilder.setConfiguration(networkConfig); + CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build(); + server.addEndpoint(noSecCoapEndpoint); + if (isDtlsEnabled()) { + createDtlsEndpoint(networkConfig); + dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName()); + dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS); + } + Resource root = server.getRoot(); + TbCoapServerMessageDeliverer messageDeliverer = new TbCoapServerMessageDeliverer(root); + server.setMessageDeliverer(messageDeliverer); - noSecCoapEndpointBuilder.setConfiguration(networkConfig); - CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build(); - server.addEndpoint(noSecCoapEndpoint); - if (isDtlsEnabled()) { - createDtlsEndpoint(networkConfig); - dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName()); - dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS); + server.start(); + return server; + } catch (RuntimeException | UnknownHostException e) { + log.error("Failed to start CoAP server, releasing resources", e); + try { + if (dtlsSessionsExecutor != null) { + dtlsSessionsExecutor.shutdownNow(); + } + if (server != null) { + server.destroy(); + } + } catch (Exception suppressed) { + e.addSuppressed(suppressed); + } finally { + server = null; + dtlsSessionsExecutor = null; + dtlsConnector = null; + dtlsCoapEndpoint = null; + tbDtlsCertificateVerifier = null; + } + throw e; } - Resource root = server.getRoot(); - TbCoapServerMessageDeliverer messageDeliverer = new TbCoapServerMessageDeliverer(root); - server.setMessageDeliverer(messageDeliverer); - - server.start(); - return server; } private boolean isDtlsEnabled() { diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java new file mode 100644 index 0000000000..0c9e9fc29d --- /dev/null +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java @@ -0,0 +1,145 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.coapserver; + +import org.eclipse.californium.core.CoapServer; +import org.eclipse.californium.core.network.CoapEndpoint; +import org.eclipse.californium.core.server.resources.Resource; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.junit.jupiter.api.AfterEach; +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.MockedConstruction; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.common.util.ThingsBoardExecutors; + +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.concurrent.ScheduledExecutorService; + +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.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultCoapServerServiceTest { + + private static final String HOST = "127.0.0.1"; + + @Mock + private CoapServerContext mockCoapServerContext; + + private DefaultCoapServerService service; + private DatagramSocket occupiedSocket; + private int occupiedPort; + + @BeforeEach + public void setUp() throws Exception { + occupiedSocket = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0)); + occupiedPort = occupiedSocket.getLocalPort(); + + service = new DefaultCoapServerService(); + ReflectionTestUtils.setField(service, "coapServerContext", mockCoapServerContext); + + when(mockCoapServerContext.getHost()).thenReturn(HOST); + when(mockCoapServerContext.getPort()).thenReturn(occupiedPort); + when(mockCoapServerContext.getDtlsSettings()).thenReturn(null); + } + + @AfterEach + public void tearDown() { + if (occupiedSocket != null && !occupiedSocket.isClosed()) { + occupiedSocket.close(); + } + } + + @Test + public void whenPlainBindFails_thenInitThrowsAndReleasesCoapServer() { + assertThatThrownBy(() -> service.init()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("None of the server endpoints could be started"); + + assertThat(ReflectionTestUtils.getField(service, "server")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsSessionsExecutor")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsConnector")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsCoapEndpoint")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "tbDtlsCertificateVerifier")).isNull(); + } + + @Test + public void whenDtlsEnabledAndStartFails_thenInitShutsDownDtlsExecutorAndReleasesCoapServer() throws Exception { + // DTLS enabled: the DTLS endpoint is created and dtlsSessionsExecutor is scheduled before server.start(). + // This exercises the catch's dtlsSessionsExecutor.shutdownNow() branch, which the plain-bind test does not. + TbCoapDtlsSettings mockDtlsSettings = mock(TbCoapDtlsSettings.class); + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); + when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress(InetAddress.getByName(HOST), occupiedPort + 1)); + TbCoapDtlsCertificateVerifier mockVerifier = mock(TbCoapDtlsCertificateVerifier.class); + when(mockVerifier.getDtlsSessionReportTimeout()).thenReturn(1800000L); + when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockVerifier); + when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); + + ScheduledExecutorService mockExecutor = mock(ScheduledExecutorService.class); + Resource mockRoot = mock(Resource.class); + + try (MockedStatic executorsStatic = mockStatic(ThingsBoardExecutors.class); + MockedConstruction serverMock = mockConstruction(CoapServer.class, (server, ctx) -> { + when(server.getRoot()).thenReturn(mockRoot); + doThrow(new IllegalStateException("None of the server endpoints could be started")).when(server).start(); + }); + MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class); + MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class, (builder, ctx) -> { + when(builder.setInetSocketAddress(any())).thenReturn(builder); + when(builder.setConfiguration(any())).thenReturn(builder); + when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); + when(builder.build()).thenReturn(mock(CoapEndpoint.class)); + })) { + + executorsStatic.when(() -> ThingsBoardExecutors.newSingleThreadScheduledExecutor(anyString())).thenReturn(mockExecutor); + + assertThatThrownBy(() -> service.init()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("None of the server endpoints could be started"); + + // DTLS branch was actually entered and the executor was created... + verify(mockDtlsSettings).dtlsConnectorConfig(any()); + // ...and the cleanup branch shut it down and destroyed the server. + verify(mockExecutor).shutdownNow(); + verify(serverMock.constructed().get(0)).destroy(); + } + + assertThat(ReflectionTestUtils.getField(service, "server")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsSessionsExecutor")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsConnector")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsCoapEndpoint")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "tbDtlsCertificateVerifier")).isNull(); + } + +} diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml index 358d74592f..c9cfeae4e7 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityService.java index 36f6fe0feb..597dbd6293 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityService.java @@ -25,7 +25,11 @@ public interface DeviceConnectivityService { JsonNode findDevicePublishTelemetryCommands(String baseUrl, Device device) throws URISyntaxException; + JsonNode getConnectivityInfo(String baseUrl) throws URISyntaxException; + Resource getPemCertFile(String protocol); Resource createGatewayDockerComposeFile(String baseUrl, Device device) throws URISyntaxException; + + Resource createGatewayDockerComposeFile(String baseUrl, Device device, DockerComposeParams params) throws URISyntaxException; } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DockerComposeParams.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DockerComposeParams.java new file mode 100644 index 0000000000..86708910d3 --- /dev/null +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DockerComposeParams.java @@ -0,0 +1,21 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.device; + +public record DockerComposeParams(boolean includeVersion, String containerName, boolean includePortBindings, + boolean includeExtraHosts, boolean includeVolumesBind, + boolean includeVolumesDeclaration) { +} diff --git a/common/data/pom.xml b/common/data/pom.xml index 6e4dac4245..450a0826b9 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common @@ -84,8 +84,11 @@ test + com.squareup.wire - wire-schema + wire-schema-jvm org.thingsboard diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java index a4317b8aa6..8d0c44800c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java @@ -114,6 +114,9 @@ public class DeviceProfile extends BaseData implements HasName, this.description = deviceProfile.getDescription(); this.image = deviceProfile.getImage(); this.isDefault = deviceProfile.isDefault(); + this.type = deviceProfile.getType(); + this.transportType = deviceProfile.getTransportType(); + this.provisionType = deviceProfile.getProvisionType(); this.defaultRuleChainId = deviceProfile.getDefaultRuleChainId(); this.defaultDashboardId = deviceProfile.getDefaultDashboardId(); this.defaultQueueName = deviceProfile.getDefaultQueueName(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java index 37242dfa1d..48b0efe16b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java @@ -24,6 +24,7 @@ import java.util.Arrays; import java.util.Base64; import java.util.List; import java.util.function.Function; +import java.util.regex.Pattern; import static org.apache.commons.lang3.StringUtils.repeat; @@ -37,6 +38,12 @@ public class StringUtils { public static final int INDEX_NOT_FOUND = -1; + public static final Pattern CONTROL_CHARS = Pattern.compile("[\\x00-\\x1F\\x7F]"); + + public static boolean containsControlChars(String source) { + return source != null && CONTROL_CHARS.matcher(source).find(); + } + public static boolean isEmpty(String source) { return source == null || source.isEmpty(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java index 527ee2f18d..e22e1809a9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java @@ -41,4 +41,5 @@ public class SystemParams { TrendzSettings trendzSettings; String nullsOrderStrategy; boolean edqsEnabled; + String iotHubBaseUrl; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java index 5f7772ed8b..abd25769d6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java @@ -42,6 +42,8 @@ public sealed interface AiChatModelConfig> extend C withMaxRetries(Integer maxRetries); - boolean supportsJsonMode(); + boolean supportsSchemalessJsonOutput(); + + boolean supportsJsonSchemaOutput(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java index ee80230fee..6e23cecf53 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java @@ -49,8 +49,13 @@ public record AmazonBedrockChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { return false; } + @Override + public boolean supportsJsonSchemaOutput() { + return true; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java index 44b55c4c22..eccea88e7d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java @@ -50,8 +50,13 @@ public record AnthropicChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { return false; } + @Override + public boolean supportsJsonSchemaOutput() { + return true; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java index 1e0d6d0c18..7347a57d9a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java @@ -51,7 +51,12 @@ public record AzureOpenAiChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java index 606f30599b..821e569cb8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java @@ -51,8 +51,13 @@ public record GitHubModelsChatModelConfig( } @Override - public boolean supportsJsonMode() { - return false; + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { + return true; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java index 422c92ba97..3f04da34c5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java @@ -52,7 +52,12 @@ public record GoogleAiGeminiChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java index 3e298e52e2..1211802f98 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java @@ -52,7 +52,12 @@ public record GoogleVertexAiGeminiChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java index 5713217fe9..ce85208945 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java @@ -51,7 +51,12 @@ public record MistralAiChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java index 3f1856f630..3e5c2a7ba0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java @@ -51,7 +51,12 @@ public record OllamaChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java index f74292cd76..53c5eeb091 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java @@ -51,7 +51,12 @@ public record OpenAiChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/IotHubInstalledItemId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/IotHubInstalledItemId.java new file mode 100644 index 0000000000..f9ea6c4443 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/IotHubInstalledItemId.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.id; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.UUID; + +public class IotHubInstalledItemId extends UUIDBased { + + @JsonCreator + public IotHubInstalledItemId(@JsonProperty("id") UUID id) { + super(id); + } + + public static IotHubInstalledItemId fromString(String id) { + return new IotHubInstalledItemId(UUID.fromString(id)); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/AlarmRuleInstalledItemDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/AlarmRuleInstalledItemDescriptor.java new file mode 100644 index 0000000000..2a7be1f5d3 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/AlarmRuleInstalledItemDescriptor.java @@ -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. + */ +package org.thingsboard.server.common.data.iot_hub; + +import lombok.Data; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; + +@Data +public class AlarmRuleInstalledItemDescriptor implements IotHubInstalledItemDescriptor { + + private CalculatedFieldId calculatedFieldId; + private EntityId entityId; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/CalculatedFieldInstalledItemDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/CalculatedFieldInstalledItemDescriptor.java new file mode 100644 index 0000000000..ceedbe2dea --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/CalculatedFieldInstalledItemDescriptor.java @@ -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. + */ +package org.thingsboard.server.common.data.iot_hub; + +import lombok.Data; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; + +@Data +public class CalculatedFieldInstalledItemDescriptor implements IotHubInstalledItemDescriptor { + + private CalculatedFieldId calculatedFieldId; + private EntityId entityId; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DashboardInstalledItemDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DashboardInstalledItemDescriptor.java new file mode 100644 index 0000000000..260665b82f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DashboardInstalledItemDescriptor.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.iot_hub; + +import lombok.Data; +import org.thingsboard.server.common.data.id.DashboardId; + +@Data +public class DashboardInstalledItemDescriptor implements IotHubInstalledItemDescriptor { + + private DashboardId dashboardId; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptor.java new file mode 100644 index 0000000000..e10eb65b25 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptor.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.iot_hub; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.List; +import java.util.Map; + +@Data +public class DeviceInstalledItemDescriptor implements IotHubInstalledItemDescriptor { + + private List createdEntityIds; + private DashboardId dashboardId; + private String selectedInstallMethod; + private Map installState; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItem.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItem.java new file mode 100644 index 0000000000..8152ec1b0d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItem.java @@ -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. + */ +package org.thingsboard.server.common.data.iot_hub; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.UUID; + +@Data +@EqualsAndHashCode(callSuper = true) +public class IotHubInstalledItem extends BaseData implements HasTenantId { + + private TenantId tenantId; + private UUID itemId; + private UUID itemVersionId; + private String itemName; + private String itemType; + private String version; + private IotHubInstalledItemDescriptor descriptor; + + public IotHubInstalledItem() { + super(); + } + + public IotHubInstalledItem(IotHubInstalledItemId id) { + super(id); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItemDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItemDescriptor.java new file mode 100644 index 0000000000..6a0359ceb3 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItemDescriptor.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.common.data.iot_hub; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import java.io.Serializable; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + property = "type") +@JsonSubTypes({ + @Type(name = "WIDGET", value = WidgetInstalledItemDescriptor.class), + @Type(name = "DASHBOARD", value = DashboardInstalledItemDescriptor.class), + @Type(name = "CALCULATED_FIELD", value = CalculatedFieldInstalledItemDescriptor.class), + @Type(name = "ALARM_RULE", value = AlarmRuleInstalledItemDescriptor.class), + @Type(name = "RULE_CHAIN", value = RuleChainInstalledItemDescriptor.class), + @Type(name = "DEVICE", value = DeviceInstalledItemDescriptor.class), + @Type(name = "SOLUTION_TEMPLATE", value = SolutionTemplateInstalledItemDescriptor.class) +}) +public interface IotHubInstalledItemDescriptor extends Serializable { + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/RuleChainInstalledItemDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/RuleChainInstalledItemDescriptor.java new file mode 100644 index 0000000000..c71a4bc6f6 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/RuleChainInstalledItemDescriptor.java @@ -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. + */ +package org.thingsboard.server.common.data.iot_hub; + +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleChainId; + +@Data +public class RuleChainInstalledItemDescriptor implements IotHubInstalledItemDescriptor { + + private RuleChainId ruleChainId; + private EntityId targetProfileId; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/SolutionTemplateInstalledItemDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/SolutionTemplateInstalledItemDescriptor.java new file mode 100644 index 0000000000..488fe3c410 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/SolutionTemplateInstalledItemDescriptor.java @@ -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. + */ +package org.thingsboard.server.common.data.iot_hub; + +import lombok.Data; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.List; + +@Data +public class SolutionTemplateInstalledItemDescriptor implements IotHubInstalledItemDescriptor { + + private List createdEntityIds; + private List tenantTelemetryKeys; + private List tenantAttributeKeys; + private DashboardId dashboardId; + private CustomerId publicId; + private boolean mainDashboardPublic; + private String details; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/WidgetInstalledItemDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/WidgetInstalledItemDescriptor.java new file mode 100644 index 0000000000..5c4195cb51 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/WidgetInstalledItemDescriptor.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.iot_hub; + +import lombok.Data; +import org.thingsboard.server.common.data.id.WidgetTypeId; + +@Data +public class WidgetInstalledItemDescriptor implements IotHubInstalledItemDescriptor { + + private WidgetTypeId widgetTypeId; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java b/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java index 03cd1d69cb..c5575b53be 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java @@ -20,4 +20,16 @@ package org.thingsboard.server.common.data.rpc; */ public enum RpcError { NOT_FOUND, FORBIDDEN, NO_ACTIVE_CONNECTION, TIMEOUT, INTERNAL; + + private static final RpcError[] VALUES = values(); + + /** + * Resolves an {@link RpcError} from the proto {@code error} ordinal. + * Returns {@code null} both for the "no error" sentinel (negative value) and for unknown ordinals + * that a newer node in a mixed-version cluster might emit, so callers never hit an + * {@link ArrayIndexOutOfBoundsException}. + */ + public static RpcError fromProtoErrorCode(int errorCode) { + return errorCode >= 0 && errorCode < VALUES.length ? VALUES[errorCode] : null; + } } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptorTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptorTest.java new file mode 100644 index 0000000000..a6b59995a8 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptorTest.java @@ -0,0 +1,129 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.iot_hub; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.DashboardId; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class DeviceInstalledItemDescriptorTest { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @Test + void deserializeFromFrontendPayload() throws Exception { + // Exact payload the frontend sends after the fix + String json = """ + { + "type": "DEVICE", + "createdEntityIds": [ + {"entityType": "DEVICE_PROFILE", "id": "3eb9c330-2e57-11f1-9334-a385172e8e7d"}, + {"entityType": "DEVICE", "id": "8a90d5e0-2e5d-11f1-a802-c35a9af2ebde"}, + {"entityType": "DASHBOARD", "id": "8bc229f0-2e5d-11f1-a802-c35a9af2ebde"} + ], + "dashboardId": {"entityType": "DASHBOARD", "id": "8bc229f0-2e5d-11f1-a802-c35a9af2ebde"} + } + """; + + // Deserialize the same way the controller does (JsonNode → treeToValue) + JsonNode node = mapper.readTree(json); + DeviceInstalledItemDescriptor descriptor = mapper.treeToValue(node, DeviceInstalledItemDescriptor.class); + + assertThat(descriptor).isNotNull(); + assertThat(descriptor.getCreatedEntityIds()).hasSize(3); + assertThat(descriptor.getCreatedEntityIds().get(0).getEntityType()).isEqualTo(EntityType.DEVICE_PROFILE); + assertThat(descriptor.getCreatedEntityIds().get(1).getEntityType()).isEqualTo(EntityType.DEVICE); + assertThat(descriptor.getCreatedEntityIds().get(2).getEntityType()).isEqualTo(EntityType.DASHBOARD); + assertThat(descriptor.getDashboardId()).isNotNull(); + assertThat(descriptor.getDashboardId().getId()).isEqualTo(UUID.fromString("8bc229f0-2e5d-11f1-a802-c35a9af2ebde")); + } + + @Test + void roundTripSerialization() throws Exception { + DeviceInstalledItemDescriptor original = new DeviceInstalledItemDescriptor(); + DashboardId dashboardId = new DashboardId(UUID.randomUUID()); + original.setDashboardId(dashboardId); + original.setCreatedEntityIds(java.util.List.of(dashboardId)); + + String json = mapper.writeValueAsString(original); + JsonNode node = mapper.readTree(json); + DeviceInstalledItemDescriptor deserialized = mapper.treeToValue(node, DeviceInstalledItemDescriptor.class); + + assertThat(deserialized.getDashboardId().getId()).isEqualTo(original.getDashboardId().getId()); + assertThat(deserialized.getCreatedEntityIds()).hasSize(1); + assertThat(deserialized.getCreatedEntityIds().get(0).getId()).isEqualTo(dashboardId.getId()); + } + + @Test + void deserializeWithInstallState() throws Exception { + String json = """ + { + "type": "DEVICE", + "createdEntityIds": [ + {"entityType": "DEVICE_PROFILE", "id": "3eb9c330-2e57-11f1-9334-a385172e8e7d"}, + {"entityType": "DEVICE", "id": "8a90d5e0-2e5d-11f1-a802-c35a9af2ebde"}, + {"entityType": "DASHBOARD", "id": "8bc229f0-2e5d-11f1-a802-c35a9af2ebde"} + ], + "dashboardId": {"entityType": "DASHBOARD", "id": "8bc229f0-2e5d-11f1-a802-c35a9af2ebde"}, + "selectedInstallMethod": "DIRECT_MQTT", + "installState": { + "Configuration": { + "formValues": {"deviceName": "ESP32 Dev Kit", "wifiSsid": "MyWiFi", "wifiPassword": "secret"} + }, + "ESP32 Dev Kit": { + "entityOutput": {"id": "8a90d5e0-2e5d-11f1-a802-c35a9af2ebde", "name": "ESP32 Dev Kit", "token": "abc123", "url": "/entities/devices/8a90d5e0-2e5d-11f1-a802-c35a9af2ebde"} + }, + "ESP32 Monitor": { + "entityOutput": {"id": "8bc229f0-2e5d-11f1-a802-c35a9af2ebde", "name": "ESP32 Monitor", "url": "/dashboards/8bc229f0-2e5d-11f1-a802-c35a9af2ebde"} + } + } + } + """; + + JsonNode node = mapper.readTree(json); + DeviceInstalledItemDescriptor descriptor = mapper.treeToValue(node, DeviceInstalledItemDescriptor.class); + + assertThat(descriptor).isNotNull(); + assertThat(descriptor.getSelectedInstallMethod()).isEqualTo("DIRECT_MQTT"); + assertThat(descriptor.getInstallState()).isNotNull(); + assertThat(descriptor.getInstallState()).hasSize(3); + + // Verify form values + JsonNode configState = descriptor.getInstallState().get("Configuration"); + assertThat(configState).isNotNull(); + assertThat(configState.get("formValues").get("deviceName").asText()).isEqualTo("ESP32 Dev Kit"); + assertThat(configState.get("formValues").get("wifiSsid").asText()).isEqualTo("MyWiFi"); + + // Verify entity output + JsonNode deviceState = descriptor.getInstallState().get("ESP32 Dev Kit"); + assertThat(deviceState).isNotNull(); + assertThat(deviceState.get("entityOutput").get("token").asText()).isEqualTo("abc123"); + + // Verify round-trip + String serialized = mapper.writeValueAsString(descriptor); + JsonNode roundTripped = mapper.readTree(serialized); + DeviceInstalledItemDescriptor deserialized = mapper.treeToValue(roundTripped, DeviceInstalledItemDescriptor.class); + assertThat(deserialized.getSelectedInstallMethod()).isEqualTo("DIRECT_MQTT"); + assertThat(deserialized.getInstallState()).hasSize(3); + assertThat(deserialized.getInstallState().get("Configuration").get("formValues").get("deviceName").asText()).isEqualTo("ESP32 Dev Kit"); + } +} diff --git a/common/discovery-api/pom.xml b/common/discovery-api/pom.xml index 397cc599d7..1c3b34f6f4 100644 --- a/common/discovery-api/pom.xml +++ b/common/discovery-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/pom.xml b/common/edge-api/pom.xml index 0a46924c8c..ff630f416c 100644 --- a/common/edge-api/pom.xml +++ b/common/edge-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index 70f90cc45b..520b4ac493 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -48,6 +48,7 @@ enum EdgeVersion { V_4_2_2 = 4220; V_4_2_2_1 = 4221; V_4_2_2_2 = 4222; + V_4_2_2_3 = 4223; V_LATEST = 99999; } diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml index fe9907a684..f107721524 100644 --- a/common/edqs/pom.xml +++ b/common/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/common/message/pom.xml b/common/message/pom.xml index 2628edba99..d9184ac548 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/common/pom.xml b/common/pom.xml index 7d6c01736d..f63c12d7dc 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT thingsboard common diff --git a/common/proto/pom.xml b/common/proto/pom.xml index f22e4fb961..5335df00b9 100644 --- a/common/proto/pom.xml +++ b/common/proto/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java index 4e0604239f..05c0a7d980 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java @@ -583,10 +583,11 @@ public class ProtoUtils { } private static ToDeviceActorNotificationMsg fromProto(TransportProtos.FromDeviceRpcResponseActorMsgProto proto) { + TransportProtos.FromDeviceRPCResponseProto rpcResponse = proto.getRpcResponse(); FromDeviceRpcResponse fromDeviceRpcResponse = new FromDeviceRpcResponse( - new UUID(proto.getRpcResponse().getRequestIdMSB(), proto.getRpcResponse().getRequestIdLSB()), - proto.getRpcResponse().getResponse(), - proto.getRpcResponse().getError() >= 0 ? RpcError.values()[proto.getRpcResponse().getError()] : null); + new UUID(rpcResponse.getRequestIdMSB(), rpcResponse.getRequestIdLSB()), + rpcResponse.hasResponse() ? rpcResponse.getResponse() : null, + RpcError.fromProtoErrorCode(rpcResponse.getError())); return new FromDeviceRpcResponseActorMsg( proto.getRequestId(), TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 7ad4d2883d..1f45ab7293 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -1238,7 +1238,7 @@ message LocalSubscriptionServiceMsgProto { message FromDeviceRPCResponseProto { int64 requestIdMSB = 1; int64 requestIdLSB = 2; - string response = 3; + optional string response = 3; int32 error = 4; } diff --git a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java index e779632088..a438c62dc7 100644 --- a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java +++ b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java @@ -226,6 +226,17 @@ class ProtoUtilsTest { assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); } + @Test + void protoFromDeviceRpcResponseOnewaySerialization() { + // Oneway RPC success: response and error are both null. Relies on the proto + // 'optional string response' presence bit so the receiver round-trips null + // rather than seeing the proto3 default "". + FromDeviceRpcResponseActorMsg msg = new FromDeviceRpcResponseActorMsg(23, tenantId, deviceId, new FromDeviceRpcResponse(id, null, null)); + TransportProtos.ToDeviceActorNotificationMsgProto serializedMsg = ProtoUtils.toProto(msg); + Assertions.assertNotNull(serializedMsg); + assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); + } + @Test void protoRemoveRpcActorSerialization() { RemoveRpcActorMsg msg = new RemoveRpcActorMsg(tenantId, deviceId, id); diff --git a/common/queue/pom.xml b/common/queue/pom.xml index d6c3d8b1ef..8e684718a0 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java index 4adc0354c4..9ee6bc0b50 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java @@ -30,6 +30,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.function.BooleanSupplier; import java.util.function.Supplier; @Slf4j @@ -40,6 +41,8 @@ public class QueueConsumerManager { private final long pollInterval; private final ExecutorService consumerExecutor; private final String threadPrefix; + /** Optional poll gate: while {@code false} the loop skips polling so the position doesn't advance; {@code null} = always ready (default). */ + private final BooleanSupplier readinessCheck; @Getter private final TbQueueConsumer consumer; @@ -49,12 +52,13 @@ public class QueueConsumerManager { @Builder public QueueConsumerManager(String name, MsgPackProcessor msgPackProcessor, long pollInterval, Supplier> consumerCreator, - ExecutorService consumerExecutor, String threadPrefix) { + ExecutorService consumerExecutor, String threadPrefix, BooleanSupplier readinessCheck) { this.name = name; this.pollInterval = pollInterval; this.msgPackProcessor = msgPackProcessor; this.consumerExecutor = consumerExecutor; this.threadPrefix = threadPrefix; + this.readinessCheck = readinessCheck; this.consumer = consumerCreator.get(); } @@ -84,6 +88,12 @@ public class QueueConsumerManager { private void consumerLoop(TbQueueConsumer consumer) { while (!stopped && !consumer.isStopped()) { try { + if (!isReadyToProcess()) { + if (!awaitNextReadinessCheck()) { + return; + } + continue; + } List msgs = consumer.poll(pollInterval); if (msgs.isEmpty()) { continue; @@ -102,6 +112,25 @@ public class QueueConsumerManager { } } + private boolean isReadyToProcess() { + return readinessCheck == null || readinessCheck.getAsBoolean(); + } + + /** + * Waits one poll interval before readiness is re-checked. Returns {@code false} if interrupted, which is treated as + * a stop signal so the consumer loop exits. + */ + private boolean awaitNextReadinessCheck() { + log.trace("[{}] Consumer is not ready to process messages yet, skipping poll iteration", name); + try { + Thread.sleep(pollInterval); + return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + public void stop() { log.debug("[{}] Stopping consumer", name); stopped = true; diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManagerTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManagerTest.java new file mode 100644 index 0000000000..6cf2d8bfe0 --- /dev/null +++ b/common/queue/src/test/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManagerTest.java @@ -0,0 +1,247 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.queue.common.consumer; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.TbQueueMsg; + +import java.util.Collections; +import java.util.List; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.mock; + +@Slf4j +class QueueConsumerManagerTest { + + private static final long POLL_INTERVAL_MS = 20L; + // Before asserting the consumer never polled we wait until the loop has evaluated the readiness gate at least + // this many times. That proves the consumer thread is actually running and deciding not to poll, rather than the + // assertion passing vacuously because the thread simply has not started yet. + private static final int MIN_READINESS_CHECKS = 3; + + private final AtomicBoolean readyToProcess = new AtomicBoolean(false); + private final AtomicInteger readinessChecks = new AtomicInteger(); + private final TestQueueConsumer consumer = new TestQueueConsumer(); + + private ExecutorService consumerExecutor; + private QueueConsumerManager manager; + + @AfterEach + void tearDown() { + if (manager != null) { + manager.stop(); + } + if (consumerExecutor != null) { + consumerExecutor.shutdownNow(); + } + } + + @Test + void eventQueuedWhileNotReadyIsDeliveredAfterReadinessGateOpensInsteadOfBeingDropped() { + List delivered = new CopyOnWriteArrayList<>(); + + consumer.enqueue(List.of(mock(TbQueueMsg.class))); + + // The processor is unconditional: only the readiness gate may hold the event back, so delivery proves the + // gate (not the processor) is what kept the event queued while not ready. + manager = launchManager(consumer, countingReadiness(readyToProcess, readinessChecks), (msgs, c) -> { + delivered.addAll(msgs); + c.commit(); + }); + + // The loop is running and repeatedly evaluating the gate during the not-ready (sync) window... + awaitReadinessGateEvaluated(readinessChecks); + // ...yet the queued event is neither polled nor delivered - it stays in the queue rather than being dropped. + assertThat(consumer.getPollCount()) + .as("consumer must not poll while not ready") + .isZero(); + assertThat(delivered) + .as("event must not be delivered while not ready") + .isEmpty(); + + // Sync completes - the processor becomes ready. + readyToProcess.set(true); + + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(delivered) + .as("event queued during the not-ready window must be delivered, not dropped") + .hasSize(1)); + } + + @Test + void consumerIsNotPolledWhileNotReadyToProcess() { + manager = launchManager(consumer, countingReadiness(readyToProcess, readinessChecks), (msgs, c) -> c.commit()); + + awaitReadinessGateEvaluated(readinessChecks); + assertThat(consumer.getPollCount()) + .as("consumer must not be polled while not ready to process") + .isZero(); + + readyToProcess.set(true); + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(consumer.getPollCount()) + .as("consumer resumes polling once ready") + .isPositive()); + } + + @Test + void consumerWithoutReadinessCheckPollsAndDeliversImmediately() { + List delivered = new CopyOnWriteArrayList<>(); + + consumer.enqueue(List.of(mock(TbQueueMsg.class))); + + // No readiness gate configured - the consumer must default to "always ready", preserving the behaviour every + // consumer that does not opt in relies on. + manager = launchManager(consumer, null, (msgs, c) -> { + delivered.addAll(msgs); + c.commit(); + }); + + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(delivered) + .as("consumer without a readiness gate must poll and deliver immediately") + .hasSize(1)); + } + + @Test + void consumerLoopExitsWhenInterruptedWhileNotReady() throws Exception { + manager = launchManager(consumer, countingReadiness(readyToProcess, readinessChecks), (msgs, c) -> c.commit()); + + // The loop is parked in the not-ready wait... + awaitReadinessGateEvaluated(readinessChecks); + + // ...interrupting the worker (as shutdownNow does on stop) must end the loop, not spin or hang. + consumerExecutor.shutdownNow(); + assertThat(consumerExecutor.awaitTermination(5, TimeUnit.SECONDS)) + .as("consumer loop must exit when interrupted while waiting to become ready") + .isTrue(); + } + + private static void awaitReadinessGateEvaluated(AtomicInteger readinessChecks) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(readinessChecks.get()) + .as("consumer loop must be running and repeatedly evaluating the readiness gate") + .isGreaterThanOrEqualTo(MIN_READINESS_CHECKS)); + } + + private static BooleanSupplier countingReadiness(AtomicBoolean ready, AtomicInteger readinessChecks) { + return () -> { + readinessChecks.incrementAndGet(); + return ready.get(); + }; + } + + private QueueConsumerManager launchManager(TestQueueConsumer consumer, BooleanSupplier readinessCheck, + QueueConsumerManager.MsgPackProcessor processor) { + consumerExecutor = Executors.newSingleThreadExecutor(); + QueueConsumerManager queueConsumerManager = QueueConsumerManager.builder() + .name("test-consumer") + .pollInterval(POLL_INTERVAL_MS) + .consumerCreator(() -> consumer) + .consumerExecutor(consumerExecutor) + .readinessCheck(readinessCheck) + .msgPackProcessor(processor) + .build(); + queueConsumerManager.subscribe(); + queueConsumerManager.launch(); + return queueConsumerManager; + } + + private static class TestQueueConsumer implements TbQueueConsumer { + + private final Queue> batches = new ConcurrentLinkedQueue<>(); + private final AtomicInteger pollCount = new AtomicInteger(); + private volatile boolean stopped; + + void enqueue(List batch) { + batches.add(batch); + } + + int getPollCount() { + return pollCount.get(); + } + + @Override + public List poll(long durationInMillis) { + pollCount.incrementAndGet(); + List batch = batches.poll(); + if (batch != null) { + return batch; + } + try { + Thread.sleep(durationInMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return Collections.emptyList(); + } + + @Override + public String getTopic() { + return "test-topic"; + } + + @Override + public void subscribe() { + } + + @Override + public void subscribe(Set partitions) { + } + + @Override + public void stop() { + stopped = true; + } + + @Override + public void unsubscribe() { + stopped = true; + } + + @Override + public void commit() { + } + + @Override + public boolean isStopped() { + return stopped; + } + + @Override + public List getFullTopicNames() { + return Collections.emptyList(); + } + + } + +} diff --git a/common/script/pom.xml b/common/script/pom.xml index 88bd15c721..cdfe36cd3c 100644 --- a/common/script/pom.xml +++ b/common/script/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/common/script/remote-js-client/pom.xml b/common/script/remote-js-client/pom.xml index 5b898b5fbe..9f179e9b1a 100644 --- a/common/script/remote-js-client/pom.xml +++ b/common/script/remote-js-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.2 + 4.2.2.3-SNAPSHOT script org.thingsboard.common.script diff --git a/common/script/script-api/pom.xml b/common/script/script-api/pom.xml index 1c24148301..5791fefc6a 100644 --- a/common/script/script-api/pom.xml +++ b/common/script/script-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.2 + 4.2.2.3-SNAPSHOT script org.thingsboard.common.script diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java index e206d2d78d..a66a1860cd 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java @@ -1186,6 +1186,10 @@ public class TbUtils { return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).intValue(); } + public static long toLong(double value) { + return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).longValue(); + } + public static boolean isNaN(double value) { return Double.isNaN(value); } diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java index 622d298bbb..dbf56d5140 100644 --- a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java @@ -1249,6 +1249,14 @@ public class TbUtilsTest { Assertions.assertEquals(28, TbUtils.toInt(28.0)); } + @Test + public void toLong() { + Assertions.assertEquals(1729L, TbUtils.toLong(doubleVal)); + Assertions.assertEquals(13L, TbUtils.toLong(12.8)); + Assertions.assertEquals(28L, TbUtils.toLong(28.0)); + Assertions.assertEquals(3_980_173_734L, TbUtils.toLong(3_980_173_734.0)); + } + @Test public void isNaN() { assertFalse(TbUtils.isNaN(doubleVal)); diff --git a/common/stats/pom.xml b/common/stats/pom.xml index c1d4a655a0..0de5ffe6ea 100644 --- a/common/stats/pom.xml +++ b/common/stats/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml index 33da47ca3f..a2681241c6 100644 --- a/common/transport/coap/pom.xml +++ b/common/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.2 + 4.2.2.3-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml index 2dcf5ca427..4d5b6bce44 100644 --- a/common/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.2 + 4.2.2.3-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/pom.xml b/common/transport/lwm2m/pom.xml index c3c79c856c..5da25b24d0 100644 --- a/common/transport/lwm2m/pom.xml +++ b/common/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.2 + 4.2.2.3-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java index 9b370d0b71..639e0bf74a 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java @@ -82,13 +82,32 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto @PostConstruct public void init() { log.info("Starting LwM2M transport bootstrap server..."); - this.server = getLhBootstrapServer(); - this.server.start(); - log.info("Started LwM2M transport bootstrap server."); + LeshanBootstrapServer bootstrapServer = null; + try { + bootstrapServer = getLhBootstrapServer(); + this.server = bootstrapServer; + bootstrapServer.start(); + log.info("Started LwM2M transport bootstrap server."); + } catch (RuntimeException e) { + log.error("Failed to start LwM2M transport bootstrap server, releasing resources", e); + try { + if (bootstrapServer != null) { + bootstrapServer.destroy(); + } + } catch (Exception suppressed) { + e.addSuppressed(suppressed); + } finally { + this.server = null; + } + throw e; + } } @PreDestroy public void shutdown() { + if (server == null) { + return; + } try { log.info("Stopping LwM2M transport bootstrap server!"); server.destroy(); diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java new file mode 100644 index 0000000000..67e8265361 --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java @@ -0,0 +1,115 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.bootstrap; + +import org.junit.jupiter.api.AfterEach; +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.transport.lwm2m.bootstrap.secure.TbLwM2MDtlsBootstrapCertificateVerifier; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MBootstrapSecurityStore; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MInMemoryBootstrapConfigStore; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportBootstrapConfig; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; + +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class LwM2MTransportBootstrapServiceTest { + + private static final String HOST = "127.0.0.1"; + + @Mock + private LwM2MTransportServerConfig serverConfig; + + @Mock + private LwM2MTransportBootstrapConfig bootstrapConfig; + + @Mock + private LwM2MBootstrapSecurityStore lwM2MBootstrapSecurityStore; + + @Mock + private LwM2MInMemoryBootstrapConfigStore lwM2MInMemoryBootstrapConfigStore; + + @Mock + private TransportService transportService; + + @Mock + private TbLwM2MDtlsBootstrapCertificateVerifier certificateVerifier; + + private LwM2MTransportBootstrapService service; + private DatagramSocket occupiedPlain; + private DatagramSocket occupiedSecure; + + @BeforeEach + public void setUp() throws Exception { + occupiedPlain = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0)); + occupiedSecure = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0)); + + when(bootstrapConfig.getHost()).thenReturn(HOST); + when(bootstrapConfig.getPort()).thenReturn(occupiedPlain.getLocalPort()); + when(bootstrapConfig.getSecureHost()).thenReturn(HOST); + when(bootstrapConfig.getSecurePort()).thenReturn(occupiedSecure.getLocalPort()); + when(bootstrapConfig.getSslCredentials()).thenReturn(null); + + when(serverConfig.isRecommendedCiphers()).thenReturn(false); + when(serverConfig.isRecommendedSupportedGroups()).thenReturn(false); + when(serverConfig.getDtlsRetransmissionTimeout()).thenReturn(9000); + when(serverConfig.getDtlsCidLength()).thenReturn(null); + + service = new LwM2MTransportBootstrapService( + serverConfig, + bootstrapConfig, + lwM2MBootstrapSecurityStore, + lwM2MInMemoryBootstrapConfigStore, + transportService, + certificateVerifier + ); + } + + @AfterEach + public void tearDown() { + if (occupiedPlain != null && !occupiedPlain.isClosed()) { + occupiedPlain.close(); + } + if (occupiedSecure != null && !occupiedSecure.isClosed()) { + occupiedSecure.close(); + } + } + + @Test + public void whenEndpointsFailToStart_thenInitThrowsAndReleasesBootstrapServer() { + assertThatThrownBy(() -> service.init()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("None of the server endpoints could be started"); + + assertThat(ReflectionTestUtils.getField(service, "server")).isNull(); + } + +} diff --git a/common/transport/mqtt/pom.xml b/common/transport/mqtt/pom.xml index 701a445a23..9cc958cde9 100644 --- a/common/transport/mqtt/pom.xml +++ b/common/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.2 + 4.2.2.3-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/pom.xml b/common/transport/pom.xml index 3bbada3665..b069dbe413 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/snmp/pom.xml b/common/transport/snmp/pom.xml index db71211fd3..2565016226 100644 --- a/common/transport/snmp/pom.xml +++ b/common/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.common - 4.2.2.2 + 4.2.2.3-SNAPSHOT transport diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml index 0c2e616ce5..ca992646a9 100644 --- a/common/transport/transport-api/pom.xml +++ b/common/transport/transport-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.2 + 4.2.2.3-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java index e6c3cab1ba..e7ef634c72 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java @@ -107,11 +107,12 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi @Override public void update(TenantProfileUpdateResult update) { - log.info("Received tenant profile update: {}", update.getProfile()); - EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(update.getProfile(), TENANT_LIMITS); - EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(update.getProfile(), DEVICE_LIMITS); - EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_LIMITS); - EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_DEVICE_LIMITS); + TenantProfile profile = update.getProfile(); + log.info("Received tenant profile update: {}", profile); + EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(profile, TENANT_LIMITS); + EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(profile, DEVICE_LIMITS); + EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(profile, GATEWAY_LIMITS); + EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(profile, GATEWAY_DEVICE_LIMITS); for (TenantId tenantId : update.getAffectedTenants()) { update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype, gatewayDeviceRateLimitPrototype); } @@ -119,11 +120,13 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi @Override public void update(TenantId tenantId) { - EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS); - EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS); - EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS); - EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_DEVICE_LIMITS); - update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype, gatewayDeviceRateLimitPrototype); + TenantProfile profile = tenantProfileCache.get(tenantId); + update(tenantId, + createRateLimits(profile, TENANT_LIMITS), + createRateLimits(profile, DEVICE_LIMITS), + createRateLimits(profile, GATEWAY_LIMITS), + createRateLimits(profile, GATEWAY_DEVICE_LIMITS) + ); } private void update(TenantId tenantId, EntityTransportRateLimits tenantRateLimitPrototype, EntityTransportRateLimits deviceRateLimitPrototype, @@ -231,25 +234,26 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi BiConsumer putFunction) { EntityTransportRateLimits oldRateLimits = getFunction.apply(entityId); if (oldRateLimits == null) { - if (EntityType.TENANT.equals(entityId.getEntityType())) { - log.info("[{}] New rate limits: {}", entityId, newRateLimits); - } else { - log.debug("[{}] New rate limits: {}", entityId, newRateLimits); - } + logLimits(entityId, "New", newRateLimits); putFunction.accept(entityId, newRateLimits); } else { EntityTransportRateLimits updated = merge(oldRateLimits, newRateLimits); if (updated != null) { - if (EntityType.TENANT.equals(entityId.getEntityType())) { - log.info("[{}] Updated rate limits: {}", entityId, updated); - } else { - log.debug("[{}] Updated rate limits: {}", entityId, updated); - } + logLimits(entityId, "Updated", updated); putFunction.accept(entityId, updated); } } } + private void logLimits(EntityId entityId, String action, EntityTransportRateLimits limits) { + // Tenant-level changes are logged at INFO; the much noisier per-device/gateway ones at DEBUG. + if (EntityType.TENANT.equals(entityId.getEntityType())) { + log.info("[{}] {} rate limits: {}", entityId, action, limits); + } else { + log.debug("[{}] {} rate limits: {}", entityId, action, limits); + } + } + private EntityTransportRateLimits merge(EntityTransportRateLimits oldRateLimits, EntityTransportRateLimits newRateLimits) { boolean regularUpdate = !oldRateLimits.getRegularMsgRateLimit().getConfiguration().equals(newRateLimits.getRegularMsgRateLimit().getConfiguration()); boolean telemetryMsgRateUpdate = !oldRateLimits.getTelemetryMsgRateLimit().getConfiguration().equals(newRateLimits.getTelemetryMsgRateLimit().getConfiguration()); @@ -269,36 +273,12 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi DefaultTenantProfileConfiguration profile = (DefaultTenantProfileConfiguration) profileData.getConfiguration(); if (profile == null) { return new EntityTransportRateLimits(ALLOW, ALLOW, ALLOW); - } else { - TransportRateLimit regularMsgRateLimit; - TransportRateLimit telemetryMsgRateLimit; - TransportRateLimit telemetryDpRateLimit; - switch (limitsType) { - case TENANT_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportTenantMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportTenantTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportTenantTelemetryDataPointsRateLimit()); - } - case DEVICE_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportDeviceMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportDeviceTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportDeviceTelemetryDataPointsRateLimit()); - } - case GATEWAY_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportGatewayMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportGatewayTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportGatewayTelemetryDataPointsRateLimit()); - } - case GATEWAY_DEVICE_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportGatewayDeviceMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportGatewayDeviceTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportGatewayDeviceTelemetryDataPointsRateLimit()); - } - default -> throw new IllegalStateException("Unknown limits type: " + limitsType); - } - - return new EntityTransportRateLimits(regularMsgRateLimit, telemetryMsgRateLimit, telemetryDpRateLimit); } + return new EntityTransportRateLimits( + newLimit(limitsType.getRegularMsgRateLimit().apply(profile)), + newLimit(limitsType.getTelemetryMsgRateLimit().apply(profile)), + newLimit(limitsType.getTelemetryDataPointsRateLimit().apply(profile)) + ); } private static TransportRateLimit newLimit(String config) { @@ -306,31 +286,36 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi } private EntityTransportRateLimits getTenantRateLimits(TenantId tenantId) { - return perTenantLimits.computeIfAbsent(tenantId, k -> createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS)); + return getRateLimits(perTenantLimits, tenantId, tenantId, TENANT_LIMITS, null); } private EntityTransportRateLimits getDeviceRateLimits(TenantId tenantId, DeviceId deviceId) { - return perDeviceLimits.computeIfAbsent(deviceId, k -> { - EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS); - getTenantDevices(tenantId).add(deviceId); - return limits; - }); + return getRateLimits(perDeviceLimits, tenantId, deviceId, DEVICE_LIMITS, () -> getTenantDevices(tenantId).add(deviceId)); } private EntityTransportRateLimits getGatewayRateLimits(TenantId tenantId, DeviceId gatewayId) { - return perGatewayLimits.computeIfAbsent(gatewayId, k -> { - EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS); - getTenantGateways(tenantId).add(gatewayId); - return limits; - }); + return getRateLimits(perGatewayLimits, tenantId, gatewayId, GATEWAY_LIMITS, () -> getTenantGateways(tenantId).add(gatewayId)); } private EntityTransportRateLimits getGatewayDeviceRateLimits(TenantId tenantId, DeviceId gatewayId) { - return perGatewayDeviceLimits.computeIfAbsent(gatewayId, k -> { - EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_DEVICE_LIMITS); - getTenantGatewayDevices(tenantId).add(gatewayId); - return limits; - }); + return getRateLimits(perGatewayDeviceLimits, tenantId, gatewayId, GATEWAY_DEVICE_LIMITS, () -> getTenantGatewayDevices(tenantId).add(gatewayId)); + } + + private EntityTransportRateLimits getRateLimits(ConcurrentMap limitsMap, TenantId tenantId, + T entityId, TransportLimitsType limitsType, Runnable onMiss) { + EntityTransportRateLimits limits = limitsMap.get(entityId); + if (limits == null) { + // Resolve the tenant profile WITHOUT holding the ConcurrentHashMap bin lock: the fetch may + // block on a cross-service round-trip, so it must run before computeIfAbsent's mapping function. + TenantProfile tenantProfile = tenantProfileCache.get(tenantId); + limits = limitsMap.computeIfAbsent(entityId, k -> createRateLimits(tenantProfile, limitsType)); + // Runs on every observed miss, including callers that lost the computeIfAbsent race and got an + // existing value back - NOT only on actual creation, so the callback must be idempotent. + if (onMiss != null) { + onMiss.run(); + } + } + return limits; } private Set getTenantDevices(TenantId tenantId) { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java index 29077877a8..3a124da2b6 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java @@ -15,6 +15,39 @@ */ package org.thingsboard.server.common.transport.limits; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; + +import java.util.function.Function; + +@Getter +@RequiredArgsConstructor public enum TransportLimitsType { - TENANT_LIMITS, DEVICE_LIMITS, GATEWAY_LIMITS, GATEWAY_DEVICE_LIMITS + + TENANT_LIMITS( + DefaultTenantProfileConfiguration::getTransportTenantMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportTenantTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportTenantTelemetryDataPointsRateLimit + ), + DEVICE_LIMITS( + DefaultTenantProfileConfiguration::getTransportDeviceMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportDeviceTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportDeviceTelemetryDataPointsRateLimit + ), + GATEWAY_LIMITS( + DefaultTenantProfileConfiguration::getTransportGatewayMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayTelemetryDataPointsRateLimit + ), + GATEWAY_DEVICE_LIMITS( + DefaultTenantProfileConfiguration::getTransportGatewayDeviceMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayDeviceTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayDeviceTelemetryDataPointsRateLimit + ); + + private final Function regularMsgRateLimit; + private final Function telemetryMsgRateLimit; + private final Function telemetryDataPointsRateLimit; + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index 240de91424..80980beeb7 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -153,6 +153,8 @@ public class DefaultTransportService extends TransportActivityManager implements private int notificationsPollDuration; @Value("${transport.stats.enabled:false}") private boolean statsEnabled; + @Value("${transport.callback_thread_pool_size:20}") + private int callbackThreadPoolSize; @Autowired @Lazy @@ -198,7 +200,7 @@ public class DefaultTransportService extends TransportActivityManager implements this.ruleEngineProducerStats = statsFactory.createMessagesStats(StatsType.RULE_ENGINE.getName() + ".producer"); this.tbCoreProducerStats = statsFactory.createMessagesStats(StatsType.CORE.getName() + ".producer"); this.transportApiStats = statsFactory.createMessagesStats(StatsType.TRANSPORT.getName() + ".producer"); - this.transportCallbackExecutor = ThingsBoardExecutors.newWorkStealingPool(20, getClass()); + this.transportCallbackExecutor = ThingsBoardExecutors.newWorkStealingPool(callbackThreadPoolSize, getClass()); this.scheduler.scheduleAtFixedRate(this::invalidateRateLimits, new Random().nextInt((int) sessionReportTimeout), sessionReportTimeout, TimeUnit.MILLISECONDS); transportApiRequestTemplate = queueProvider.createTransportApiRequestTemplate(); transportApiRequestTemplate.setMessagesStats(transportApiStats); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java index 4923bcd9a2..ac2fd4c28a 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.transport.service; +import com.google.common.util.concurrent.Striped; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; @@ -37,14 +38,20 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; @Component @TbTransportComponent @Slf4j public class DefaultTransportTenantProfileCache implements TransportTenantProfileCache { - private final Lock tenantProfileFetchLock = new ReentrantLock(); + // Number of stripes for the per-tenant fetch locks. Only contended during concurrent cold-cache + // misses (cached tenants never take the lock), and concurrent fetches are already bounded by the + // transport callback pool, so this comfortably over-provisions the realistic concurrency. + private static final int TENANT_PROFILE_FETCH_LOCK_STRIPES = 1024; + + // Bounded set of per-tenant locks: de-duplicates concurrent misses for the same tenant while + // letting different tenants fetch concurrently (eager array - no weak-ref overhead at this size). + private final Striped tenantProfileFetchLocks = Striped.lock(TENANT_PROFILE_FETCH_LOCK_STRIPES); private final ConcurrentMap profiles = new ConcurrentHashMap<>(); private final ConcurrentMap tenantIds = new ConcurrentHashMap<>(); private final ConcurrentMap> tenantProfileIds = new ConcurrentHashMap<>(); @@ -103,43 +110,52 @@ public class DefaultTransportTenantProfileCache implements TransportTenantProfil } private TenantProfile getTenantProfile(TenantId tenantId) { - TenantProfile profile = null; - TenantProfileId tenantProfileId = tenantIds.get(tenantId); - if (tenantProfileId != null) { - profile = profiles.get(tenantProfileId); - } + TenantProfile profile = lookupCached(tenantId); if (profile == null) { - tenantProfileFetchLock.lock(); + // Per-tenant lock: de-duplicates concurrent misses for the SAME tenant while allowing + // different tenants to resolve their profiles concurrently. + Lock lock = tenantProfileFetchLocks.get(tenantId); + lock.lock(); try { - tenantProfileId = tenantIds.get(tenantId); - if (tenantProfileId != null) { - profile = profiles.get(tenantProfileId); - } + profile = lookupCached(tenantId); if (profile == null) { - TransportProtos.GetEntityProfileRequestMsg msg = TransportProtos.GetEntityProfileRequestMsg.newBuilder() - .setEntityType(EntityType.TENANT.name()) - .setEntityIdMSB(tenantId.getId().getMostSignificantBits()) - .setEntityIdLSB(tenantId.getId().getLeastSignificantBits()) - .build(); - TransportProtos.GetEntityProfileResponseMsg entityProfileMsg = transportService.getEntityProfile(msg); - profile = ProtoUtils.fromProto(entityProfileMsg.getTenantProfile()); - TenantProfile existingProfile = profiles.get(profile.getId()); - if (existingProfile != null) { - profile = existingProfile; - } else { - profiles.put(profile.getId(), profile); - } - tenantProfileIds.computeIfAbsent(profile.getId(), id -> ConcurrentHashMap.newKeySet()).add(tenantId); - tenantIds.put(tenantId, profile.getId()); - ApiUsageState apiUsageState = ProtoUtils.fromProto(entityProfileMsg.getApiState()); - rateLimitService.update(tenantId, apiUsageState.isTransportEnabled()); + profile = fetchAndCacheTenantProfile(tenantId); } } finally { - tenantProfileFetchLock.unlock(); + lock.unlock(); } } return profile; } + private TenantProfile lookupCached(TenantId tenantId) { + TenantProfileId tenantProfileId = tenantIds.get(tenantId); + if (tenantProfileId != null) { + return profiles.get(tenantProfileId); + } + return null; + } + + private TenantProfile fetchAndCacheTenantProfile(TenantId tenantId) { + TransportProtos.GetEntityProfileRequestMsg msg = TransportProtos.GetEntityProfileRequestMsg.newBuilder() + .setEntityType(EntityType.TENANT.name()) + .setEntityIdMSB(tenantId.getId().getMostSignificantBits()) + .setEntityIdLSB(tenantId.getId().getLeastSignificantBits()) + .build(); + TransportProtos.GetEntityProfileResponseMsg entityProfileMsg = transportService.getEntityProfile(msg); + TenantProfile profile = ProtoUtils.fromProto(entityProfileMsg.getTenantProfile()); + TenantProfile existingProfile = profiles.get(profile.getId()); + if (existingProfile != null) { + profile = existingProfile; + } else { + profiles.put(profile.getId(), profile); + } + tenantProfileIds.computeIfAbsent(profile.getId(), id -> ConcurrentHashMap.newKeySet()).add(tenantId); + tenantIds.put(tenantId, profile.getId()); + ApiUsageState apiUsageState = ProtoUtils.fromProto(entityProfileMsg.getApiState()); + rateLimitService.update(tenantId, apiUsageState.isTransportEnabled()); + return profile; + } + } diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java new file mode 100644 index 0000000000..b7b6a81abf --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java @@ -0,0 +1,202 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.limits; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; +import org.thingsboard.server.common.transport.TransportTenantProfileCache; +import org.thingsboard.server.common.transport.profile.TenantProfileUpdateResult; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DefaultTransportRateLimitServiceTest { + + private TransportTenantProfileCache tenantProfileCache; + private ExecutorService executor; + + private final TenantId tenant = TenantId.fromUUID(UUID.randomUUID()); + + @BeforeEach + void setUp() { + tenantProfileCache = mock(TransportTenantProfileCache.class); + executor = Executors.newCachedThreadPool(); + } + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + @Test + void checkLimitsDoesNotHoldMapBinLockAcrossProfileFetch() throws Exception { + // Two concurrent rate-limit checks for the SAME tenant must both be able to reach + // the (blocking) tenant-profile fetch concurrently. If the blocking fetch runs inside + // ConcurrentHashMap.computeIfAbsent, the second caller is stuck on the bin reservation + // node and never reaches the fetch -> the latch never reaches zero. + CountDownLatch bothCallersReachedFetch = new CountDownLatch(2); + CountDownLatch releaseFetch = new CountDownLatch(1); + + when(tenantProfileCache.get(tenant)).thenAnswer(invocation -> { + bothCallersReachedFetch.countDown(); + releaseFetch.await(5, TimeUnit.SECONDS); + return tenantProfile(); + }); + + DefaultTransportRateLimitService service = new DefaultTransportRateLimitService(tenantProfileCache); + + Runnable check = () -> service.checkLimits(tenant, null, null, 1, false); + executor.submit(check); + executor.submit(check); + + boolean bothReached = bothCallersReachedFetch.await(3, TimeUnit.SECONDS); + releaseFetch.countDown(); + + assertThat(bothReached) + .as("both checkLimits calls should reach the profile fetch concurrently (no bin lock across I/O)") + .isTrue(); + } + + @ParameterizedTest + @EnumSource(TransportLimitsType.class) + void eachLimitsTypeReadsItsOwnProfileFields(TransportLimitsType type) { + // Distinct sentinel per profile field so a transposed method reference (e.g. GATEWAY_DEVICE_LIMITS + // wired to the plain gateway getters) resolves to the wrong value and fails the assertion. + DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); + config.setTransportTenantMsgRateLimit("tenant-msg"); + config.setTransportTenantTelemetryMsgRateLimit("tenant-tele-msg"); + config.setTransportTenantTelemetryDataPointsRateLimit("tenant-tele-dp"); + config.setTransportDeviceMsgRateLimit("device-msg"); + config.setTransportDeviceTelemetryMsgRateLimit("device-tele-msg"); + config.setTransportDeviceTelemetryDataPointsRateLimit("device-tele-dp"); + config.setTransportGatewayMsgRateLimit("gateway-msg"); + config.setTransportGatewayTelemetryMsgRateLimit("gateway-tele-msg"); + config.setTransportGatewayTelemetryDataPointsRateLimit("gateway-tele-dp"); + config.setTransportGatewayDeviceMsgRateLimit("gateway-device-msg"); + config.setTransportGatewayDeviceTelemetryMsgRateLimit("gateway-device-tele-msg"); + config.setTransportGatewayDeviceTelemetryDataPointsRateLimit("gateway-device-tele-dp"); + + String prefix = switch (type) { + case TENANT_LIMITS -> "tenant"; + case DEVICE_LIMITS -> "device"; + case GATEWAY_LIMITS -> "gateway"; + case GATEWAY_DEVICE_LIMITS -> "gateway-device"; + }; + + assertThat(type.getRegularMsgRateLimit().apply(config)).isEqualTo(prefix + "-msg"); + assertThat(type.getTelemetryMsgRateLimit().apply(config)).isEqualTo(prefix + "-tele-msg"); + assertThat(type.getTelemetryDataPointsRateLimit().apply(config)).isEqualTo(prefix + "-tele-dp"); + } + + @ParameterizedTest + @EnumSource(EntityLevel.class) + void profileUpdateReachesEntityTrackedDuringFirstCheck(EntityLevel level) { + DeviceId entity = new DeviceId(UUID.randomUUID()); + when(tenantProfileCache.get(tenant)).thenReturn(profileWithRegularMsgLimit(level, "100:600")); + DefaultTransportRateLimitService service = new DefaultTransportRateLimitService(tenantProfileCache); + + // First check resolves the (permissive) limit and must register the entity into the per-tenant + // tracking set via the onMiss callback - otherwise a later update(tenantId) can't reach it. + assertThat(level.check(service, tenant, entity)) + .as("permissive limit should allow the first %s check", level).isNull(); + + // Tighten the limit to a single message and push a profile update for this tenant. + service.update(new TenantProfileUpdateResult(profileWithRegularMsgLimit(level, "1:600"), Set.of(tenant))); + + // The freshly merged "1:600" bucket allows exactly one message... + assertThat(level.check(service, tenant, entity)).isNull(); + // ...and blocks the next one. This only happens if update(tenantId) reached the tracked entity. + assertThat(level.check(service, tenant, entity)) + .as("update(tenantId) must reach the tracked %s so the tightened limit applies", level).isNotNull(); + } + + private TenantProfile tenantProfile() { + return profileWith(new DefaultTenantProfileConfiguration()); + } + + private TenantProfile profileWithRegularMsgLimit(EntityLevel level, String regularMsgRateLimit) { + DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); + level.setRegularMsgRateLimit(config, regularMsgRateLimit); + return profileWith(config); + } + + private TenantProfile profileWith(DefaultTenantProfileConfiguration config) { + TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID())); + profile.setName("test-profile"); + TenantProfileData profileData = new TenantProfileData(); + profileData.setConfiguration(config); + profile.setProfileData(profileData); + return profile; + } + + private enum EntityLevel { + DEVICE { + @Override + void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) { + config.setTransportDeviceMsgRateLimit(value); + } + + @Override + Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) { + return service.checkLimits(tenantId, null, entityId, 0, false); + } + }, + GATEWAY { + @Override + void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) { + config.setTransportGatewayMsgRateLimit(value); + } + + @Override + Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) { + return service.checkLimits(tenantId, entityId, null, 0, false); + } + }, + GATEWAY_DEVICE { + @Override + void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) { + config.setTransportGatewayDeviceMsgRateLimit(value); + } + + @Override + Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) { + return service.checkLimits(tenantId, null, entityId, 0, true); + } + }; + + abstract void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value); + + abstract Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId); + } + +} diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java new file mode 100644 index 0000000000..d2b4544f53 --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.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.common.transport.service; + +import com.google.common.util.concurrent.Striped; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.ApiUsageStateValue; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.ApiUsageStateId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.limits.TransportRateLimitService; +import org.thingsboard.server.common.util.ProtoUtils; +import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileResponseMsg; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class DefaultTransportTenantProfileCacheTest { + + private DefaultTransportTenantProfileCache cache; + private TransportService transportService; + private TransportRateLimitService rateLimitService; + private ExecutorService executor; + + // Must match DefaultTransportTenantProfileCache.TENANT_PROFILE_FETCH_LOCK_STRIPES. + private static final int STRIPE_COUNT = 1024; + + private final TenantId tenantA = TenantId.fromUUID(UUID.randomUUID()); + // Deterministically pick a tenant that maps to a DIFFERENT stripe than tenantA, so the cross-tenant + // test below cannot flake on the ~1/1024 chance two random UUIDs hash to the same stripe. + private final TenantId tenantB = differentStripeFrom(tenantA); + + private static TenantId differentStripeFrom(TenantId other) { + Striped probe = Striped.lock(STRIPE_COUNT); + TenantId candidate = TenantId.fromUUID(UUID.randomUUID()); + while (probe.get(candidate) == probe.get(other)) { + candidate = TenantId.fromUUID(UUID.randomUUID()); + } + return candidate; + } + + @BeforeEach + void setUp() { + cache = new DefaultTransportTenantProfileCache(); + transportService = mock(TransportService.class); + rateLimitService = mock(TransportRateLimitService.class); + doNothing().when(rateLimitService).update(any(TenantId.class), anyBoolean()); + cache.setTransportService(transportService); + cache.setRateLimitService(rateLimitService); + executor = Executors.newCachedThreadPool(); + } + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + @Test + void fetchForOneTenantDoesNotBlockResolutionOfAnotherTenant() throws Exception { + CountDownLatch tenantAFetchStarted = new CountDownLatch(1); + CountDownLatch releaseTenantA = new CountDownLatch(1); + + GetEntityProfileResponseMsg responseA = responseFor(tenantA); + GetEntityProfileResponseMsg responseB = responseFor(tenantB); + + when(transportService.getEntityProfile(any())).thenAnswer(invocation -> { + GetEntityProfileRequestMsg msg = invocation.getArgument(0); + TenantId requested = TenantId.fromUUID(new UUID(msg.getEntityIdMSB(), msg.getEntityIdLSB())); + if (requested.equals(tenantA)) { + tenantAFetchStarted.countDown(); + releaseTenantA.await(5, TimeUnit.SECONDS); + return responseA; + } + return responseB; + }); + + // T1 starts fetching tenantA's profile and blocks inside the cross-service round-trip. + Future tenantAResult = executor.submit(() -> cache.get(tenantA)); + assertThat(tenantAFetchStarted.await(5, TimeUnit.SECONDS)) + .as("tenantA fetch should have started").isTrue(); + + // T2 resolves a different tenant - it must NOT wait for tenantA's in-flight fetch. + // Fails today (single global lock); passes once locking is per-tenant. + TenantProfile tenantBProfile = CompletableFuture + .supplyAsync(() -> cache.get(tenantB), executor) + .get(2, TimeUnit.SECONDS); + assertThat(tenantBProfile).isNotNull(); + + releaseTenantA.countDown(); + assertThat(tenantAResult.get(5, TimeUnit.SECONDS)).isNotNull(); + } + + @Test + void concurrentMissesForSameTenantDedupeToSingleFetch() throws Exception { + // The per-tenant lock exists precisely so that concurrent cold misses for the SAME tenant collapse + // into a single cross-service fetch (the rest are served from cache). Assert that contract directly. + int callers = 8; + CountDownLatch fetchStarted = new CountDownLatch(1); + CountDownLatch releaseFetch = new CountDownLatch(1); + + when(transportService.getEntityProfile(any())).thenAnswer(invocation -> { + fetchStarted.countDown(); + // Hold the (single) in-flight fetch open while the other callers pile up on the per-tenant lock. + releaseFetch.await(5, TimeUnit.SECONDS); + return responseFor(tenantA); + }); + + CountDownLatch allSubmitted = new CountDownLatch(callers); + List> results = new ArrayList<>(); + for (int i = 0; i < callers; i++) { + results.add(executor.submit(() -> { + allSubmitted.countDown(); + return cache.get(tenantA); + })); + } + + assertThat(allSubmitted.await(5, TimeUnit.SECONDS)).as("all callers should start").isTrue(); + assertThat(fetchStarted.await(5, TimeUnit.SECONDS)).as("the first fetch should start").isTrue(); + releaseFetch.countDown(); + + for (Future result : results) { + assertThat(result.get(5, TimeUnit.SECONDS)).isNotNull(); + } + // All 8 callers resolved the same tenant, but only one of them hit the backend. + verify(transportService, times(1)).getEntityProfile(any()); + } + + private GetEntityProfileResponseMsg responseFor(TenantId tenantId) { + TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID())); + profile.setName("profile-" + tenantId.getId()); + return GetEntityProfileResponseMsg.newBuilder() + .setEntityType(EntityType.TENANT.name()) + .setTenantProfile(ProtoUtils.toProto(profile)) + .setApiState(ProtoUtils.toProto(enabledApiUsageState(tenantId))) + .build(); + } + + private ApiUsageState enabledApiUsageState(TenantId tenantId) { + ApiUsageState state = new ApiUsageState(new ApiUsageStateId(UUID.randomUUID())); + state.setTenantId(tenantId); + state.setEntityId(tenantId); + state.setTransportState(ApiUsageStateValue.ENABLED); + state.setDbStorageState(ApiUsageStateValue.ENABLED); + state.setReExecState(ApiUsageStateValue.ENABLED); + state.setJsExecState(ApiUsageStateValue.ENABLED); + state.setTbelExecState(ApiUsageStateValue.ENABLED); + state.setEmailExecState(ApiUsageStateValue.ENABLED); + state.setSmsExecState(ApiUsageStateValue.ENABLED); + state.setAlarmExecState(ApiUsageStateValue.ENABLED); + state.setVersion(1L); + return state; + } + +} diff --git a/common/util/pom.xml b/common/util/pom.xml index f1b6cd12ac..3c60d4f077 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/common/version-control/pom.xml b/common/version-control/pom.xml index 04de87e821..09c4318a20 100644 --- a/common/version-control/pom.xml +++ b/common/version-control/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index 19033c1187..4449502857 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT thingsboard dao diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java index 70f5f48272..5ce73bfcb7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java @@ -48,6 +48,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; import java.util.List; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -155,11 +156,17 @@ public class DeviceConnectivityServiceImpl implements DeviceConnectivityService @Override public Resource createGatewayDockerComposeFile(String baseUrl, Device device) throws URISyntaxException { + DockerComposeParams params = new DockerComposeParams(true, "tb-gateway", true, true, true, true); + return createGatewayDockerComposeFile(baseUrl, device, params); + } + + @Override + public Resource createGatewayDockerComposeFile(String baseUrl, Device device, DockerComposeParams params) throws URISyntaxException { String mqttType = isEnabled(MQTTS) ? MQTTS : MQTT; DeviceConnectivityInfo properties = getConnectivity(mqttType); DeviceCredentials creds = deviceCredentialsService.findDeviceCredentialsByDeviceId(device.getTenantId(), device.getId()); String host = getHost(baseUrl, properties, mqttType); - return DeviceConnectivityUtil.getGatewayDockerComposeFile(host, gatewayImageVersion, creds); + return DeviceConnectivityUtil.getGatewayDockerComposeFile(host, gatewayImageVersion, creds, params); } private DeviceConnectivityInfo getConnectivity(String protocol) { @@ -176,6 +183,23 @@ public class DeviceConnectivityServiceImpl implements DeviceConnectivityService return info != null && info.isEnabled(); } + @Override + public JsonNode getConnectivityInfo(String baseUrl) throws URISyntaxException { + String[] protocols = {HTTP, HTTPS, MQTT, MQTTS, COAP, COAPS}; + Map result = new LinkedHashMap<>(); + for (String protocol : protocols) { + DeviceConnectivityInfo info = getConnectivity(protocol); + if (info != null && info.isEnabled()) { + DeviceConnectivityInfo resolved = new DeviceConnectivityInfo(); + resolved.setEnabled(true); + resolved.setHost(getHost(baseUrl, info, protocol)); + resolved.setPort(getPort(info)); + result.put(protocol, resolved); + } + } + return JacksonUtil.valueToTree(result); + } + private Resource getCert(String path) { if (StringUtils.isBlank(path) || !ResourceUtils.resourceExists(this, path)) { return null; diff --git a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java new file mode 100644 index 0000000000..8e20a68eb0 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java @@ -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. + */ +package org.thingsboard.server.dao.iot_hub; + +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.Dao; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public interface IotHubInstalledItemDao extends Dao { + + Optional findByTenantIdAndId(TenantId tenantId, IotHubInstalledItemId installedItemId); + + PageData findByTenantId(TenantId tenantId, List itemTypes, UUID itemId, PageLink pageLink); + + List findInstalledItemIdsByTenantId(TenantId tenantId); + + List findInstalledItemIdsByTenantIdAndItemIdIn(TenantId tenantId, Collection itemIds); + + long countByTenantId(TenantId tenantId, String itemType); + + Map findInstalledItemCounts(TenantId tenantId, String itemType); + + void deleteByTenantId(TenantId tenantId); + + boolean deleteByTenantIdAndId(TenantId tenantId, IotHubInstalledItemId installedItemId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java new file mode 100644 index 0000000000..874e9bfcc5 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java @@ -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. + */ +package org.thingsboard.server.dao.iot_hub; + +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public interface IotHubInstalledItemService { + + IotHubInstalledItem save(TenantId tenantId, IotHubInstalledItem item); + + IotHubInstalledItem findById(TenantId tenantId, IotHubInstalledItemId id); + + PageData findByTenantId(TenantId tenantId, List itemTypes, UUID itemId, PageLink pageLink); + + List findInstalledItemIdsByTenantId(TenantId tenantId); + + List findInstalledItemIdsByTenantIdAndItemIdIn(TenantId tenantId, Collection itemIds); + + long countByTenantId(TenantId tenantId, String itemType); + + Map findInstalledItemCounts(TenantId tenantId, String itemType); + + void deleteById(TenantId tenantId, IotHubInstalledItemId id); + + void deleteByTenantId(TenantId tenantId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java new file mode 100644 index 0000000000..7e86bcb0b9 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java @@ -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. + */ +package org.thingsboard.server.dao.iot_hub; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.model.sql.IotHubInstalledItemEntity; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.thingsboard.server.dao.service.Validator.validatePageLink; + +@Service +@RequiredArgsConstructor +@Slf4j +class IotHubInstalledItemServiceImpl implements IotHubInstalledItemService { + + private final IotHubInstalledItemDao iotHubInstalledItemDao; + + @Override + public IotHubInstalledItem save(TenantId tenantId, IotHubInstalledItem item) { + log.debug("[{}] Saving IoT Hub installed item: {}", tenantId, item); + return iotHubInstalledItemDao.save(tenantId, item); + } + + @Override + public IotHubInstalledItem findById(TenantId tenantId, IotHubInstalledItemId id) { + return iotHubInstalledItemDao.findByTenantIdAndId(tenantId, id).orElse(null); + } + + @Override + public PageData findByTenantId(TenantId tenantId, List itemTypes, UUID itemId, PageLink pageLink) { + validatePageLink(pageLink, IotHubInstalledItemEntity.ALLOWED_SORT_PROPERTIES); + return iotHubInstalledItemDao.findByTenantId(tenantId, itemTypes, itemId, pageLink); + } + + @Override + public List findInstalledItemIdsByTenantId(TenantId tenantId) { + return iotHubInstalledItemDao.findInstalledItemIdsByTenantId(tenantId); + } + + @Override + public List findInstalledItemIdsByTenantIdAndItemIdIn(TenantId tenantId, Collection itemIds) { + return iotHubInstalledItemDao.findInstalledItemIdsByTenantIdAndItemIdIn(tenantId, itemIds); + } + + @Override + public long countByTenantId(TenantId tenantId, String itemType) { + return iotHubInstalledItemDao.countByTenantId(tenantId, itemType); + } + + @Override + public Map findInstalledItemCounts(TenantId tenantId, String itemType) { + return iotHubInstalledItemDao.findInstalledItemCounts(tenantId, itemType); + } + + @Override + public void deleteById(TenantId tenantId, IotHubInstalledItemId id) { + iotHubInstalledItemDao.deleteByTenantIdAndId(tenantId, id); + } + + @Override + public void deleteByTenantId(TenantId tenantId) { + iotHubInstalledItemDao.deleteByTenantId(tenantId); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index de1a04e81e..d0573d0b69 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -760,6 +760,18 @@ public class ModelConstants { public static final String AI_MODEL_NAME_COLUMN_NAME = NAME_PROPERTY; public static final String AI_MODEL_CONFIGURATION_COLUMN_NAME = "configuration"; + /** + * IoT Hub installed item constants. + */ + public static final String IOT_HUB_INSTALLED_ITEM_TABLE_NAME = "iot_hub_installed_item"; + public static final String IOT_HUB_INSTALLED_ITEM_TENANT_ID_COLUMN = TENANT_ID_COLUMN; + public static final String IOT_HUB_INSTALLED_ITEM_ITEM_ID_COLUMN = "item_id"; + public static final String IOT_HUB_INSTALLED_ITEM_ITEM_VERSION_ID_COLUMN = "item_version_id"; + public static final String IOT_HUB_INSTALLED_ITEM_ITEM_NAME_COLUMN = "item_name"; + public static final String IOT_HUB_INSTALLED_ITEM_ITEM_TYPE_COLUMN = "item_type"; + public static final String IOT_HUB_INSTALLED_ITEM_VERSION_COLUMN = "version"; + public static final String IOT_HUB_INSTALLED_ITEM_DESCRIPTOR_COLUMN = "descriptor"; + protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN}; protected static final String[] COUNT_AGGREGATION_COLUMNS = new String[]{count(LONG_VALUE_COLUMN), count(DOUBLE_VALUE_COLUMN), count(BOOLEAN_VALUE_COLUMN), count(STRING_VALUE_COLUMN), count(JSON_VALUE_COLUMN), max(TS_COLUMN)}; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/IotHubInstalledItemEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/IotHubInstalledItemEntity.java new file mode 100644 index 0000000000..118a175a36 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/IotHubInstalledItemEntity.java @@ -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. + */ +package org.thingsboard.server.dao.model.sql; + +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.Type; +import org.hibernate.proxy.HibernateProxy; +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemDescriptor; +import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.ModelConstants; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +@Getter +@Setter +@ToString +@Entity +@Table(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_TABLE_NAME) +public class IotHubInstalledItemEntity extends BaseSqlEntity { + + public static final Set ALLOWED_SORT_PROPERTIES = Collections.unmodifiableSet( + new LinkedHashSet<>(List.of("createdTime", "itemName", "itemType", "version")) + ); + + @Column(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_TENANT_ID_COLUMN, nullable = false, columnDefinition = "UUID") + private UUID tenantId; + + @Column(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_ITEM_ID_COLUMN, nullable = false, columnDefinition = "UUID") + private UUID itemId; + + @Column(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_ITEM_VERSION_ID_COLUMN, nullable = false, columnDefinition = "UUID") + private UUID itemVersionId; + + @Column(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_ITEM_NAME_COLUMN, nullable = false) + private String itemName; + + @Column(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_ITEM_TYPE_COLUMN, nullable = false) + private String itemType; + + @Column(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_VERSION_COLUMN, nullable = false) + private String version; + + @Type(JsonBinaryType.class) + @Column(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_DESCRIPTOR_COLUMN, nullable = false, columnDefinition = "JSONB") + private IotHubInstalledItemDescriptor descriptor; + + public IotHubInstalledItemEntity() { + } + + public IotHubInstalledItemEntity(IotHubInstalledItem item) { + super(item); + tenantId = getTenantUuid(item.getTenantId()); + itemId = item.getItemId(); + itemVersionId = item.getItemVersionId(); + itemName = item.getItemName(); + itemType = item.getItemType(); + version = item.getVersion(); + descriptor = item.getDescriptor(); + } + + @Override + public IotHubInstalledItem toData() { + var item = new IotHubInstalledItem(new IotHubInstalledItemId(id)); + item.setCreatedTime(createdTime); + item.setTenantId(TenantId.fromUUID(tenantId)); + item.setItemId(itemId); + item.setItemVersionId(itemVersionId); + item.setItemName(itemName); + item.setItemType(itemType); + item.setVersion(version); + item.setDescriptor(descriptor); + return item; + } + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); + Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + IotHubInstalledItemEntity that = (IotHubInstalledItemEntity) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java index 7035fcfd9b..c053f36d5b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java @@ -18,10 +18,13 @@ package org.thingsboard.server.dao.service.validator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.dao.device.DeviceCredentialsDao; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; @@ -69,9 +72,24 @@ public class DeviceCredentialsDataValidator extends DataValidator { + + Optional findByTenantIdAndId(UUID tenantId, UUID id); + + @Query("SELECT DISTINCT item.itemId FROM IotHubInstalledItemEntity item WHERE item.tenantId = :tenantId") + List findInstalledItemIdsByTenantId(@Param("tenantId") UUID tenantId); + + @Query("SELECT DISTINCT item.itemId FROM IotHubInstalledItemEntity item " + + "WHERE item.tenantId = :tenantId AND item.itemId IN :itemIds") + List findInstalledItemIdsByTenantIdAndItemIdIn(@Param("tenantId") UUID tenantId, + @Param("itemIds") Collection itemIds); + + @Query(""" + SELECT item FROM IotHubInstalledItemEntity item + WHERE item.tenantId = :tenantId + AND (:itemTypes IS NULL OR item.itemType IN :itemTypes) + AND (:itemId IS NULL OR item.itemId = :itemId) + AND (:textSearch IS NULL + OR ilike(item.itemName, CONCAT('%', :textSearch, '%')) = true + OR ilike(item.itemType, CONCAT('%', :textSearch, '%')) = true + OR ilike(item.version, CONCAT('%', :textSearch, '%')) = true) + """) + Page findByTenantId(@Param("tenantId") UUID tenantId, @Param("itemTypes") List itemTypes, @Param("itemId") UUID itemId, @Param("textSearch") String textSearch, Pageable pageable); + + @Query(""" + SELECT COUNT(item) FROM IotHubInstalledItemEntity item + WHERE item.tenantId = :tenantId + AND (:itemType IS NULL OR item.itemType = :itemType) + """) + long countByTenantId(@Param("tenantId") UUID tenantId, @Param("itemType") String itemType); + + @Query(""" + SELECT item.itemId, COUNT(item) FROM IotHubInstalledItemEntity item + WHERE item.tenantId = :tenantId + AND item.itemType = :itemType + GROUP BY item.itemId + """) + List findInstalledItemCounts(@Param("tenantId") UUID tenantId, @Param("itemType") String itemType); + + @Transactional + @Modifying + @Query(value = "DELETE FROM iot_hub_installed_item WHERE tenant_id = :tenantId", nativeQuery = true) + void deleteByTenantId(@Param("tenantId") UUID tenantId); + + @Transactional + @Modifying + @Query("DELETE FROM IotHubInstalledItemEntity item WHERE item.tenantId = :tenantId AND item.id IN (:ids)") + int deleteByTenantIdAndIdIn(@Param("tenantId") UUID tenantId, @Param("ids") Set ids); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java new file mode 100644 index 0000000000..5cd305b5ad --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java @@ -0,0 +1,126 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.sql.iot_hub; + +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; +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.dao.DaoUtil; +import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemDao; +import org.thingsboard.server.dao.model.sql.IotHubInstalledItemEntity; +import org.thingsboard.server.dao.sql.JpaAbstractDao; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +@SqlDao +@Component +@RequiredArgsConstructor +class JpaIotHubInstalledItemDao extends JpaAbstractDao implements IotHubInstalledItemDao { + + private final IotHubInstalledItemRepository repository; + + @Override + public Optional findByTenantIdAndId(TenantId tenantId, IotHubInstalledItemId installedItemId) { + return repository.findByTenantIdAndId(tenantId.getId(), installedItemId.getId()).map(DaoUtil::getData); + } + + @Override + public PageData findByTenantId(TenantId tenantId, List itemTypes, UUID itemId, PageLink pageLink) { + return DaoUtil.toPageData(repository.findByTenantId( + tenantId.getId(), + itemTypes == null || itemTypes.isEmpty() ? null : itemTypes, + itemId, + StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), + toPageRequest(pageLink)) + ); + } + + @Override + public List findInstalledItemIdsByTenantId(TenantId tenantId) { + return repository.findInstalledItemIdsByTenantId(tenantId.getId()); + } + + @Override + public List findInstalledItemIdsByTenantIdAndItemIdIn(TenantId tenantId, Collection itemIds) { + return repository.findInstalledItemIdsByTenantIdAndItemIdIn(tenantId.getId(), itemIds); + } + + @Override + public long countByTenantId(TenantId tenantId, String itemType) { + return repository.countByTenantId(tenantId.getId(), itemType); + } + + @Override + public Map findInstalledItemCounts(TenantId tenantId, String itemType) { + List results = repository.findInstalledItemCounts(tenantId.getId(), itemType); + Map counts = new HashMap<>(); + for (Object[] row : results) { + counts.put((UUID) row[0], (Long) row[1]); + } + return counts; + } + + @Override + public void deleteByTenantId(TenantId tenantId) { + repository.deleteByTenantId(tenantId.getId()); + } + + @Override + public boolean deleteByTenantIdAndId(TenantId tenantId, IotHubInstalledItemId installedItemId) { + return repository.deleteByTenantIdAndIdIn(tenantId.getId(), Set.of(installedItemId.getId())) > 0; + } + + private static PageRequest toPageRequest(PageLink pageLink) { + Sort sort; + SortOrder sortOrder = pageLink.getSortOrder(); + if (sortOrder == null) { + sort = Sort.by(Sort.Direction.DESC, "createdTime"); + } else { + sort = Sort.by( + Sort.Direction.fromString(sortOrder.getDirection().name()), + sortOrder.getProperty() + ).and(Sort.by(Sort.Direction.ASC, "id")); + } + return PageRequest.of(pageLink.getPage(), pageLink.getPageSize(), sort); + } + + @Override + protected Class getEntityClass() { + return IotHubInstalledItemEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return repository; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index 3674804493..0045a1042e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -39,6 +39,7 @@ import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.entity.AbstractCachedEntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; +import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService; import org.thingsboard.server.dao.mobile.QrCodeSettingService; import org.thingsboard.server.dao.notification.NotificationSettingsService; import org.thingsboard.server.dao.service.PaginatedRemover; @@ -83,6 +84,8 @@ public class TenantServiceImpl extends AbstractCachedEntityService existsTenantCache; @@ -168,6 +171,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("credentialsId") + .hasMessageContaining("control characters"); + } + + @Test + void rejectsCarriageReturnInAccessToken() { + DeviceCredentials creds = accessToken("token\rprivileged: true"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("control characters"); + } + + @Test + void rejectsNewlineInMqttClientId() { + DeviceCredentials creds = mqttBasic("cid\nentrypoint: x", "user", "pwd"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("clientId"); + } + + @Test + void rejectsNewlineInMqttUserName() { + DeviceCredentials creds = mqttBasic("cid", "user\nprivileged: true", "pwd"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("userName"); + } + + @Test + void rejectsNewlineInMqttPassword() { + DeviceCredentials creds = mqttBasic("cid", "user", "pwd\nentrypoint: x"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("password"); + } + + @Test + void acceptsValidCredentials() { + willReturn(new Device()).given(deviceService).findDeviceById(tenantId, deviceId); + DeviceCredentials creds = accessToken("safe_token_123"); + + assertThatCode(() -> validator.validateDataImpl(tenantId, creds)) + .doesNotThrowAnyException(); + } + + @Test + void acceptsValidMqttBasicCredentials() { + willReturn(new Device()).given(deviceService).findDeviceById(tenantId, deviceId); + DeviceCredentials creds = mqttBasic("client-1", "user-1", "pwd-1"); + + assertThatCode(() -> validator.validateDataImpl(tenantId, creds)) + .doesNotThrowAnyException(); + } + + private DeviceCredentials accessToken(String token) { + DeviceCredentials c = new DeviceCredentials(); + c.setDeviceId(deviceId); + c.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + c.setCredentialsId(token); + return c; + } + + private DeviceCredentials mqttBasic(String clientId, String userName, String password) { + BasicMqttCredentials inner = new BasicMqttCredentials(); + inner.setClientId(clientId); + inner.setUserName(userName); + inner.setPassword(password); + DeviceCredentials c = new DeviceCredentials(); + c.setDeviceId(deviceId); + c.setCredentialsType(DeviceCredentialsType.MQTT_BASIC); + c.setCredentialsId("mqtt-credentials-id"); + c.setCredentialsValue(JacksonUtil.toString(inner)); + return c; + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java b/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java index d4c93a1d5f..e53a76f09f 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java @@ -16,6 +16,14 @@ package org.thingsboard.server.dao.util; import org.junit.jupiter.api.Test; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.dao.device.DockerComposeParams; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; @@ -29,4 +37,121 @@ class DeviceConnectivityUtilTest { assertThat(DeviceConnectivityUtil.CA_ROOT_CERT_PEM).doesNotContainAnyWhitespaces(); } + @Test + void validAccessTokenIsRenderedAsIs() throws Exception { + String yaml = renderCompose(accessToken("safe_token_123")); + + assertThat(yaml).contains("- TB_GW_ACCESS_TOKEN=safe_token_123\n"); + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInAccessTokenIsSanitized() throws Exception { + String malicious = "safe_token\n entrypoint: [\"/bin/bash\",\"-c\",\"id\"]"; + + String yaml = renderCompose(accessToken(malicious)); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void carriageReturnInAccessTokenIsSanitized() throws Exception { + String yaml = renderCompose(accessToken("token\rprivileged: true")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInMqttClientIdIsSanitized() throws Exception { + String yaml = renderCompose(mqttBasic("cid\n entrypoint: [\"/bin/sh\"]", "user", "pwd")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInMqttUserNameIsSanitized() throws Exception { + String yaml = renderCompose(mqttBasic("cid", "user\n privileged: true", "pwd")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInMqttPasswordIsSanitized() throws Exception { + String yaml = renderCompose(mqttBasic("cid", "user", "pwd\n entrypoint: [\"/bin/sh\"]")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void mqttBasicQuoteInUserNameIsEscapedInPublishCommand() { + String command = DeviceConnectivityUtil.getMqttPublishCommand( + "mqtt", "localhost", "1883", "v1/devices/me/telemetry", + mqttBasic("cid", "u\";touch pwned;echo \"", "pwd")); + + // the double quote must be backslash-escaped so it cannot terminate the -u "..." argument + assertThat(command).contains("-u \"u\\\";touch pwned;echo \\\"\""); + assertThat(command).doesNotContain("-u \"u\";"); + } + + @Test + void controlCharsInMqttClientIdAreStrippedInPublishCommand() { + String command = DeviceConnectivityUtil.getMqttPublishCommand( + "mqtt", "localhost", "1883", "v1/devices/me/telemetry", + mqttBasic("c\nid", "user", "pwd")); + + assertThat(command).doesNotContain("\n"); + assertThat(command).contains("-i \"c_id\""); + } + + @Test + void controlCharsInAccessTokenAreStrippedInHttpAndCoapCommands() { + DeviceCredentials creds = accessToken("tok\nen"); + + assertThat(DeviceConnectivityUtil.getHttpPublishCommand("http", "localhost", ":8080", creds)) + .doesNotContain("\n") + .contains("/api/v1/tok_en/telemetry"); + assertThat(DeviceConnectivityUtil.getCoapPublishCommand("coap", "localhost", ":5683", creds)) + .doesNotContain("\n") + .contains("/api/v1/tok_en/telemetry"); + } + + private static String renderCompose(DeviceCredentials credentials) throws Exception { + DockerComposeParams params = new DockerComposeParams(true, "tb-gateway", true, true, true, true); + var resource = DeviceConnectivityUtil.getGatewayDockerComposeFile( + "host.docker.internal", "3.8-stable", credentials, params); + try (var in = resource.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static DeviceCredentials accessToken(String token) { + DeviceCredentials c = new DeviceCredentials(); + c.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + c.setCredentialsId(token); + return c; + } + + private static DeviceCredentials mqttBasic(String clientId, String userName, String password) { + BasicMqttCredentials inner = new BasicMqttCredentials(); + inner.setClientId(clientId); + inner.setUserName(userName); + inner.setPassword(password); + DeviceCredentials c = new DeviceCredentials(); + c.setCredentialsType(DeviceCredentialsType.MQTT_BASIC); + c.setCredentialsId("mqtt-credentials-id"); + c.setCredentialsValue(JacksonUtil.toString(inner)); + return c; + } + + private static void assertNoInjectedSiblingKeys(String yaml) throws IOException { + for (String line : yaml.split("\n")) { + String trimmed = line.replaceFirst("^\\s+", ""); + assertThat(trimmed) + .as("unexpected sibling key — possible YAML injection: %s", line) + .doesNotStartWith("entrypoint:") + .doesNotStartWith("privileged:") + .doesNotStartWith("command:"); + } + } + } diff --git a/edqs/pom.xml b/edqs/pom.xml index 77b16fadb8..2d811909ec 100644 --- a/edqs/pom.xml +++ b/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT thingsboard edqs diff --git a/monitoring/pom.xml b/monitoring/pom.xml index 6ddba7f4cc..fa8b83c6f7 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -21,7 +21,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT thingsboard diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index 5578f39ce6..e433d6bfb2 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/edqs/pom.xml b/msa/edqs/pom.xml index fce8c0623c..54a1cd91c7 100644 --- a/msa/edqs/pom.xml +++ b/msa/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json index 9cd29e35d5..070bf829a5 100644 --- a/msa/js-executor/package.json +++ b/msa/js-executor/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-js-executor", "private": true, - "version": "4.2.2.2", + "version": "4.2.2.3", "description": "ThingsBoard JavaScript Executor Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index 3e6def2d6e..7174947cbc 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/monitoring/pom.xml b/msa/monitoring/pom.xml index f5c4b53581..519c928b6e 100644 --- a/msa/monitoring/pom.xml +++ b/msa/monitoring/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT msa diff --git a/msa/pom.xml b/msa/pom.xml index 9ccd557a75..2552bf188c 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT thingsboard msa diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml index a119c68e16..ba83cbb211 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index 609535a282..a20505772e 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index df5cfe1018..309536b4ef 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.2 + 4.2.2.3-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index 033a06c18c..f7ea4160d1 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.2 + 4.2.2.3-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/lwm2m/pom.xml b/msa/transport/lwm2m/pom.xml index 6c6ab4975a..d80b63217c 100644 --- a/msa/transport/lwm2m/pom.xml +++ b/msa/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.2 + 4.2.2.3-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index 1a4c702938..0bf990171b 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.2 + 4.2.2.3-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml index 97063551e7..a711411729 100644 --- a/msa/transport/pom.xml +++ b/msa/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/snmp/pom.xml b/msa/transport/snmp/pom.xml index d6918909b3..f0996dc1bf 100644 --- a/msa/transport/snmp/pom.xml +++ b/msa/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.msa transport - 4.2.2.2 + 4.2.2.3-SNAPSHOT org.thingsboard.msa.transport diff --git a/msa/vc-executor-docker/pom.xml b/msa/vc-executor-docker/pom.xml index dd78d80568..9d781e0854 100644 --- a/msa/vc-executor-docker/pom.xml +++ b/msa/vc-executor-docker/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index f6707005b7..47fff0501d 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/web-ui/package.json b/msa/web-ui/package.json index aaac7f8b67..4c58d7a94f 100644 --- a/msa/web-ui/package.json +++ b/msa/web-ui/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-web-ui", "private": true, - "version": "4.2.2.2", + "version": "4.2.2.3", "description": "ThingsBoard Web UI Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index aa9c3d90ec..bc40e149ff 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT msa org.thingsboard.msa diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index cc6066490f..6a2b55cd84 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,11 +19,11 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT thingsboard netty-mqtt - 4.2.2.2 + 4.2.2.3-SNAPSHOT jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index aff0becdc9..1a2faa6cb7 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT pom Thingsboard @@ -62,15 +62,15 @@ ${project.name} /var/log/${pkg.name} /usr/share/${pkg.name} - 3.5.14 - + 3.5.15 + 3.5.13 3.18.0 - 42.7.11 - 4.1.134.Final - 10.1.55 + 4.1.135.Final 2.4.0-b180830.0359 0.12.5 0.10 @@ -136,20 +136,18 @@ 1.11.0 4.4 1.0.4TB - 3.7.1 + 6.3.0 10.1.3 3.7.4 4.0.2 1.7.5 3.8.0 - 1.8.0-TB - 2.5.9 - 2.38.0 + 1.16.1-TB1 + 2.43.0 1.24 1.11.0 - 3.49.3 + 3.42.0 0.27.0 - 1.7.0 2.7.3 1.5.6 @@ -1017,23 +1015,6 @@ import - - - org.apache.tomcat.embed - tomcat-embed-core - ${tomcat.version} - - - org.apache.tomcat.embed - tomcat-embed-el - ${tomcat.version} - - - org.apache.tomcat.embed - tomcat-embed-websocket - ${tomcat.version} - - org.springframework.boot spring-boot-dependencies @@ -1371,16 +1352,6 @@ commons-lang3 ${commons-lang3.version} - - org.postgresql - postgresql - ${postgresql.version} - - - org.apache.opennlp - opennlp-tools - ${opennlp-tools.version} - commons-io commons-io @@ -1801,7 +1772,7 @@ com.squareup.wire - wire-schema + wire-schema-jvm ${wire-schema.version} @@ -2025,11 +1996,6 @@ perfmark-api ${perfmark-api.version} - - org.threeten - threetenbp - ${threetenbp.version} - diff --git a/rest-client/pom.xml b/rest-client/pom.xml index beee2b0021..9051482016 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT thingsboard rest-client diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index fd278bfb23..2c73670dde 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 702625b7b0..8d9b6e5cbc 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 4b1f277c2c..dcb0f975f2 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java index bff72beb9b..28cf510207 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java @@ -69,7 +69,6 @@ import java.util.Set; import java.util.UUID; import static com.google.common.util.concurrent.MoreExecutors.directExecutor; -import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbResponseFormatType; import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; @Slf4j @@ -93,7 +92,7 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie configDirective = "tbExternalNodeAiConfig", iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDkiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OSA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zOC42MzExIDE3LjA3OTVDNDAuMTcwNSAxNy4wNzk2IDQxLjY1MTggMTcuNjg3MiA0Mi43NDc4IDE4Ljc3NjNDNDMuODQ0OCAxOS44NjYzIDQ0LjQ2NTkgMjEuMzUwMSA0NC40NjU5IDIyLjkwMjlWMzUuNDY1MkM0NC40NjU5IDM2LjM1MDkgNDQuMzU2NyAzNy4wNzY5IDQ0LjA5NzMgMzcuNzUxN0M0My44NDE0IDM4LjQxNjcgNDMuNDY1MSAzOC45NjE0IDQzLjA0NDggMzkuNTAyOEM0Mi40NjY3IDQwLjI0NzIgNDEuNjU2MyA0MC42ODU5IDQwLjg5MTkgNDAuOTM4OEM0MC4xMjExIDQxLjE5MzcgMzkuMzE0MyA0MS4yODg1IDM4LjYzMTEgNDEuMjg4NUgzMS4wMjU5TDIzLjM4MTIgNDUuODQ2NEMyMy4wNDMxIDQ2LjA0NzggMjIuNjI0MSA0Ni4wNTA3IDIyLjI4MzkgNDUuODUyOUMyMS45NDM3IDQ1LjY1NDcgMjEuNzMzOCA0NS4yODU5IDIxLjczMzcgNDQuODg3MlY0MS4yODg1SDE5LjY2NjNDMTguMTI2OSA0MS4yODg0IDE2LjY0NTUgNDAuNjgwOSAxNS41NDk2IDM5LjU5MThDMTQuNDUyNyAzOC41MDE5IDEzLjgzMTUgMzcuMDE3OSAxMy44MzE1IDM1LjQ2NTJWMjIuOTAyOUMxMy44MzE1IDIyLjMyMDIgMTMuOTE4NSAyMS43NDY4IDE0LjA4NTggMjEuMjAwN0wxNi4yODg5IDIxLjgxMDFMMTcuMjA5OSAyNS4yNTAyQzE3Ljk0MTYgMjcuOTg0NSAyMS43NTYyIDI3Ljk4NDQgMjIuNDg4IDI1LjI1MDJMMjMuNDA3OSAyMS44MTAxTDI2Ljc5MTcgMjAuODc0OUMyOC41NzkxIDIwLjM4MDUgMjkuMTc3IDE4LjUwMjYgMjguNTg4OCAxNy4wNzk1SDM4LjYzMTFaTTIyLjU4NDIgMzEuNTM5NUMyMS45OCAzMS41Mzk3IDIxLjQ5MDEgMzIuMDM3NiAyMS40OTAxIDMyLjY1MTlDMjEuNDkwMiAzMy4yNjYgMjEuOTgwMSAzMy43NjQgMjIuNTg0MiAzMy43NjQySDM0LjYxOTFDMzUuMjIzMyAzMy43NjQyIDM1LjcxMzEgMzMuMjY2MSAzNS43MTMyIDMyLjY1MTlDMzUuNzEzMiAzMi4wMzc1IDM1LjIyMzQgMzEuNTM5NSAzNC42MTkxIDMxLjUzOTVIMjIuNTg0MlpNMjQuNzcyMyAyNC44NjU3QzI0LjE2ODIgMjQuODY1OCAyMy42NzgzIDI1LjM2MzggMjMuNjc4MyAyNS45NzhDMjMuNjc4NCAyNi41OTIyIDI0LjE2ODMgMjcuMDkwMiAyNC43NzIzIDI3LjA5MDNIMzcuOTAxNEMzOC41MDU1IDI3LjA5MDMgMzguOTk1MyAyNi41OTIyIDM4Ljk5NTQgMjUuOTc4QzM4Ljk5NTQgMjUuMzYzNyAzOC41MDU2IDI0Ljg2NTcgMzcuOTAxNCAyNC44NjU3SDI0Ljc3MjNaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0xOC43ODkxIDExLjI5NzVDMTkuMDY5MSAxMC4xODA4IDIwLjYyOTkgMTAuMTgwOCAyMC45MDk5IDExLjI5NzVMMjEuOTE0MyAxNS4zMDM2QzIyLjAxMTYgMTUuNjkxOCAyMi4zMDY1IDE1Ljk5NzggMjIuNjg2NyAxNi4xMDNMMjYuMzYxMSAxNy4xMTg3QzI3LjQzNyAxNy40MTYyIDI3LjQzNyAxOC45Njc2IDI2LjM2MTEgMTkuMjY1MUwyMi42NzYxIDIwLjI4NEMyMi4zMDE4IDIwLjM4NzQgMjIuMDA4NyAyMC42ODQ1IDIxLjkwNjggMjEuMDY1TDIwLjkwNDYgMjQuODEyNUMyMC42MTE3IDI1LjkwNTggMTkuMDg2MSAyNS45MDU5IDE4Ljc5MzMgMjQuODEyNUwxNy43OTExIDIxLjA2NUMxNy42ODkzIDIwLjY4NDcgMTcuMzk3IDIwLjM4NzUgMTcuMDIyOSAyMC4yODRMMTMuMzM2OCAxOS4yNjUxQzEyLjI2MTQgMTguOTY3MyAxMi4yNjE1IDE3LjQxNjUgMTMuMzM2OCAxNy4xMTg3TDE3LjAxMTIgMTYuMTAzQzE3LjM5MTYgMTUuOTk3OCAxNy42ODc0IDE1LjY5MTkgMTcuNzg0NyAxNS4zMDM2TDE4Ljc4OTEgMTEuMjk3NVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTEwLjAzNDMgNy4wMjQyNUMxMC4zMDY4IDUuODk0NDQgMTEuODg2OCA1Ljg5NDQ0IDEyLjE1OTQgNy4wMjQyNUwxMi42OTg5IDkuMjYyOThDMTIuNzkyNyA5LjY1MTc0IDEzLjA4NTEgOS45NTg4NyAxMy40NjQgMTAuMDY3OUwxNS41NzczIDEwLjY3NTFDMTYuNjM5MyAxMC45ODAzIDE2LjYzOTMgMTIuNTEwOSAxNS41NzczIDEyLjgxNjFMMTMuNDUzMyAxMy40MjY1QzEzLjA4MDIgMTMuNTMzOCAxMi43OTA4IDEzLjgzMzkgMTIuNjkyNSAxNC4yMTUxTDEyLjE1NTEgMTYuMzA0QzExLjg3IDE3LjQxMTYgMTAuMzIzNiAxNy40MTE2IDEwLjAzODUgMTYuMzA0TDkuNTAwMDMgMTQuMjE1MUM5LjQwMTczIDEzLjgzMzkgOS4xMTIzNSAxMy41MzM3IDguNzM5MyAxMy40MjY1TDYuNjE1MjQgMTIuODE2MUM1LjU1Mzc4IDEyLjUxMDYgNS41NTM2NCAxMC45ODA0IDYuNjE1MjQgMTAuNjc1MUw4LjcyODYyIDEwLjA2NzlDOS4xMDc2IDkuOTU4OTggOS4zOTk3OCA5LjY1MTg0IDkuNDkzNjIgOS4yNjI5OEwxMC4wMzQzIDcuMDI0MjVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0yNS45MDI4IDYuNzMzMTNDMjYuMTg3OCA1LjYyNTQxIDI3LjczNDMgNS42MjU0MSAyOC4wMTkzIDYuNzMzMTNMMjguMjAzMSA3LjQ0Njc5QzI4LjMwMyA3LjgzNDMxIDI4LjYwMDEgOC4xMzcwNSAyOC45ODA5IDguMjM5NzVMMjkuNTM0NCA4LjM4OTY1QzMwLjYxOTIgOC42ODIxMiAzMC42MTkzIDEwLjI0NjkgMjkuNTM0NCAxMC41MzkzTDI4Ljk2OTIgMTAuNjkxNEMyOC41OTQ0IDEwLjc5MjUgMjguMjk5OSAxMS4wODgzIDI4LjE5NTYgMTEuNDY4TDI4LjAxNTEgMTIuMTI4NUMyNy43MTc0IDEzLjIxMjggMjYuMjA0NyAxMy4yMTI4IDI1LjkwNyAxMi4xMjg1TDI1LjcyNTQgMTEuNDY4QzI1LjYyMTEgMTEuMDg4MiAyNS4zMjY4IDEwLjc5MjQgMjQuOTUxOCAxMC42OTE0TDI0LjM4NzcgMTAuNTM5M0MyMy4zMDI2IDEwLjI0NyAyMy4zMDI2IDguNjgxOTggMjQuMzg3NyA4LjM4OTY1TDI0Ljk0MDEgOC4yMzk3NUMyNS4zMjExIDguMTM3MDkgMjUuNjE5MSA3LjgzNDQ2IDI1LjcxOSA3LjQ0Njc5TDI1LjkwMjggNi43MzMxM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPC9zdmc+Cg==", ruleChainTypes = RuleChainType.CORE, - docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/ai-request/" + docUrl = "https://thingsboard.io/docs/reference/rule-engine/nodes/external/ai-request/" ) public final class TbAiNode extends TbAbstractExternalNode implements TbNode { @@ -127,13 +126,11 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] must be of type CHAT, but was " + modelType, true); } AiChatModelConfig chatModelConfig = (AiChatModelConfig) model.getConfiguration(); - if (isJsonModeConfigured(config)) { - if (!chatModelConfig.supportsJsonMode()) { - throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] does not support '" + config.getResponseFormat().type() + "' response format", true); - } - // LangChain4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT - responseFormat = config.getResponseFormat().toLangChainResponseFormat(); + TbResponseFormat tbResponseFormat = config.getResponseFormat(); + if (!tbResponseFormat.isSupportedBy(chatModelConfig)) { + throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] does not support '" + tbResponseFormat.type() + "' response format", true); } + responseFormat = tbResponseFormat.toLangChainResponseFormat(); if (config.getResourceIds() != null && !config.getResourceIds().isEmpty()) { resourceIds = new HashSet<>(config.getResourceIds().size()); for (UUID resourceId : config.getResourceIds()) { @@ -149,11 +146,6 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { super.forceAck = config.isForceAck() || super.forceAck; // force ack if node config says so, or if env variable (super.forceAck) says so } - private static boolean isJsonModeConfigured(TbAiNodeConfiguration config) { - var responseFormatType = config.getResponseFormat().type(); - return responseFormatType == TbResponseFormatType.JSON || responseFormatType == TbResponseFormatType.JSON_SCHEMA; - } - @Override public void onMsg(TbContext ctx, TbMsg msg) { var ackedMsg = ackIfNeeded(ctx, msg); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java index a32469be42..5dd1cd74f0 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import dev.langchain4j.model.chat.request.ResponseFormat; import dev.langchain4j.model.chat.request.ResponseFormatType; import jakarta.validation.constraints.NotNull; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; import org.thingsboard.server.common.data.validation.ValidJsonSchema; import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; @@ -41,6 +42,8 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes TbResponseFormatType type(); + boolean isSupportedBy(AiChatModelConfig modelConfig); + ResponseFormat toLangChainResponseFormat(); enum TbResponseFormatType { @@ -58,6 +61,11 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes return TbResponseFormatType.TEXT; } + @Override + public boolean isSupportedBy(AiChatModelConfig modelConfig) { + return true; + } + @Override public ResponseFormat toLangChainResponseFormat() { return ResponseFormat.TEXT; @@ -72,6 +80,11 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes return TbResponseFormatType.JSON; } + @Override + public boolean isSupportedBy(AiChatModelConfig modelConfig) { + return modelConfig.supportsSchemalessJsonOutput(); + } + @Override public ResponseFormat toLangChainResponseFormat() { return ResponseFormat.JSON; @@ -86,6 +99,11 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes return TbResponseFormatType.JSON_SCHEMA; } + @Override + public boolean isSupportedBy(AiChatModelConfig modelConfig) { + return modelConfig.supportsJsonSchemaOutput(); + } + @Override public ResponseFormat toLangChainResponseFormat() { return ResponseFormat.builder() diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java index a8f2f9b021..a7113c784a 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java @@ -38,8 +38,8 @@ import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.DirectListeningExecutor; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonSchemaResponseFormat; import org.thingsboard.rule.engine.ai.TbResponseFormat.TbTextResponseFormat; @@ -53,8 +53,10 @@ import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; import org.thingsboard.server.common.data.id.AiModelId; @@ -400,6 +402,124 @@ class TbAiNodeTest { .matches(e -> ((TbNodeException) e).isUnrecoverable()); } + @Test + void givenJsonSchemaResponseFormatAndModelSupportsIt_whenInit_thenDoesNotThrow() { + // GIVEN + var jsonSchema = """ + { + "title": "Joke", + "type": "object", + "properties": { + "joke": { + "type": "string" + } + }, + "required": [ + "joke" + ] + } + """; + + config = constructValidConfig(); + config.setResponseFormat(new TbJsonSchemaResponseFormat((ObjectNode) JacksonUtil.toJsonNode(jsonSchema))); + + // Anthropic does not support schemaless JSON mode, but does support JSON Schema constrained output + modelConfig = AnthropicChatModelConfig.builder() + .providerConfig(new AnthropicProviderConfig("test-api-key")) + .modelId("claude-sonnet-4-5") + .build(); + + model = AiModel.builder() + .tenantId(tenantId) + .name("Test model") + .configuration(modelConfig) + .build(); + + model.setId(modelId); + model.setVersion(1L); + model.setCreatedTime(123L); + + given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model)); + + // WHEN-THEN + assertThatNoException() + .isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))); + } + + @Test + void givenJsonSchemaResponseFormatAndBedrockModel_whenInit_thenDoesNotThrow() { + // GIVEN + var jsonSchema = """ + { + "title": "Joke", + "type": "object", + "properties": { + "joke": { + "type": "string" + } + }, + "required": [ + "joke" + ] + } + """; + + config = constructValidConfig(); + config.setResponseFormat(new TbJsonSchemaResponseFormat((ObjectNode) JacksonUtil.toJsonNode(jsonSchema))); + + // Bedrock does not support schemaless JSON mode, but does support JSON Schema constrained output (Converse API) + modelConfig = AmazonBedrockChatModelConfig.builder() + .providerConfig(new AmazonBedrockProviderConfig("us-east-1", "test-access-key", "test-secret-key")) + .modelId("anthropic.claude-sonnet-4-5") + .build(); + + model = AiModel.builder() + .tenantId(tenantId) + .name("Test model") + .configuration(modelConfig) + .build(); + + model.setId(modelId); + model.setVersion(1L); + model.setCreatedTime(123L); + + given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model)); + + // WHEN-THEN + assertThatNoException() + .isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))); + } + + @Test + void givenSchemalessJsonResponseFormatAndBedrockModel_whenInit_thenThrowsUnrecoverableTbNodeException() { + // GIVEN + config = constructValidConfig(); + config.setResponseFormat(new TbJsonResponseFormat()); + + modelConfig = AmazonBedrockChatModelConfig.builder() + .providerConfig(new AmazonBedrockProviderConfig("us-east-1", "test-access-key", "test-secret-key")) + .modelId("anthropic.claude-sonnet-4-5") + .build(); + + model = AiModel.builder() + .tenantId(tenantId) + .name("Test model") + .configuration(modelConfig) + .build(); + + model.setId(modelId); + model.setVersion(1L); + model.setCreatedTime(123L); + + given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model)); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasMessage("[" + tenantId + "] AI model with ID: [" + modelId + "] does not support 'JSON' response format") + .matches(e -> ((TbNodeException) e).isUnrecoverable()); + } + @Test void givenNotExistingResources_whenInit_thenThrowsException() { // GIVEN @@ -669,7 +789,7 @@ class TbAiNodeTest { argThat(actualChatRequest -> { assertThat(actualChatRequest.messages()).hasSize(2); assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(systemPrompt)); - assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents()) + assertThat(((UserMessage) actualChatRequest.messages().get(1)).contents()) .containsAll(List.of(new TextContent(userPrompt), new TextContent(textData), new TextContent(xmlData), new ImageContent(Base64.getEncoder().encodeToString(PNG_IMAGE), "image/png"))); return true; @@ -706,7 +826,7 @@ class TbAiNodeTest { argThat(actualChatRequest -> { assertThat(actualChatRequest.messages()).hasSize(2); assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(config.getSystemPrompt())); - assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents()) + assertThat(((UserMessage) actualChatRequest.messages().get(1)).contents()) .containsAll(List.of(new TextContent(config.getUserPrompt()))); return true; }) @@ -993,7 +1113,7 @@ class TbAiNodeTest { then(aiChatModelServiceMock).should().sendChatRequestAsync( any(), argThat(actualChatRequest -> { - assertThat(actualChatRequest.responseFormat()).isNull(); + assertThat(actualChatRequest.responseFormat()).isEqualTo(ResponseFormat.builder().type(ResponseFormatType.TEXT).build()); return true; }) ); diff --git a/tools/pom.xml b/tools/pom.xml index 0980b90b0e..137dbaa1a7 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT thingsboard tools diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index 5dd96b2155..4fef3b50e7 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index 1554b3fc24..97d53384ba 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -133,6 +133,8 @@ redis: blockWhenExhausted: "${REDIS_POOL_CONFIG_BLOCK_WHEN_EXHAUSTED:true}" transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" # Local CoAP transport parameters coap: # CoaP processing timeout in milliseconds diff --git a/transport/http/pom.xml b/transport/http/pom.xml index b9952eab1b..8d24e0d982 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index c878887a01..bf8d5e1542 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -167,6 +167,8 @@ redis: # HTTP server parameters transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" http: # HTTP request processing timeout in milliseconds request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}" diff --git a/transport/lwm2m/pom.xml b/transport/lwm2m/pom.xml index ee2d964379..99b98fa7c9 100644 --- a/transport/lwm2m/pom.xml +++ b/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 51b1ad0a2b..1c14202efd 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -134,6 +134,8 @@ redis: # LWM2M server parameters transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" sessions: # Session inactivity timeout is a global configuration parameter that defines how long the device transport session will be opened after the last message arrives from the device. # The parameter value is in milliseconds. diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index 6eff01a351..1d61b07440 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index dfe35db29c..0c011e207e 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -135,6 +135,8 @@ redis: # MQTT server parameters transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" mqtt: # MQTT bind-address bind_address: "${MQTT_BIND_ADDRESS:0.0.0.0}" diff --git a/transport/pom.xml b/transport/pom.xml index e39539afc7..3889d8d334 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT thingsboard transport diff --git a/transport/snmp/pom.xml b/transport/snmp/pom.xml index 673379bcde..a1f9d384f1 100644 --- a/transport/snmp/pom.xml +++ b/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT transport diff --git a/transport/snmp/src/main/resources/tb-snmp-transport.yml b/transport/snmp/src/main/resources/tb-snmp-transport.yml index d021030a91..b5ec777af6 100644 --- a/transport/snmp/src/main/resources/tb-snmp-transport.yml +++ b/transport/snmp/src/main/resources/tb-snmp-transport.yml @@ -134,6 +134,8 @@ redis: # Snmp server parameters transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" snmp: # Enable/disable SNMP transport protocol enabled: "${SNMP_ENABLED:true}" diff --git a/ui-ngx/angular.json b/ui-ngx/angular.json index 6d90f9ec20..007af200ba 100644 --- a/ui-ngx/angular.json +++ b/ui-ngx/angular.json @@ -109,7 +109,8 @@ "node_modules/@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css", "node_modules/prismjs/themes/prism.css", "node_modules/prismjs/plugins/line-numbers/prism-line-numbers.css", - "node_modules/ace-diff/dist/ace-diff.min.css" + "node_modules/ace-diff/dist/ace-diff.min.css", + "node_modules/photoswipe/dist/photoswipe.css" ], "stylePreprocessorOptions": { "includePaths": [ @@ -130,6 +131,9 @@ "node_modules/prismjs/components/prism-json.min.js", "node_modules/prismjs/components/prism-javascript.min.js", "node_modules/prismjs/components/prism-typescript.min.js", + "node_modules/prismjs/components/prism-python.min.js", + "node_modules/prismjs/components/prism-c.min.js", + "node_modules/prismjs/components/prism-cpp.min.js", "node_modules/prismjs/plugins/line-numbers/prism-line-numbers.min.js" ], "allowedCommonJsDependencies": [ diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 076e2d316a..2ac8b3a6d7 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -1,6 +1,6 @@ { "name": "thingsboard", - "version": "4.2.2.2", + "version": "4.2.2.3", "scripts": { "ng": "ng", "start": "node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0 --open", @@ -13,16 +13,16 @@ }, "private": true, "dependencies": { - "@angular/animations": "20.3.19", + "@angular/animations": "20.3.25", "@angular/cdk": "20.2.14", - "@angular/common": "20.3.19", - "@angular/compiler": "20.3.19", - "@angular/core": "20.3.19", - "@angular/forms": "20.3.19", + "@angular/common": "20.3.25", + "@angular/compiler": "20.3.25", + "@angular/core": "20.3.25", + "@angular/forms": "20.3.25", "@angular/material": "20.2.14", - "@angular/platform-browser": "20.3.19", - "@angular/platform-browser-dynamic": "20.3.19", - "@angular/router": "20.3.19", + "@angular/platform-browser": "20.3.25", + "@angular/platform-browser-dynamic": "20.3.25", + "@angular/router": "20.3.25", "@auth0/angular-jwt": "^5.2.0", "@flowjs/flow.js": "^2.14.1", "@flowjs/ngx-flow": "20.0.2", @@ -77,6 +77,7 @@ "ngx-sharebuttons": "^17.0.0", "ngx-translate-messageformat-compiler": "^7.2.0", "objectpath": "^2.0.0", + "photoswipe": "^5.4.4", "qrcode": "^1.5.4", "raphael": "^2.3.0", "rxjs": "~7.8.2", @@ -94,13 +95,13 @@ }, "devDependencies": { "@angular-builders/custom-esbuild": "20.0.0", - "@angular-devkit/build-angular": "20.3.24", - "@angular-devkit/core": "20.3.24", - "@angular-devkit/schematics": "20.3.24", - "@angular/build": "20.3.24", - "@angular/cli": "20.3.24", - "@angular/compiler-cli": "20.3.19", - "@angular/language-service": "20.3.19", + "@angular-devkit/build-angular": "20.3.25", + "@angular-devkit/core": "20.3.25", + "@angular-devkit/schematics": "20.3.25", + "@angular/build": "20.3.25", + "@angular/cli": "20.3.25", + "@angular/compiler-cli": "20.3.25", + "@angular/language-service": "20.3.25", "@types/ace-diff": "^2.1.4", "@types/canvas-gauges": "^2.1.8", "@types/flot": "^0.0.36", @@ -139,7 +140,8 @@ "ace-builds": "1.43.6", "tinymce": "6.8.6", "@babel/core": "7.28.3", - "esbuild": "0.28.0", + "esbuild": "0.28.1", + "vite": "7.3.5", "rollup": "4.59.0", "jquery.terminal/**/form-data": ">=4.0.4", "js-beautify/**/minimatch": "^9.0.7" diff --git a/ui-ngx/patches/@angular+build+20.3.24.patch b/ui-ngx/patches/@angular+build+20.3.25.patch similarity index 100% rename from ui-ngx/patches/@angular+build+20.3.24.patch rename to ui-ngx/patches/@angular+build+20.3.25.patch diff --git a/ui-ngx/patches/@angular+core+20.3.19.patch b/ui-ngx/patches/@angular+core+20.3.25.patch similarity index 92% rename from ui-ngx/patches/@angular+core+20.3.19.patch rename to ui-ngx/patches/@angular+core+20.3.25.patch index 5295946f7b..5cb9b5e521 100644 --- a/ui-ngx/patches/@angular+core+20.3.19.patch +++ b/ui-ngx/patches/@angular+core+20.3.25.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@angular/core/fesm2022/debug_node.mjs b/node_modules/@angular/core/fesm2022/debug_node.mjs -index 4f7d936..4a98b2c 100755 +index be43067..079f100 100755 --- a/node_modules/@angular/core/fesm2022/debug_node.mjs +++ b/node_modules/@angular/core/fesm2022/debug_node.mjs -@@ -9428,13 +9428,13 @@ function findDirectiveDefMatches(tView, tNode) { +@@ -9599,13 +9599,13 @@ function findDirectiveDefMatches(tView, tNode) { if (isNodeMatchingSelectorList(tNode, def.selectors, /* isProjectionMode */ false)) { matches ??= []; if (isComponentDef(def)) { diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index d1868ee3e6..863463e78c 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.2 + 4.2.2.3-SNAPSHOT thingsboard org.thingsboard diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts index 5572730923..9ef4a4461b 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -110,6 +110,7 @@ export interface WidgetActionsApi { elementClick: ($event: Event) => void; cardClick: ($event: Event) => void; click: ($event: Event) => void; + invokeAction: ($event: Event, actionName: string, additionalParams?: any) => void; getActiveEntityInfo: () => SubscriptionEntityInfo; openDashboardStateInSeparateDialog: (targetDashboardStateId: string, params?: StateParams, dialogTitle?: string, hideDashboardToolbar?: boolean, dialogWidth?: number, dialogHeight?: number) => MatDialogRef; diff --git a/ui-ngx/src/app/core/api/widget-subscription.ts b/ui-ngx/src/app/core/api/widget-subscription.ts index 213b327892..8b513a4da2 100644 --- a/ui-ngx/src/app/core/api/widget-subscription.ts +++ b/ui-ngx/src/app/core/api/widget-subscription.ts @@ -871,7 +871,8 @@ export class WidgetSubscription implements IWidgetSubscription { return timer(initialTimeout, pollingInterval).pipe( switchMap(() => this.ctx.deviceService.getPersistedRpc(response.rpcId, true)), filter(persistentRespons => - persistentRespons.status !== RpcStatus.DELIVERED && persistentRespons.status !== RpcStatus.QUEUED), + (oneWayElseTwoWay && persistentRespons.status === RpcStatus.DELIVERED) || + (persistentRespons.status !== RpcStatus.DELIVERED && persistentRespons.status !== RpcStatus.QUEUED)), switchMap(persistentResponse => { if ([RpcStatus.TIMEOUT, RpcStatus.EXPIRED].includes(persistentResponse.status)) { return throwError(() => ({status: 504})); diff --git a/ui-ngx/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts index 2485783853..9adc5cdce7 100644 --- a/ui-ngx/src/app/core/auth/auth.models.ts +++ b/ui-ngx/src/app/core/auth/auth.models.ts @@ -37,6 +37,7 @@ export interface SysParamsState { trendzSettings: TrendzSettings; nullsOrderStrategy: NullsOrderStrategy; edqsEnabled: boolean; + iotHubBaseUrl: string; } export interface SysParams extends SysParamsState { diff --git a/ui-ngx/src/app/core/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts index 263b182cb9..c3afbd5e76 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -38,7 +38,8 @@ const emptyUserAuthState: AuthPayload = { userSettings: initialUserSettings, trendzSettings: initialTrendzSettings, nullsOrderStrategy: 'default', - edqsEnabled: false + edqsEnabled: false, + iotHubBaseUrl: '' }; export const initialState: AuthState = { diff --git a/ui-ngx/src/app/core/http/iot-hub-api.service.ts b/ui-ngx/src/app/core/http/iot-hub-api.service.ts new file mode 100644 index 0000000000..ad4eefbdc6 --- /dev/null +++ b/ui-ngx/src/app/core/http/iot-hub-api.service.ts @@ -0,0 +1,290 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { PageData } from '@shared/models/page/page-data'; +import { PageLink } from '@shared/models/page/page-link'; +import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { CreatorView } from '@shared/models/iot-hub/iot-hub-creator.models'; +import { IotHubInstalledItem, InstallItemVersionResult, InstallPlan, InstallPlanResult, UpdateItemVersionResult, ItemPublishedVersionInfo } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { ItemType, ItemTypeFilterInfo, WidgetCategory } from '@shared/models/iot-hub/iot-hub-item.models'; +import { InterceptorHttpParams } from '@core/interceptors/interceptor-http-params'; +import { InterceptorConfig } from '@core/interceptors/interceptor-config'; +import { AppState } from '@core/core.state'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { DeviceConnectivitySettings } from '@shared/models/settings.models'; +import { environment as env } from '@env/environment'; + +export function tbVersionToInt(version: string): number { + const parts = version.replace(/[^0-9.]/g, '').split('.'); + const major = parseInt(parts[0], 10) || 0; + const minor = parseInt(parts[1], 10) || 0; + const patch = parseInt(parts[2], 10) || 0; + return major * 100 + minor * 10 + patch; +} + +// Inverse of tbVersionToInt — produces "{major}.{minor}" by default +// and appends ".{patch}" when the patch component is non-zero so the +// rendered version stays compact (e.g. 430 → "4.3", 421 → "4.2.1"). +export function tbVersionIntToString(version: number): string { + const major = Math.floor(version / 100); + const minor = Math.floor((version % 100) / 10); + const patch = version % 10; + return patch === 0 ? `${major}.${minor}` : `${major}.${minor}.${patch}`; +} + +export function iotHubResourceUrl(baseUrl: string, path: string): string { + if (!path) { + return path; + } + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + return `${baseUrl}${path.startsWith('/') ? '' : '/'}${path}`; +} + +export interface IotHubRequestConfig { + ignoreLoading?: boolean; + ignoreErrors?: boolean; +} + +@Injectable({ + providedIn: 'root' +}) +export class IotHubApiService { + + constructor( + private http: HttpClient, + private store: Store + ) {} + + get baseUrl(): string { + return getCurrentAuthState(this.store)?.iotHubBaseUrl || ''; + } + + public resolveResourceUrl(path: string): string { + return iotHubResourceUrl(this.baseUrl, path); + } + + public getPublishedVersions(query: MpItemVersionQuery, config?: IotHubRequestConfig): Observable> { + if (query.options.tbVersion == null) { + query.options.tbVersion = tbVersionToInt(env.tbVersion); + } + if (query.options.peOnly == null) { + query.options.peOnly = false; + } + return this.http.get>( + `${this.baseUrl}/api/versions/published${query.toQuery()}`, + { params: this.buildParams(config) } + ); + } + + public getFilterInfo(itemType: ItemType, config?: IotHubRequestConfig): Observable { + const url = `${this.baseUrl}/api/item-listing/filterInfo/${itemType}` + + `?peOnly=false&tbVersion=${tbVersionToInt(env.tbVersion)}`; + return this.http.get(url, { params: this.buildParams(config) }); + } + + public getWidgetCategories(textSearch?: string, scadaFirst?: boolean, + config?: IotHubRequestConfig): Observable { + const queryParams: string[] = [ + `peOnly=false`, + `tbVersion=${tbVersionToInt(env.tbVersion)}` + ]; + if (textSearch?.trim()) { + queryParams.push(`textSearch=${encodeURIComponent(textSearch.trim())}`); + } + if (scadaFirst != null) { + queryParams.push(`scadaFirst=${scadaFirst}`); + } + const url = `${this.baseUrl}/api/item-listing/widgetCategories?${queryParams.join('&')}`; + return this.http.get(url, { params: this.buildParams(config) }); + } + + public getVersionInfo(versionId: string, config?: IotHubRequestConfig): Observable { + return this.http.get( + `${this.baseUrl}/api/versions/${versionId}`, + { params: this.buildParams(config) } + ); + } + + public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable { + return this.http.get( + `${this.baseUrl}/api/items/${itemId}/published`, + { params: this.buildParams(config) } + ); + } + + /** + * Resolves the listing item-version that matches the caller's edition + * and platform version. + * + * Backend: GET /api/listings/public/by-slug/{slug}/item-version + * + * On success → 200 with `MpItemVersionView`. + * On miss → 404 with a `ListingItemVersionNotFound` body shape + * (`noMatchingVersions`, `peRequired`, or + * `minTbVersionRequired`). Callers should inspect + * `HttpErrorResponse.error` to differentiate. + */ + public getListingItemVersion(slug: string, config?: IotHubRequestConfig): Observable { + const queryParams = [ + 'ce=true', + `tbVersion=${tbVersionToInt(env.tbVersion)}` + ]; + return this.http.get( + `${this.baseUrl}/api/listings/public/by-slug/${encodeURIComponent(slug)}/item-version?${queryParams.join('&')}`, + { params: this.buildParams(config) } + ); + } + + public getVersionReadme(versionId: string, config?: IotHubRequestConfig): Observable { + return this.http.get(`${this.baseUrl}/api/versions/${versionId}/readme`, { + params: this.buildParams(config), + responseType: 'text' + }); + } + + public getVersionFileData(versionId: string, config?: IotHubRequestConfig): Observable { + return this.http.get(`${this.baseUrl}/api/versions/${versionId}/fileData`, { + params: this.buildParams(config), + responseType: 'blob' + }); + } + + public getConnectivitySettings(config?: IotHubRequestConfig): Observable { + return this.http.get( + `/api/iot-hub/connectivity`, + { params: this.buildParams(config) } + ); + } + + public registerDeviceInstall( + versionId: string, + descriptor: { type?: string; createdEntityIds: { entityType: string; id: string }[]; dashboardId?: { entityType: string; id: string }; selectedInstallMethod?: string; installState?: Record }, + config?: IotHubRequestConfig + ): Observable { + return this.http.post( + `/api/iot-hub/device/register?versionId=${versionId}`, + { type: 'DEVICE', ...descriptor }, + { params: this.buildParams(config) } + ); + } + + public installItemVersion(versionId: string, config?: IotHubRequestConfig, data?: any): Observable { + return this.http.post( + `/api/iot-hub/versions/${versionId}/install`, + data || null, + { params: this.buildParams(config) } + ); + } + + public resolveInstallPlan(versionId: string, config?: IotHubRequestConfig): Observable { + return this.http.get( + `/api/iot-hub/versions/${versionId}/installPlan`, + { params: this.buildParams(config) } + ); + } + + public installPlan(plan: InstallPlan, data?: any, config?: IotHubRequestConfig): Observable { + return this.http.post( + `/api/iot-hub/installPlan`, + { plan, data: data ?? null }, + { params: this.buildParams(config) } + ); + } + + public updateItemVersion(installedItemId: string, versionId: string, config?: IotHubRequestConfig, force?: boolean): Observable { + let params = this.buildParams(config); + if (force) { + params = params.set('force', 'true'); + } + return this.http.post( + `/api/iot-hub/installedItems/${installedItemId}/update/${versionId}`, + null, + { params } + ); + } + + public getInstalledItemIds(config?: IotHubRequestConfig): Observable { + return this.http.get( + `/api/iot-hub/installedItems/itemIds`, + { params: this.buildParams(config) } + ); + } + + public getInstalledItemCounts(itemType: string, config?: IotHubRequestConfig): Observable> { + return this.http.get>( + `/api/iot-hub/installedItems/counts?itemType=${itemType}`, + { params: this.buildParams(config) } + ); + } + + public getInstalledItems(pageLink: PageLink, itemTypes?: string | string[], + itemId?: string, config?: IotHubRequestConfig): Observable> { + let query = pageLink.toQuery(); + if (itemTypes) { + const types = Array.isArray(itemTypes) ? itemTypes : [itemTypes]; + types.forEach(t => query += `&itemTypes=${t}`); + } + if (itemId) { + query += `&itemId=${itemId}`; + } + return this.http.get>( + `/api/iot-hub/installedItems${query}`, + { params: this.buildParams(config) } + ); + } + + public getInstalledItemsCount(itemType?: string, config?: IotHubRequestConfig): Observable { + let url = '/api/iot-hub/installedItems/count'; + if (itemType) { + url += `?itemType=${itemType}`; + } + return this.http.get(url, { params: this.buildParams(config) }); + } + + public deleteInstalledItem(installedItemId: string, config?: IotHubRequestConfig): Observable { + return this.http.delete( + `/api/iot-hub/installedItems/${installedItemId}`, + { params: this.buildParams(config) } + ); + } + + public getItemsPublishedVersions(itemIds: string[], config?: IotHubRequestConfig): Observable { + return this.http.post( + `${this.baseUrl}/api/versions/publishedVersions`, + itemIds, + { params: this.buildParams(config) } + ); + } + + public getCreatorProfile(creatorId: string, config?: IotHubRequestConfig): Observable { + return this.http.get( + `${this.baseUrl}/api/creators/${creatorId}/profile`, + { params: this.buildParams(config) } + ); + } + + private buildParams(config?: IotHubRequestConfig): HttpParams { + return new InterceptorHttpParams( + new InterceptorConfig(config?.ignoreLoading ?? false, config?.ignoreErrors ?? false) + ); + } +} diff --git a/ui-ngx/src/app/core/services/menu.models.ts b/ui-ngx/src/app/core/services/menu.models.ts index a3863f8b5e..4040bdd920 100644 --- a/ui-ngx/src/app/core/services/menu.models.ts +++ b/ui-ngx/src/app/core/services/menu.models.ts @@ -30,6 +30,7 @@ export interface MenuSection { pages?: Array; opened?: boolean; rootOnly?: boolean; + isNew?: boolean; customTranslate?: boolean; } @@ -106,7 +107,8 @@ export enum MenuId { version_control = 'version_control', api_usage = 'api_usage', trendz_settings = 'trendz_settings', - ai_models = 'ai_models' + ai_models = 'ai_models', + iot_hub = 'iot_hub' } declare type MenuFilter = (authState: AuthState) => boolean; @@ -707,6 +709,17 @@ export const menuSectionMap = new Map([ path: '/settings/trendz', icon: 'trendz-settings' } + ], + [ + MenuId.iot_hub, + { + id: MenuId.iot_hub, + name: 'iot-hub.iot-hub', + type: 'link', + path: '/iot-hub', + icon: 'hub', + isNew: true + } ] ]); @@ -794,6 +807,7 @@ const defaultUserMenuMap = new Map([ {id: MenuId.home}, {id: MenuId.alarms}, {id: MenuId.dashboards}, + {id: MenuId.iot_hub}, { id: MenuId.entities, pages: [ diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 7ccad1c91e..f8ce277f2a 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -178,7 +178,6 @@ import * as HistorySelectorComponent from '@shared/components/time/history-selec import * as EntityGatewaySelectComponent from '@shared/components/entity/entity-gateway-select.component'; import * as ContactComponent from '@shared/components/contact.component'; import * as OtaPackageAutocompleteComponent from '@shared/components/ota-package/ota-package-autocomplete.component'; -import * as WidgetsBundleSearchComponent from '@shared/components/widgets-bundle-search.component'; import * as CopyButtonComponent from '@shared/components/button/copy-button.component'; import * as TogglePasswordComponent from '@shared/components/button/toggle-password.component'; import * as ProtobufContentComponent from '@shared/components/protobuf-content.component'; @@ -323,7 +322,6 @@ import * as DashboardStateDialogComponent from '@home/components/dashboard-page/ import * as EmbedDashboardDialogComponent from '@home/components/widget/dialog/embed-dashboard-dialog.component'; import * as EdgeDownlinkTableComponent from '@home/components/edge/edge-downlink-table.component'; import * as EdgeDownlinkTableHeaderComponent from '@home/components/edge/edge-downlink-table-header.component'; -import * as DisplayWidgetTypesPanelComponent from '@home/components/dashboard-page/widget-types-panel.component'; import * as AlarmDurationPredicateValueComponent from '@home/components/profile/alarm/alarm-duration-predicate-value.component'; import * as DashboardImageDialogComponent from '@home/components/dashboard-page/dashboard-image-dialog.component'; import * as WidgetContainerComponent from '@home/components/widget/widget-container.component'; @@ -526,7 +524,6 @@ class ModulesMap implements IModulesMap { '@shared/components/entity/entity-gateway-select.component': EntityGatewaySelectComponent, '@shared/components/contact.component': ContactComponent, '@shared/components/ota-package/ota-package-autocomplete.component': OtaPackageAutocompleteComponent, - '@shared/components/widgets-bundle-search.component': WidgetsBundleSearchComponent, '@shared/components/button/copy-button.component': CopyButtonComponent, '@shared/components/button/toggle-password.component': TogglePasswordComponent, '@shared/components/protobuf-content.component': ProtobufContentComponent, @@ -673,7 +670,6 @@ class ModulesMap implements IModulesMap { '@home/components/widget/dialog/embed-dashboard-dialog.component': EmbedDashboardDialogComponent, '@home/components/edge/edge-downlink-table.component': EdgeDownlinkTableComponent, '@home/components/edge/edge-downlink-table-header.component': EdgeDownlinkTableHeaderComponent, - '@home/components/dashboard-page/widget-types-panel.component': DisplayWidgetTypesPanelComponent, '@home/components/profile/alarm/alarm-duration-predicate-value.component': AlarmDurationPredicateValueComponent, '@home/components/dashboard-page/dashboard-image-dialog.component': DashboardImageDialogComponent, '@home/components/widget/widget-container.component': WidgetContainerComponent, 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 72805454ff..c7f7bc5b74 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 @@ -57,6 +57,8 @@ import { ImportExportService } from '@shared/import-export/import-export.service import { isObject } from '@core/utils'; import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; import { DatePipe } from '@angular/common'; +import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; export class CalculatedFieldsTableConfig extends EntityTableConfig { @@ -77,6 +79,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig true, onAction: () => this.importCalculatedField() + }, + { + name: this.translate.instant('iot-hub.add-from-iot-hub'), + icon: 'hub', + isEnabled: () => true, + onAction: () => this.addCalculatedFieldFromIotHub() } ]; @@ -243,6 +252,14 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig { + if (result?.descriptor) { + this.updateData(); + } + }); + } + private importCalculatedField(): void { this.importExportService.openCalculatedFieldImportDialog() .pipe( 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 741e9851e1..1ffe3e4310 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 @@ -34,6 +34,7 @@ import { CalculatedFieldsTableConfig } from '@home/components/calculated-fields/ 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 { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; import { DatePipe } from '@angular/common'; @Component({ @@ -63,6 +64,7 @@ export class CalculatedFieldsTableComponent { private renderer: Renderer2, private importExportService: ImportExportService, private entityDebugSettingsService: EntityDebugSettingsService, + private iotHubActions: IotHubActionsService, private destroyRef: DestroyRef) { effect(() => { @@ -79,6 +81,7 @@ export class CalculatedFieldsTableComponent { this.entityName(), this.importExportService, this.entityDebugSettingsService, + this.iotHubActions, ); this.cd.markForCheck(); } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html index ce1f0d1a7d..dec624e0c2 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html @@ -400,68 +400,15 @@ (applyWidgetConfig)="saveWidget()"> - -
- -
-
- - -
-
- - - {{ 'widgets-bundle.widgets-bundles' | translate }} - {{ 'widget.all-widgets' | translate }} - - - - {{ 'widget.all' | translate }} - {{ 'widget.actual' | translate }} - {{ 'widget.deprecated' | translate }} - -
- - -
+ + diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss index 7b0d56b2e6..1db4c35a6f 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss @@ -63,6 +63,7 @@ div.tb-dashboard-page { .dashboard_logo{ height: 75%; + width: auto; margin-right: 16px; } @@ -195,3 +196,4 @@ div.tb-dashboard-page { } } + diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index 024282e501..5875afe1a1 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -30,7 +30,6 @@ import { OnInit, Optional, Renderer2, - StaticProvider, ViewChild, ViewContainerRef, ViewEncapsulation, @@ -79,7 +78,6 @@ import { WidgetConfig, WidgetInfo, WidgetPosition, - widgetType, widgetTypesData } from '@shared/models/widget.models'; import { environment as env } from '@env/environment'; @@ -127,13 +125,6 @@ import { ImportExportService } from '@shared/import-export/import-export.service import { AuthState } from '@app/core/auth/auth.models'; import { FiltersDialogComponent, FiltersDialogData } from '@home/components/filter/filters-dialog.component'; import { Filters } from '@shared/models/query/query.models'; -import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; -import { ComponentPortal } from '@angular/cdk/portal'; -import { - DISPLAY_WIDGET_TYPES_PANEL_DATA, - DisplayWidgetTypesPanelComponent, - DisplayWidgetTypesPanelData -} from '@home/components/dashboard-page/widget-types-panel.component'; import { DashboardWidgetSelectComponent } from '@home/components/dashboard-page/dashboard-widget-select.component'; import { MobileService } from '@core/services/mobile.service'; @@ -245,7 +236,6 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC forceDashboardMobileMode = false; isAddingWidget = false; isAddingWidgetClosed = true; - filterWidgetTypes: widgetType[] = null; isToolbarOpened = false; isToolbarOpenedAnimate = false; @@ -346,7 +336,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC } get mobileDisplayRightLayoutFirst(): boolean { - return this.isMobile && this.layouts.right.layoutCtx.gridSettings?.mobileDisplayLayoutFirst; + return this.isMobile && this.layouts.right.show && this.layouts.right.layoutCtx.gridSettings?.mobileDisplayLayoutFirst; } set mobileDisplayRightLayoutFirst(mobileDisplayRightLayoutFirst: boolean) { @@ -379,7 +369,6 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC private renderer: Renderer2, private ngZone: NgZone, @Optional() @Inject('embeddedValue') private embeddedValue, - private overlay: Overlay, private viewContainerRef: ViewContainerRef, private cd: ChangeDetectorRef, public elRef: ElementRef, @@ -1681,59 +1670,6 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC return widgetContextActions; } - clearSelectedWidgetBundle() { - this.dashboardWidgetSelectComponent.search = ''; - this.dashboardWidgetSelectComponent.widgetsBundle = null; - this.dashboardWidgetSelectComponent.selectWidgetMode = 'bundles'; - } - - editWidgetsTypesToDisplay($event: Event) { - if ($event) { - $event.stopPropagation(); - } - const target = $event.target || $event.currentTarget; - const config = new OverlayConfig(); - config.backdropClass = 'cdk-overlay-transparent-backdrop'; - config.hasBackdrop = true; - const connectedPosition: ConnectedPosition = { - originX: 'end', - originY: 'bottom', - overlayX: 'end', - overlayY: 'top' - }; - config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement) - .withPositions([connectedPosition]); - - const overlayRef = this.overlay.create(config); - overlayRef.backdropClick().subscribe(() => { - overlayRef.dispose(); - }); - - const filterWidgetTypes = this.dashboardWidgetSelectComponent.filterWidgetTypes; - const widgetTypesList = Array.from(this.dashboardWidgetSelectComponent.widgetTypes.values()).map(type => - ({type, display: filterWidgetTypes === null ? true : filterWidgetTypes.includes(type)})); - - const providers: StaticProvider[] = [ - { - provide: DISPLAY_WIDGET_TYPES_PANEL_DATA, - useValue: { - types: widgetTypesList, - typesUpdated: (newTypes) => { - this.filterWidgetTypes = newTypes.filter(type => type.display).map(type => type.type); - this.cd.markForCheck(); - } - } as DisplayWidgetTypesPanelData - }, - { - provide: OverlayRef, - useValue: overlayRef - } - ]; - const injector = Injector.create({parent: this.viewContainerRef.injector, providers}); - overlayRef.attach(new ComponentPortal(DisplayWidgetTypesPanelComponent, this.viewContainerRef, injector)); - this.cd.markForCheck(); - } - public updateDashboardImage($event: Event) { if ($event) { $event.stopPropagation(); diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html index 582e266005..7d2ea9cdb5 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html @@ -16,14 +16,232 @@ -->
- - - - - - - - + +
+ @if (showTopLevelWidgetModeToggle()) { + + + + + +
+ now_widgets +
{{ 'widget.installed' | translate }}
+
+
+ +
+ hub +
{{ 'iot-hub.iot-hub' | translate }}
+
+
+ } @else if (showBackButton()) { + + {{ getHeaderTitle() }} + } + + @if (selectWidgetMode === 'installed') { + + } + +
+
+ + @if (isFilterVisible()) { + + } + @if (selectWidgetMode === 'iotHub' && iotHubSubMode !== 'default') { + + + @for (option of iotHubSortOptions; track option.value; let i = $index) { + + } + + } +
+
+
+ @switch (selectWidgetMode) { + @case ('installed') { + @if (widgetsBundle) { + + } @else if (installedSubMode === 'allWidgets') { + + } @else { + + + + @switch (item.__logical) { + @case (LOGICAL_ALL_WIDGETS) { + +
+
{{ 'widget.all-widgets' | translate }}
+
+
+
+ {{ 'widget.all-widgets' | translate }} +
+
+ } + @default { + +
+
+ {{item.title}} +
+
+ @if (isSystem(item)) { +
sys
+ } +
i
+
+
+
+
+ {{ item.title }} +
+
+ } + } +
+ +
+ + {{ 'widgets-bundle.loading-widgets-bundles' | translate }} + + +
+
+ } + } + @case ('iotHub') { + @switch (iotHubSubMode) { + @case ('default') { + + + + @switch (item.__logical) { + @case (LOGICAL_ALL_WIDGETS) { + +
+
{{ 'widget.all-widgets' | translate }}
+
+
+
+ {{ 'widget.all-widgets' | translate }} +
+
+ } + @case (LOGICAL_INSTALLED_FROM_IOT_HUB) { + +
+
{{ 'iot-hub.installed-from-iot-hub' | translate }}
+
+
+
+ {{ 'iot-hub.installed-from-iot-hub' | translate }} +
+
+ } + @default { + +
+
{{ item.name }}
+
+
+
+ @if (getCategoryImageUrl(item); as imgUrl) { + {{ item.name }} + } @else { + {{ item.name }} + } +
+
+ } + } +
+ +
+ +
+
+ } + @case ('installed') { + + } + @default { + + } + } + } + } +
+ + [noData]="noData"> - +
{{item.name}} @@ -44,9 +262,11 @@
{{ 'widget.' + item.widgetType + '-short' | translate }}
-
i
+
i
-
widget.deprecated
+ @if (item.deprecated) { +
widget.deprecated
+ }
@@ -63,59 +283,304 @@
- - {{(selectWidgetMode === 'bundles' ? 'widgets-bundle.empty' : 'widget.no-widgets-text') | translate}} - - + + +
+
+
+
+
+
+
+ + + [dataLoading]="loadingIotHubWidgets" + [noData]="noData"> - - -
-
- {{item.title}} -
-
-
sys
-
i
-
-
-
-
- {{ item.title }} -
-
+ + - +
- {{ 'widgets-bundle.loading-widgets-bundles' | translate }} + {{ 'widget.loading-widgets' | translate }} - +
- - widgets-bundle.no-widgets-bundles-text + + + + + + + + + +
+ + {{ 'widget.loading-widgets' | translate }} + + +
- - + + +
+
+
+

{{ 'iot-hub.no-widgets-found' | translate }}

+

{{ 'iot-hub.no-items-found-text' | translate }}

+ @if (hasActiveSearchOrFilters()) { + + } +
+ @if (selectWidgetMode === 'installed' && installedSubMode === 'allWidgets') { +
+ storefront +

+ {{ 'iot-hub.browse-tip-prefix' | translate }} {{ 'iot-hub.browse-tip-link' | translate }} {{ 'iot-hub.browse-tip-suffix' | translate }} +

+ arrow_forward +
+ } +
+
+ + +
+
+ {{item.name}} +
+
+ @if (item.dataDescriptor?.widgetType) { +
{{ 'widget.' + item.dataDescriptor.widgetType + '-short' | translate }}
+ } +
i
+
+ @if (getIotHubItemImage(item); as imgUrl) { + {{ item.name }} + } @else { + {{ item.name }} + } +
+
+ + + +
+
+ {{ 'iot-hub.filters' | translate }} + + @if (hasActiveFilters()) { + + } + +
+ +
+ @if (selectWidgetMode === 'installed') { + @if (widgetTypes.size > 1) { + + + + {{ 'iot-hub.type' | translate }} + @if (filterWidgetTypes?.length) { + {{ filterWidgetTypes.length }} + } + + +
+ @for (type of widgetTypesList; track type) { +
+ + {{ getWidgetTypeLabel(type) }} + +
+ } +
+
+ } + @if (hasDeprecated) { + @if (widgetTypes.size > 1) { + + } +
+ + {{ 'iot-hub.include-deprecated' | translate }} + +
+ } + } @else { + + + + {{ 'iot-hub.type' | translate }} + @if (iotHubAppliedWidgetTypes.size) { + {{ iotHubAppliedWidgetTypes.size }} + } + + + @if (iotHubWidgetTypeOptions.length >= 10) { + + } +
+ @for (opt of getFilteredIotHubItems(iotHubWidgetTypeOptions, 'widgetType'); track opt.key) { +
+ + {{ getIotHubWidgetTypeLabel(opt.key) }} + +
+ } +
+
+ @if (!hideCategoriesFilterSection) { + + + + + {{ 'iot-hub.category' | translate }} + @if (iotHubAppliedCategories.size) { + {{ iotHubAppliedCategories.size }} + } + + + @if (iotHubCategoryOptions.length >= 10) { + + } +
+ @for (opt of getFilteredIotHubItems(iotHubCategoryOptions, 'category'); track opt.key) { +
+ + {{ opt.key }} + +
+ } +
+
+ } + + + + + {{ 'iot-hub.use-case' | translate }} + @if (iotHubAppliedUseCases.size) { + {{ iotHubAppliedUseCases.size }} + } + + + @if (iotHubUseCaseOptions.length >= 10) { + + } +
+ @for (group of getGroupedIotHubFilterItems(iotHubUseCaseOptions, 'useCase'); track group.label) { + @if (group.label) { +
{{ group.label | translate }}
+ } + @for (opt of group.items; track opt.key) { +
+ + {{ opt.key }} + +
+ } + } +
+
+ } +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss index 04803e6a00..d77a929fd8 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss @@ -13,10 +13,256 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@import "../../../../../scss/constants"; +@import "../iot-hub/iot-hub-common"; + +tb-dashboard-widget-select { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + .tb-dashboard-widget-select { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; background-color: #cfd8dc; - position: absolute; - inset: 0; + + mat-toolbar.tb-widget-select-header { + flex-shrink: 0; + width: 100%; + height: auto; + gap: 16px; + color: #fff; + padding: 12px 12px 12px 24px; + + button.mat-mdc-icon-button.mat-mdc-button-base { + --mdc-icon-button-state-layer-size: 40px; + --mat-icon-button-state-layer-size: 40px; + --mdc-icon-button-icon-color: #fff; + padding: 8px; + color: #fff; + } + } + + .tb-widget-select-title-row { + gap: 8px; + width: 100%; + min-height: 40px; + + .tb-widget-select-title { + font-size: 24px; + line-height: 32px; + font-weight: 400; + color: #fff; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; + } + } + + .tb-top-level-widget-mode-toggle { + .tb-toggle-container { + .mat-button-toggle-group.mat-button-toggle-group-appearance-standard.tb-toggle-header { + height: 42px; + border-radius: 8px; + overflow: hidden; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.15); + .mat-button-toggle.mat-button-toggle-appearance-standard { + .mat-button-toggle-focus-overlay, .mat-button-toggle-ripple { + border-radius: 0; + } + .mat-button-toggle-button { + height: 40px; + .mat-button-toggle-label-content { + line-height: 36px; + font-weight: 500; + } + } + &.mat-button-toggle-checked { + .mat-button-toggle-button { + border: none; + border-radius: 0; + } + } + } + &.tb-fill.tb-invert { + .mat-button-toggle.mat-button-toggle-appearance-standard { + color: #FFF; + } + } + } + } + } + + .tb-widget-select-search-row { + width: 100%; + gap: 8px; + + .tb-widget-select-search { + height: 40px; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.16); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + caret-color: $tb-primary-color; + color: rgba(255, 255, 255, 0.84); + + mat-icon { + color: rgba(255, 255, 255, 0.8); + } + + input { + width: 100%; + height: 100%; + min-width: 0; + padding: 0; + font-size: 16px; + line-height: 24px; + background: transparent; + color: #fff; + border: none; + outline: none; + + &::placeholder { + color: rgba(255, 255, 255, 0.8); + opacity: 1; + } + } + + button.mat-mdc-icon-button mat-icon { + color: rgba(255, 255, 255, 0.8); + } + + &.focus { + background: #fff; + border-color: rgba(255, 255, 255, 0.15); + caret-color: $tb-primary-color; + + mat-icon { + color: rgba(0, 0, 0, 0.54); + } + + input { + color: rgba(0, 0, 0, 0.76); + opacity: 1; + + &::placeholder { + color: rgba(0, 0, 0, 0.38); + } + } + + button.mat-mdc-icon-button mat-icon, + mat-icon.close { + color: rgba(0, 0, 0, 0.54) !important; + opacity: 1; + } + } + } + + .tb-widget-select-sort { + color: #fff; + font-weight: 500; + font-size: 14px; + letter-spacing: 0.25px; + + mat-icon { + color: #fff; + } + } + } + + .tb-widget-select-body { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + } + + .tb-widget-logical-card, + .tb-widget-category-card { + .widget-title { + color: rgba(0, 0, 0, 0.87); + font-weight: 600; + } + } + + .tb-widget-select-empty-state { + padding: 40px 12px; + text-align: center; + gap: 40px; + + .tb-widget-select-empty-inner { + gap: 20px; + + .tb-no-data-bg { + flex: auto; + height: 100px; + } + + h3 { + font-size: 20px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.87); + margin: 0; + } + + p { + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + margin: 0; + } + } + + .tb-widget-select-browse-tip { + display: flex; + width: 100%; + gap: 16px; + align-items: center; + padding: 16px 20px; + border: 1px solid $tb-primary-color; + border-radius: 4px; + background: #fff; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background: rgba($tb-primary-color, 0.06); + } + + .tb-widget-select-browse-tip-icon, + .tb-widget-select-browse-tip-arrow { + flex-shrink: 0; + color: $tb-primary-color; + } + + .tb-widget-select-browse-tip-text { + flex: 1; + text-align: left; + margin: 0; + font-size: 16px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.76); + + .tb-widget-select-browse-tip-link { + font-weight: 500; + letter-spacing: 0.25px; + color: $tb-primary-color; + } + } + } + } .mat-mdc-card.tb-widget-preview-card { cursor: pointer; @@ -137,6 +383,250 @@ height: 100%; } } + + // IoT Hub widget card footer + .tb-iot-hub-widget-card { + .tb-iot-hub-widget-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 4px; + } + + .tb-iot-hub-widget-author, + .tb-iot-hub-widget-installs { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: rgba(0, 0, 0, 0.54); + + &.tb-iot-hub-verified-icon { + color: #00695c; + } + } + } + + .tb-iot-hub-widget-installed-badge { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 1px 6px; + border-radius: 10px; + background: rgba(25, 128, 56, 0.06); + color: #198038; + font-size: 10px; + font-weight: 500; + line-height: 14px; + letter-spacing: 0.2px; + white-space: nowrap; + + mat-icon { + font-size: 12px; + width: 12px; + height: 12px; + color: #198038; + } + } + + .tb-iot-hub-widget-installs { + font-weight: 500; + + mat-icon { + font-size: 12px; + width: 12px; + height: 12px; + } + } + } +} + +// IoT Hub filter panel +.tb-iot-hub-filter-panel { + display: flex; + flex-direction: column; + gap: 12px; + max-height: 500px; + + .tb-iot-hub-filter-header { + display: flex; + align-items: center; + gap: 8px; + } + + .tb-wsp-clear-all.mat-mdc-outlined-button { + --mat-button-outlined-outline-color: rgba(0, 0, 0, 0.12); + height: 40px; + line-height: 20px; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.25px; + padding: 0 16px; + } + + .tb-wsp-close.mat-mdc-icon-button { + color: rgba(0, 0, 0, 0.54); + } + + .tb-iot-hub-filter-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + + .tb-iot-hub-filter-sections { + overflow-y: auto; + padding-right: 8px; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background-color: rgba(0, 0, 0, 0.04); + } + + &::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.2); + border-radius: 3px; + + &:hover { + background-color: rgba(0, 0, 0, 0.35); + } + } + + &.filter-items-hovered { + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: transparent; + } + } + + mat-expansion-panel { + box-shadow: none; + background: transparent; + + .mat-expansion-panel-body { + padding: 0; + } + + .mat-expansion-panel-header { + padding: 8px 16px 8px 8px; + height: 40px; + min-height: 40px; + } + } + + mat-divider { + margin: 8px 0; + } + } + + .tb-iot-hub-filter-search { + @include tb-iot-hub-search-container; + margin: 4px 8px; + + mat-icon { + @include tb-iot-hub-search-icon(rgba(0, 0, 0, 0.54)); + } + + input { + @include tb-iot-hub-search-input-text(0.2px); + } + + .tb-iot-hub-filter-search-clear.mat-mdc-icon-button { + @include tb-iot-hub-search-clear; + } + } + + .tb-iot-hub-filter-items { + max-height: 340px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: transparent; + border-radius: 3px; + } + + &:hover { + &::-webkit-scrollbar-track { + background-color: rgba(0, 0, 0, 0.04); + } + + &::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.2); + + &:hover { + background-color: rgba(0, 0, 0, 0.35); + } + } + } + } + + .tb-iot-hub-filter-group-label { + font-size: 14px; + font-weight: 500; + color: rgba(0, 0, 0, 0.54); + letter-spacing: 0.5px; + padding: 12px 0 4px 12px; + } + + .tb-iot-hub-filter-option { + height: 40px; + display: flex; + align-items: center; + padding: 8px 0; + overflow: hidden; + } + + .tb-iot-hub-filter-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + border-radius: 9px; + background: #FF5722; + color: white; + font-size: 11px; + font-weight: 600; + margin-left: 8px; + padding: 0 4px; + } + + .tb-iot-hub-filter-actions { + display: flex; + align-items: center; + gap: 8px; + padding-top: 8px; + } + + .tb-widget-select-include-deprecated { + padding: 8px 8px 4px; + } + } @keyframes shine { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts index bf4a7b78eb..3034cee011 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, Input, Output, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; import { IAliasController } from '@core/api/widget-api.models'; import { NULL_UUID } from '@shared/models/id/has-uuid'; @@ -27,15 +27,36 @@ import { WidgetTypeInfo } from '@shared/models/widget.models'; import { debounceTime, distinctUntilChanged, map, skip } from 'rxjs/operators'; -import { BehaviorSubject, combineLatest } from 'rxjs'; -import { isObject } from '@core/utils'; +import { BehaviorSubject, combineLatest, forkJoin, of } from 'rxjs'; import { PageLink } from '@shared/models/page/page-link'; -import { Direction } from '@shared/models/page/sort-order'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; import { GridEntitiesFetchFunction, ScrollGridColumns } from '@shared/components/grid/scroll-grid-datasource'; import { ItemSizeStrategy } from '@shared/components/grid/scroll-grid.component'; import { coerceBoolean } from '@shared/decorators/coercion'; +import { TranslateService } from '@ngx-translate/core'; +import { MpItemVersionQuery, MpItemVersionView, widgetTypeTranslations } from '@shared/models/iot-hub/iot-hub-version.models'; +import { ItemType, FilterParamInfo, WidgetCategory } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; +import { filterIotHubItemsBySearch, groupIotHubFilterItems, IotHubFilterGroup, resolveIotHubItemImageUrl } from '@home/components/iot-hub/iot-hub-utils'; + +type selectWidgetMode = 'installed' | 'iotHub'; +type installedSubMode = 'default' | 'allWidgets'; +type iotHubSubMode = 'default' | 'allWidgets' | 'installed' | 'category'; + +interface WidgetSelectSortOption { + value: string; + label: string; + direction: Direction; +} + +const LOGICAL_ALL_WIDGETS = '__logical_all_widgets__'; +const LOGICAL_INSTALLED_FROM_IOT_HUB = '__logical_installed_from_iot_hub__'; -type selectWidgetMode = 'bundles' | 'allWidgets'; +interface LogicalCard { + __logical: typeof LOGICAL_ALL_WIDGETS | typeof LOGICAL_INSTALLED_FROM_IOT_HUB; +} interface WidgetsFilter { search: string; @@ -54,7 +75,7 @@ interface BundleWidgetsFilter extends WidgetsFilter { encapsulation: ViewEncapsulation.None, standalone: false }) -export class DashboardWidgetSelectComponent implements OnInit { +export class DashboardWidgetSelectComponent { private searchSubject = new BehaviorSubject(''); private search$ = this.searchSubject.asObservable().pipe( @@ -62,12 +83,25 @@ export class DashboardWidgetSelectComponent implements OnInit { private filterWidgetTypes$ = new BehaviorSubject>(null); private deprecatedFilter$ = new BehaviorSubject(DeprecatedFilter.ACTUAL); - private selectWidgetMode$ = new BehaviorSubject('bundles'); + private selectWidgetMode$ = new BehaviorSubject('installed'); + private installedSubMode$ = new BehaviorSubject('default'); + private iotHubSubMode$ = new BehaviorSubject('default'); private widgetsBundle$ = new BehaviorSubject(null); widgetTypes = new Set(); hasDeprecated = false; + iotHubSelectedCategory: string = null; + includeDeprecated = false; + searchFocused = false; + + iotHubSortOptions: WidgetSelectSortOption[] = [ + { value: 'totalInstallCount', label: 'iot-hub.sort-most-installed', direction: Direction.DESC }, + { value: 'publishedTime', label: 'iot-hub.sort-newest', direction: Direction.DESC }, + { value: 'name', label: 'iot-hub.sort-name', direction: Direction.ASC } + ]; + iotHubSelectedSortIndex = 0; + @Input() aliasController: IAliasController; @@ -84,7 +118,6 @@ export class DashboardWidgetSelectComponent implements OnInit { return this.searchSubject.value; } - @Input() set filterWidgetTypes(widgetTypes: Array) { this.filterWidgetTypes$.next(widgetTypes); } @@ -96,16 +129,23 @@ export class DashboardWidgetSelectComponent implements OnInit { @Input() set selectWidgetMode(mode: selectWidgetMode) { if (this.selectWidgetMode$.value !== mode) { - if (mode === 'bundles' && this.widgetsBundle$.value === null) { - this.widgetTypes.clear(); - this.hasDeprecated = false; - } else { - this.widgetTypes = new Set(Object.keys(widgetType).map(t => t as widgetType)); - this.hasDeprecated = true; - } + this.resetSubModes(); this.filterWidgetTypes$.next(null); this.deprecatedFilter$.next(DeprecatedFilter.ACTUAL); + this.includeDeprecated = false; this.selectWidgetMode$.next(mode); + this.updateWidgetTypesForCurrentState(); + if (mode === 'iotHub') { + this.loadInstalledWidgets(); + this.loadIotHubFilterInfo(); + this.loadWidgetCategories(); + } else { + this.installedWidgetVersions = null; + this.iotHubAppliedWidgetTypes.clear(); + this.iotHubAppliedCategories.clear(); + this.iotHubAppliedUseCases.clear(); + this.iotHubFilterCount = 0; + } } } @@ -113,6 +153,40 @@ export class DashboardWidgetSelectComponent implements OnInit { return this.selectWidgetMode$.value; } + set installedSubMode(sub: installedSubMode) { + if (this.installedSubMode$.value !== sub) { + this.installedSubMode$.next(sub); + this.filterWidgetTypes$.next(null); + this.deprecatedFilter$.next(DeprecatedFilter.ACTUAL); + this.includeDeprecated = false; + this.updateWidgetTypesForCurrentState(); + } + } + + get installedSubMode(): installedSubMode { + return this.installedSubMode$.value; + } + + set iotHubSubMode(sub: iotHubSubMode) { + if (this.iotHubSubMode$.value !== sub) { + this.iotHubSubMode$.next(sub); + if (sub !== 'category') { + this.iotHubSelectedCategory = null; + } + this.installedWidgetVersions = null; + if (sub === 'default') { + this.widgetCategoriesFilter = (this.searchSubject.value || '') + '|' + Date.now(); + this.cd.markForCheck(); + } else { + this.reloadIotHubWidgets(); + } + } + } + + get iotHubSubMode(): iotHubSubMode { + return this.iotHubSubMode$.value; + } + @Input() set deprecatedFilter(filter: DeprecatedFilter) { this.deprecatedFilter$.next(filter); @@ -124,7 +198,7 @@ export class DashboardWidgetSelectComponent implements OnInit { set widgetsBundle(widgetBundle: WidgetsBundle) { if (this.widgetsBundle$.value !== widgetBundle) { - if (widgetBundle === null && this.selectWidgetMode$.value !== 'allWidgets') { + if (widgetBundle === null) { this.widgetTypes.clear(); this.hasDeprecated = false; } else { @@ -141,9 +215,17 @@ export class DashboardWidgetSelectComponent implements OnInit { return this.widgetsBundle$.value; } + @ViewChild('iotHubFilterPanel', {static: true}) iotHubFilterPanel: TemplateRef; + @Output() widgetSelected: EventEmitter = new EventEmitter(); + @Output() + closeClick: EventEmitter = new EventEmitter(); + + @Output() + importClick: EventEmitter = new EventEmitter(); + columns: ScrollGridColumns = { columns: 2, breakpoints: { @@ -161,14 +243,42 @@ export class DashboardWidgetSelectComponent implements OnInit { }; widgetBundlesFetchFunction: GridEntitiesFetchFunction; + installedDefaultFetchFunction: GridEntitiesFetchFunction; allWidgetsFetchFunction: GridEntitiesFetchFunction; widgetsFetchFunction: GridEntitiesFetchFunction; + iotHubWidgetsFetchFunction: GridEntitiesFetchFunction; + iotHubInstalledWidgetsFetchFunction: GridEntitiesFetchFunction; + iotHubDefaultFetchFunction: GridEntitiesFetchFunction; + + readonly LOGICAL_ALL_WIDGETS = LOGICAL_ALL_WIDGETS; + readonly LOGICAL_INSTALLED_FROM_IOT_HUB = LOGICAL_INSTALLED_FROM_IOT_HUB; widgetsBundleFilter = ''; allWidgetsFilter: WidgetsFilter = {search: '', filter: null, deprecatedFilter: DeprecatedFilter.ACTUAL}; widgetsFilter: BundleWidgetsFilter = {search: '', filter: null, deprecatedFilter: DeprecatedFilter.ACTUAL, widgetsBundleId: null}; + iotHubWidgetsFilter = ''; + iotHubInstalledWidgetsFilter = ''; + widgetCategoriesFilter = ''; + + private installedWidgets: IotHubInstalledItem[] = null; + private installedWidgetVersions: MpItemVersionView[] = null; + + // IoT Hub filter model — applied state (used by fetch functions + panel UI) + iotHubAppliedWidgetTypes = new Set(); + iotHubAppliedCategories = new Set(); + iotHubAppliedUseCases = new Set(); + + iotHubWidgetTypeOptions: FilterParamInfo[] = []; + iotHubCategoryOptions: FilterParamInfo[] = []; + iotHubUseCaseOptions: FilterParamInfo[] = []; + iotHubFilterCount = 0; + iotHubFilterSearch: Record = {}; + iotHubFilterItemsHovered = false; constructor(private widgetsService: WidgetService, + private iotHubApiService: IotHubApiService, + private iotHubActions: IotHubActionsService, + private translate: TranslateService, private cd: ChangeDetectorRef) { this.widgetBundlesFetchFunction = (pageSize, page, filter) => { @@ -197,12 +307,71 @@ export class DashboardWidgetSelectComponent implements OnInit { true, filter.deprecatedFilter, filter.filter); }; + this.iotHubWidgetsFetchFunction = (pageSize, page, filter) => { + const search = typeof filter === 'string' ? filter.split('|')[0] : filter; + const sort = this.iotHubSortOptions[this.iotHubSelectedSortIndex]; + const sortOrder: SortOrder = { property: sort.value, direction: sort.direction }; + const pageLink = new PageLink(pageSize, page, search || null, sortOrder); + const effectiveCategories = this.iotHubSelectedCategory + ? [this.iotHubSelectedCategory] + : (this.iotHubAppliedCategories.size > 0 ? Array.from(this.iotHubAppliedCategories) : undefined); + const query = new MpItemVersionQuery(pageLink, { + type: ItemType.WIDGET, + categories: effectiveCategories, + useCases: this.iotHubAppliedUseCases.size > 0 ? Array.from(this.iotHubAppliedUseCases) : undefined, + widgetTypes: this.iotHubAppliedWidgetTypes.size > 0 ? Array.from(this.iotHubAppliedWidgetTypes) : undefined, + scadaFirst: this.scadaFirst ? true : undefined + }); + return this.iotHubApiService.getPublishedVersions(query, { ignoreLoading: true }); + }; + + this.iotHubInstalledWidgetsFetchFunction = (pageSize, page, filter) => { + if (this.installedWidgetVersions === null) { + return this.fetchInstalledWidgetVersions().pipe( + map(versions => this.filterAndPaginateInstalledVersions(versions, pageSize, page, filter)) + ); + } + return of(this.filterAndPaginateInstalledVersions(this.installedWidgetVersions, pageSize, page, filter)); + }; + + this.installedDefaultFetchFunction = (pageSize, page, filter) => { + return this.widgetBundlesFetchFunction(pageSize, page, filter).pipe( + map(pd => this.prependLogicalCards(pd, page, [{ __logical: LOGICAL_ALL_WIDGETS }])) + ); + }; + + this.iotHubDefaultFetchFunction = (pageSize, page, filter) => { + const search = typeof filter === 'string' ? filter.split('|')[0] : filter; + return this.iotHubApiService.getWidgetCategories(search || undefined, + this.scadaFirst ? true : undefined, { ignoreLoading: true }).pipe( + map(categories => ({ + data: categories.slice(page * pageSize, page * pageSize + pageSize), + totalPages: Math.ceil(categories.length / pageSize), + totalElements: categories.length, + hasNext: (page + 1) * pageSize < categories.length + })), + map(pd => this.prependLogicalCards(pd, page, [ + { __logical: LOGICAL_ALL_WIDGETS }, + { __logical: LOGICAL_INSTALLED_FROM_IOT_HUB } + ])) + ); + }; + this.search$.pipe( distinctUntilChanged(), skip(1) ).subscribe( (search) => { this.widgetsBundleFilter = search; + if (this.selectWidgetMode$.value === 'iotHub') { + if (this.iotHubSubMode$.value === 'installed') { + this.iotHubInstalledWidgetsFilter = search; + } else if (this.iotHubSubMode$.value === 'default') { + this.widgetCategoriesFilter = search; + } else { + this.iotHubWidgetsFilter = search; + } + } this.cd.markForCheck(); } ); @@ -232,10 +401,39 @@ export class DashboardWidgetSelectComponent implements OnInit { ); } - ngOnInit(): void { + private prependLogicalCards(pageData: any, page: number, cards: LogicalCard[]): any { + if (page === 0 && cards.length) { + return { + ...pageData, + data: [...cards, ...(pageData.data || [])], + totalElements: (pageData.totalElements || 0) + cards.length + }; + } + return pageData; + } + + private resetSubModes(): void { + this.widgetsBundle$.next(null); + this.installedSubMode$.next('default'); + this.iotHubSubMode$.next('default'); + this.iotHubSelectedCategory = null; + } + + private updateWidgetTypesForCurrentState(): void { + const mode = this.selectWidgetMode$.value; + const showTypeFilter = + (mode === 'installed' && (this.installedSubMode$.value === 'allWidgets' || this.widgetsBundle$.value !== null)) || + (mode === 'iotHub' && this.iotHubSubMode$.value !== 'default'); + if (showTypeFilter) { + this.widgetTypes = new Set(Object.keys(widgetType).map(t => t as widgetType)); + this.hasDeprecated = mode === 'installed'; + } else { + this.widgetTypes.clear(); + this.hasDeprecated = false; + } } - onWidgetClicked($event: Event, widget: WidgetTypeInfo): void { + onWidgetClicked(widget: WidgetTypeInfo): void { this.widgetSelected.emit(this.toWidgetInfo(widget)); } @@ -246,14 +444,404 @@ export class DashboardWidgetSelectComponent implements OnInit { selectBundle($event: Event, bundle: WidgetsBundle) { $event.preventDefault(); this.widgetsBundle = bundle; - if (bundle.title?.toLowerCase().includes(this.search.toLowerCase()) || - bundle.description?.toLowerCase().includes(this.search.toLowerCase())) { - this.searchSubject.next(''); + } + + onAllWidgetsCardClicked($event: Event): void { + $event.preventDefault(); + if (this.selectWidgetMode$.value === 'installed') { + this.installedSubMode = 'allWidgets'; + } else { + this.iotHubSubMode = 'allWidgets'; + } + } + + onInstalledFromIotHubCardClicked($event: Event): void { + $event.preventDefault(); + this.iotHubSubMode = 'installed'; + } + + onCategoryCardClicked($event: Event, category: WidgetCategory): void { + $event.preventDefault(); + this.iotHubSelectedCategory = category.name; + this.iotHubSubMode = 'category'; + } + + getCategoryImageUrl(category: WidgetCategory): string | null { + if (!category?.image) { + return null; + } + return this.iotHubApiService.resolveResourceUrl(category.image); + } + + onIotHubWidgetClicked($event: Event, item: MpItemVersionView): void { + $event.preventDefault(); + const installedItem = this.installedWidgets?.find(i => i.itemId === item.itemId); + if (installedItem) { + const widgetTypeId = installedItem.descriptor?.type === 'WIDGET' ? installedItem.descriptor.widgetTypeId?.id : null; + if (widgetTypeId) { + this.widgetsService.getWidgetTypeInfoById(widgetTypeId).subscribe(wt => { + if (wt) { + this.widgetSelected.emit({ + typeFullFqn: fullWidgetTypeFqn(wt), + type: wt.widgetType, + title: wt.name, + image: wt.image, + description: wt.description, + deprecated: wt.deprecated + }); + } + }); + } + return; + } + this.iotHubActions.openItemDetail(item, undefined, undefined, 'add').subscribe(result => { + if (result?.action === 'add') { + this.installAndAddWidget(result.item); + } + }); + } + + isIotHubWidgetInstalled(item: MpItemVersionView): boolean { + return this.installedWidgets?.some(i => i.itemId === item.itemId) ?? false; + } + + getIotHubItemImage(item: MpItemVersionView): string | null { + return resolveIotHubItemImageUrl(item, this.iotHubApiService); + } + + getIotHubWidgetTypeLabel(key: string): string { + const translationKey = widgetTypeTranslations.get(key); + return translationKey ? this.translate.instant(translationKey) : key; + } + + getFilteredIotHubItems(items: FilterParamInfo[], searchKey: string): FilterParamInfo[] { + return filterIotHubItemsBySearch(items, this.iotHubFilterSearch[searchKey]); + } + + getGroupedIotHubFilterItems(items: FilterParamInfo[], searchKey: string): IotHubFilterGroup[] { + return groupIotHubFilterItems(items, this.iotHubFilterSearch[searchKey]); + } + + toggleIotHubWidgetType(key: string): void { + if (this.iotHubAppliedWidgetTypes.has(key)) { + this.iotHubAppliedWidgetTypes.delete(key); + } else { + this.iotHubAppliedWidgetTypes.add(key); + } + this.onIotHubFiltersChanged(); + } + + toggleIotHubCategory(key: string): void { + if (this.iotHubAppliedCategories.has(key)) { + this.iotHubAppliedCategories.delete(key); + } else { + this.iotHubAppliedCategories.add(key); + } + this.onIotHubFiltersChanged(); + } + + toggleIotHubUseCase(key: string): void { + if (this.iotHubAppliedUseCases.has(key)) { + this.iotHubAppliedUseCases.delete(key); + } else { + this.iotHubAppliedUseCases.add(key); + } + this.onIotHubFiltersChanged(); + } + + clearIotHubFilters(): void { + this.iotHubAppliedWidgetTypes.clear(); + this.iotHubAppliedCategories.clear(); + this.iotHubAppliedUseCases.clear(); + this.iotHubFilterSearch = {}; + this.onIotHubFiltersChanged(); + } + + private onIotHubFiltersChanged(): void { + this.iotHubFilterCount = + this.iotHubAppliedWidgetTypes.size + + (this.iotHubSelectedCategory ? 0 : this.iotHubAppliedCategories.size) + + this.iotHubAppliedUseCases.size; + this.reloadIotHubWidgets(); + } + + onIotHubSortChange(index: number): void { + if (this.iotHubSelectedSortIndex !== index) { + this.iotHubSelectedSortIndex = index; + this.installedWidgetVersions = null; + this.reloadIotHubWidgets(); + } + } + + get totalFilterCount(): number { + if (this.selectWidgetMode === 'installed') { + return (this.filterWidgetTypes?.length ?? 0) + (this.includeDeprecated ? 1 : 0); + } + return this.iotHubFilterCount; + } + + hasActiveFilters(): boolean { + if (this.selectWidgetMode === 'installed') { + return (this.filterWidgetTypes?.length ?? 0) > 0 || this.includeDeprecated; + } + return this.iotHubAppliedWidgetTypes.size > 0 + || this.iotHubAppliedCategories.size > 0 + || this.iotHubAppliedUseCases.size > 0; + } + + clearAllFilters(): void { + if (this.selectWidgetMode === 'installed') { + this.filterWidgetTypes = null; + this.onIncludeDeprecatedChange(false); + } else { + this.clearIotHubFilters(); + } + } + + hasActiveSearchOrFilters(): boolean { + return !!this.searchSubject.value?.trim() || this.hasActiveFilters(); + } + + clearSearchAndFilters(): void { + this.searchSubject.next(''); + if (this.hasActiveFilters()) { + this.clearAllFilters(); + } + } + + navigateToIotHubAllWidgets(): void { + this.selectWidgetMode = 'iotHub'; + this.iotHubSubMode = 'allWidgets'; + } + + get hideCategoriesFilterSection(): boolean { + return this.selectWidgetMode === 'iotHub' && this.iotHubSubMode === 'category'; + } + + onIncludeDeprecatedChange(value: boolean): void { + this.includeDeprecated = value; + this.deprecatedFilter = value ? DeprecatedFilter.ALL : DeprecatedFilter.ACTUAL; + } + + isFilterVisible(): boolean { + if (this.selectWidgetMode === 'iotHub') { + return this.iotHubSubMode !== 'default'; + } + return this.installedSubMode === 'allWidgets' || this.widgetsBundle !== null; + } + + showTopLevelWidgetModeToggle(): boolean { + if (this.selectWidgetMode === 'installed') { + return !this.widgetsBundle && this.installedSubMode === 'default'; + } + return this.iotHubSubMode === 'default'; + } + + showBackButton(): boolean { + if (this.selectWidgetMode === 'installed') { + return !!this.widgetsBundle || this.installedSubMode === 'allWidgets'; + } + return this.iotHubSubMode !== 'default'; + } + + getHeaderTitle(): string { + if (this.selectWidgetMode === 'iotHub') { + if (this.iotHubSubMode === 'category' && this.iotHubSelectedCategory) { + return this.iotHubSelectedCategory; + } + if (this.iotHubSubMode === 'installed') { + return this.translate.instant('iot-hub.installed-from-iot-hub'); + } + if (this.iotHubSubMode === 'allWidgets') { + return this.translate.instant('iot-hub.all-iot-hub-widgets'); + } + return ''; + } + if (this.widgetsBundle) { + return this.widgetsBundle.title; + } + if (this.installedSubMode === 'allWidgets') { + return this.translate.instant('widget.all-widgets'); } + return ''; } - isObject(value: any): boolean { - return isObject(value); + getSearchPlaceholder(): string { + if (this.selectWidgetMode === 'iotHub') { + if (this.iotHubSubMode === 'default') { + return this.translate.instant('iot-hub.search-categories'); + } + return this.translate.instant('iot-hub.search-widgets'); + } + if (this.installedSubMode === 'default' && !this.widgetsBundle) { + return this.translate.instant('iot-hub.search-bundles'); + } + return this.translate.instant('iot-hub.search-widgets'); + } + + onBackClick(): void { + if (this.selectWidgetMode === 'installed') { + if (this.widgetsBundle) { + this.widgetsBundle = null; + } else if (this.installedSubMode === 'allWidgets') { + this.installedSubMode = 'default'; + } + } else if (this.selectWidgetMode === 'iotHub') { + if (this.iotHubSubMode !== 'default') { + this.iotHubSubMode = 'default'; + } + } + } + + onClose(): void { + this.closeClick.emit(); + } + + clearSearch($event: Event): void { + $event.preventDefault(); + $event.stopPropagation(); + this.search = ''; + } + + onImport($event: Event): void { + this.importClick.emit($event); + } + + get widgetTypesList(): widgetType[] { + return Array.from(this.widgetTypes.values()); + } + + getWidgetTypeLabel(type: widgetType): string { + return this.translate.instant('widget.type-' + type); + } + + isWidgetTypeChecked(type: widgetType): boolean { + return !!this.filterWidgetTypes && this.filterWidgetTypes.includes(type); + } + + toggleWidgetTypeFilter(type: widgetType): void { + const current = this.filterWidgetTypes ?? []; + const next = current.includes(type) + ? current.filter(t => t !== type) + : [...current, type]; + this.filterWidgetTypes = next.length ? next : null; + } + + private reloadIotHubWidgets(): void { + if (this.iotHubSubMode === 'installed') { + this.installedWidgetVersions = null; + this.iotHubInstalledWidgetsFilter = this.searchSubject.value + '|' + Date.now(); + } else { + this.iotHubWidgetsFilter = this.searchSubject.value + '|' + Date.now(); + } + this.cd.markForCheck(); + } + + private installAndAddWidget(item: MpItemVersionView): void { + const versionId = item.id as string; + this.iotHubApiService.installItemVersion(versionId, { ignoreLoading: true }).subscribe({ + next: (result) => { + if (result.success && result.descriptor?.type === 'WIDGET') { + this.loadInstalledWidgets(); + const widgetTypeId = result.descriptor.widgetTypeId?.id; + if (widgetTypeId) { + this.widgetsService.getWidgetTypeInfoById(widgetTypeId).subscribe(wt => { + if (wt) { + this.widgetSelected.emit(this.toWidgetInfo(wt)); + } + }); + } + } + } + }); + } + + private loadIotHubFilterInfo(): void { + const hasItems = (i: FilterParamInfo) => i.totalItems > 0; + this.iotHubApiService.getFilterInfo(ItemType.WIDGET, { ignoreLoading: true }).subscribe(info => { + this.iotHubWidgetTypeOptions = (info.types || []).filter(hasItems); + this.iotHubCategoryOptions = (info.categories || []).filter(hasItems); + this.iotHubUseCaseOptions = (info.useCases || []).filter(hasItems); + }); + } + + private loadWidgetCategories(): void { + this.widgetCategoriesFilter = this.searchSubject.value + '|' + Date.now(); + } + + private loadInstalledWidgets(): void { + if (this.installedWidgets === null) { + this.installedWidgets = []; + } + const pageLink = new PageLink(10000, 0); + this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, undefined, { ignoreLoading: true }).subscribe(data => { + this.installedWidgets = data.data; + }); + } + + private fetchInstalledWidgetVersions() { + const versionIds = (this.installedWidgets || []) + .map(i => i.itemVersionId) + .filter(id => !!id); + if (versionIds.length === 0) { + this.installedWidgetVersions = []; + return of([]); + } + const versionRequests = versionIds.map(id => + this.iotHubApiService.getVersionInfo(id, { ignoreLoading: true }) + ); + return forkJoin(versionRequests).pipe( + map(versions => { + this.installedWidgetVersions = versions.sort((a, b) => b.totalInstallCount - a.totalInstallCount); + return this.installedWidgetVersions; + }) + ); + } + + private filterAndPaginateInstalledVersions(versions: MpItemVersionView[], pageSize: number, page: number, filter: string) { + let filtered = versions; + const search = typeof filter === 'string' ? filter.split('|')[0] : ''; + if (search) { + filtered = filtered.filter(v => v.name.toLowerCase().includes(search.toLowerCase())); + } + if (this.iotHubAppliedWidgetTypes.size > 0) { + filtered = filtered.filter(v => this.iotHubAppliedWidgetTypes.has(v.dataDescriptor?.widgetType)); + } + if (this.iotHubAppliedCategories.size > 0) { + filtered = filtered.filter(v => v.categories?.some(c => this.iotHubAppliedCategories.has(c))); + } + if (this.iotHubAppliedUseCases.size > 0) { + filtered = filtered.filter(v => v.useCases?.some(u => this.iotHubAppliedUseCases.has(u))); + } + filtered = this.sortInstalledVersions(filtered); + const start = page * pageSize; + const data = filtered.slice(start, start + pageSize); + return { data, totalPages: Math.ceil(filtered.length / pageSize), totalElements: filtered.length, hasNext: start + pageSize < filtered.length }; + } + + private sortInstalledVersions(versions: MpItemVersionView[]): MpItemVersionView[] { + const sort = this.iotHubSortOptions[this.iotHubSelectedSortIndex]; + const sign = sort.direction === Direction.ASC ? 1 : -1; + const copy = [...versions]; + copy.sort((a, b) => { + if (this.scadaFirst) { + const aScada = !!a.dataDescriptor?.scada; + const bScada = !!b.dataDescriptor?.scada; + if (aScada !== bScada) { + return aScada ? -1 : 1; + } + } + const av = (a as any)[sort.value]; + const bv = (b as any)[sort.value]; + if (av == null && bv == null) { return 0; } + if (av == null) { return 1; } + if (bv == null) { return -1; } + if (typeof av === 'string' && typeof bv === 'string') { + return sign * av.localeCompare(bv); + } + return sign * (av < bv ? -1 : av > bv ? 1 : 0); + }); + return copy; } private toWidgetInfo(widgetTypeInfo: WidgetTypeInfo): WidgetInfo { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.ts deleted file mode 100644 index cb43dce650..0000000000 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.ts +++ /dev/null @@ -1,49 +0,0 @@ -/// -/// Copyright © 2016-2026 The Thingsboard Authors -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT 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, InjectionToken } from '@angular/core'; -import { widgetType } from '@shared/models/widget.models'; - -export const DISPLAY_WIDGET_TYPES_PANEL_DATA = new InjectionToken('DisplayWidgetTypesPanelData'); - -export interface WidgetTypes { - type: widgetType; - display: boolean; -} - -export interface DisplayWidgetTypesPanelData { - types: WidgetTypes[]; - typesUpdated: (columns: WidgetTypes[]) => void; -} - -@Component({ - selector: 'tb-widget-types-panel', - templateUrl: './widget-types-panel.component.html', - styleUrls: ['./widget-types-panel.component.scss'], - standalone: false -}) -export class DisplayWidgetTypesPanelComponent { - - types: WidgetTypes[]; - - constructor(@Inject(DISPLAY_WIDGET_TYPES_PANEL_DATA) public data: DisplayWidgetTypesPanelData) { - this.types = this.data.types; - } - - public update() { - this.data.typesUpdated(this.types); - } -} diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts index 8988a02380..7d9b9e07f7 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts @@ -98,7 +98,24 @@ export class EntityDetailsPageComponent extends EntityDetailsPanelComponent impl const id = paramMap.get('entityId'); this.currentEntityId = { id, entityType }; this.reload(); - this.selectedTab = 0; + const queryParams = this.route.snapshot.queryParams; + let selectedTabIndex = 0; + if (queryParams['selectedTab']) { + this.router.navigate([], { + queryParams: { + selectedTab: null + }, + queryParamsHandling: 'merge', + replaceUrl: true + }); + if (this.entityTabsComponent) { + const selectedTab: string = queryParams['selectedTab']; + if (selectedTab) { + selectedTabIndex = this.entityTabsComponent.resolveTabIndex(selectedTab); + } + } + } + this.selectedTab = selectedTabIndex; } })); } diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts index 8c6fc004d8..b9ed698a47 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts @@ -215,6 +215,7 @@ export class EntityDetailsPanelComponent extends PageComponent implements AfterV if (entityTabs) { if (this.viewInited) { this.matTabGroup._tabs.reset([...this.inclusiveTabs.toArray(), ...entityTabs]); + this.matTabGroup.selectedIndex = this.selectedTab; this.matTabGroup._tabs.notifyOnChanges(); } else { this.pendingTabs = entityTabs; @@ -321,6 +322,7 @@ export class EntityDetailsPanelComponent extends PageComponent implements AfterV this.viewInited = true; if (this.pendingTabs) { this.matTabGroup._tabs.reset([...this.inclusiveTabs.toArray(), ...this.pendingTabs]); + this.matTabGroup.selectedIndex = this.selectedTab; this.matTabGroup._tabs.notifyOnChanges(); this.pendingTabs = null; } diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts index ec6e46fb72..c5b2eebeb4 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts @@ -117,6 +117,10 @@ export abstract class EntityTabsComponent, ); } + resolveTabIndex(tab: string): number { + return 0; + } + protected setEntity(entity: T) { this.entityValue = entity; } diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 00c0d3e5e1..7b4f3c6c54 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -112,6 +112,7 @@ import { StatesControllerModule } from '@home/components/dashboard-page/states/s import { DashboardLayoutComponent } from '@home/components/dashboard-page/layout/dashboard-layout.component'; import { EditWidgetComponent } from '@home/components/dashboard-page/edit-widget.component'; import { DashboardWidgetSelectComponent } from '@home/components/dashboard-page/dashboard-widget-select.component'; +import { IotHubComponentsModule } from '@home/components/iot-hub/iot-hub-components.module'; import { AddWidgetDialogComponent } from '@home/components/dashboard-page/add-widget-dialog.component'; import { ManageDashboardLayoutsDialogComponent } from '@home/components/dashboard-page/layout/manage-dashboard-layouts-dialog.component'; import { @@ -124,7 +125,6 @@ import { EmbedDashboardDialogComponent } from '@home/components/widget/dialog/em import { EMBED_DASHBOARD_DIALOG_TOKEN } from '@home/components/widget/dialog/embed-dashboard-dialog-token'; import { EdgeDownlinkTableComponent } from '@home/components/edge/edge-downlink-table.component'; import { EdgeDownlinkTableHeaderComponent } from '@home/components/edge/edge-downlink-table-header.component'; -import { DisplayWidgetTypesPanelComponent } from '@home/components/dashboard-page/widget-types-panel.component'; import { AlarmDurationPredicateValueComponent } from '@home/components/profile/alarm/alarm-duration-predicate-value.component'; import { DashboardImageDialogComponent } from '@home/components/dashboard-page/dashboard-image-dialog.component'; import { @@ -329,7 +329,6 @@ import { ResourcesLibraryComponent } from "@home/components/resources/resources- DashboardStateDialogComponent, DashboardImageDialogComponent, EmbedDashboardDialogComponent, - DisplayWidgetTypesPanelComponent, TenantProfileQueuesComponent, QueueFormComponent, RepositorySettingsComponent, @@ -367,6 +366,7 @@ import { ResourcesLibraryComponent } from "@home/components/resources/resources- CommonModule, SharedModule, SharedHomeComponentsModule, + IotHubComponentsModule, WidgetConfigComponentsModule, BasicWidgetConfigModule, Lwm2mProfileComponentsModule, @@ -469,6 +469,7 @@ import { ResourcesLibraryComponent } from "@home/components/resources/resources- SelectDashboardBreakpointComponent, EditWidgetComponent, DashboardWidgetSelectComponent, + IotHubComponentsModule, AddWidgetDialogComponent, MoveWidgetsDialogComponent, ManageDashboardLayoutsDialogComponent, @@ -478,7 +479,6 @@ import { ResourcesLibraryComponent } from "@home/components/resources/resources- DashboardStateDialogComponent, DashboardImageDialogComponent, EmbedDashboardDialogComponent, - DisplayWidgetTypesPanelComponent, TenantProfileQueuesComponent, QueueFormComponent, RepositorySettingsComponent, diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/_iot-hub-common.scss b/ui-ngx/src/app/modules/home/components/iot-hub/_iot-hub-common.scss new file mode 100644 index 0000000000..12fccf7ff1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/_iot-hub-common.scss @@ -0,0 +1,134 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Shared IoT Hub visual building blocks — used across +// iot-hub-browse, iot-hub-search, and dashboard-widget-select +// to keep filter / search controls in sync. + +@mixin tb-iot-hub-search-container { + display: flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 8px 12px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + background: white; +} + +@mixin tb-iot-hub-search-icon($color: rgba(0, 0, 0, 0.38)) { + font-size: 24px; + width: 24px; + height: 24px; + color: $color; + flex-shrink: 0; +} + +@mixin tb-iot-hub-search-input-text($letter-spacing: normal) { + flex: 1; + min-width: 0; + border: none; + outline: none; + padding: 0; + font-family: inherit; + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: $letter-spacing; + color: rgba(0, 0, 0, 0.87); + background: transparent; + + &::placeholder { + color: rgba(0, 0, 0, 0.38); + } +} + +@mixin tb-iot-hub-search-clear { + width: 24px; + height: 24px; + padding: 0; + flex-shrink: 0; + color: rgba(0, 0, 0, 0.38); + --mdc-icon-button-state-layer-size: 24px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +// Page-level search field — used by iot-hub-browse and iot-hub-search. +// Larger than the filter inputs above: 56px tall, 16px text, with a 40px +// trailing action area (search icon when empty, close icon-button when +// typing). + +@mixin tb-iot-hub-page-search-container { + display: flex; + align-items: center; + width: 0; + height: 56px; + padding: 8px 4px 8px 16px; + gap: 8px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + background: white; +} + +@mixin tb-iot-hub-page-search-input { + flex: 1; + min-width: 0; + border: none; + outline: none; + padding: 0; + font-family: inherit; + font-size: 16px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.87); + background: transparent; + + &::placeholder { + color: rgba(0, 0, 0, 0.54); + } +} + +@mixin tb-iot-hub-page-search-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex-shrink: 0; + border-radius: 50%; + color: rgba(0, 0, 0, 0.54); + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + } + + &.mat-mdc-icon-button { + padding: 8px; + --mdc-icon-button-state-layer-size: 40px; + } + + &-static { + pointer-events: none; + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html new file mode 100644 index 0000000000..402381ad67 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html @@ -0,0 +1,290 @@ + + + + +
+ @switch (ws.type) { + + @case ('placeholder') { + + } + + @case ('connectivity') { +
+

{{ 'iot-hub.device-install-select-connectivity' | translate }}

+
+ @for (ct of availableInstallMethods; track ct) { + + } +
+
+ } + + @case ('instruction') { + + + } + + @case ('form') { +
+ + +
+ } + + @case ('progress') { +
+ @for (ep of ws.entitySteps; track ep.step.name; let i = $index) { + @if (i > 0) { +
+ } + @if (ep.status === 'conflict' || ep.status === 'error') { +
+
+
+ warning +
+
+ + {{ ('iot-hub.device-install-step-type-' + ep.step.type | translate) + ' — ' + (ep.resolvedName || ep.step.name) }} + + @if (ep.status === 'conflict') { + + {{ 'iot-hub.device-install-conflict-exists' | translate:{ type: ('iot-hub.device-install-step-type-' + ep.step.type | translate) } }} + + } + @if (ep.status === 'error' && ep.errorMessage) { + {{ ep.errorMessage }} + } +
+
+
+ @if (ep.status === 'conflict') { + @if (ep.conflictType === 'use-or-overwrite') { + + + } @else { + + + } + } + @if (ep.status === 'error') { + + } +
+
+ } @else { +
+
+ @switch (ep.status) { + @case ('pending') { +
{{ i + 1 }}
+ } + @case ('running') { + + } + @case ('success') { + check_circle + } + } +
+ + {{ ('iot-hub.device-install-step-type-' + ep.step.type | translate) + ' — ' + (ep.resolvedName || ep.step.name) }} + + + @if (ep.status === 'success') { + {{ 'iot-hub.done' | translate }} + } + @if (ep.status === 'running') { + {{ 'iot-hub.in-progress' | translate }} + } + +
+ } + } +
+ } + } +
+
+ + +@if (loading) { +
+ +
+} @else { +
+

{{ 'iot-hub.device-install-title' | translate:{ name: packageInfo?.name || data.item.name } }}

+ +
+ + + @if (reviewMode) { + +
+ + @for (ws of wizardSteps; track ws.label) { + + + + + + } + +
+ + + @if (primaryEntityAction; as action) { + + } + + + } @else { + +
+ + + done + + + @for (ws of wizardSteps; track ws.label) { + + {{ ws.label }} + + + } + +
+ + + @if (allProgressDone) { + + } @else { + + } + + + + @if (!isFirstWizardStep) { + + } + + @if (currentWizardStep; as step) { + @switch (step.type) { + @case ('connectivity') { + + + } + @case ('instruction') { + @if (isLastWizardStep) { + + @if (primaryEntityAction; as action) { + + } + } @else { + + + } + } + @case ('form') { + + + } + @case ('progress') { + @if (step.progressError) { + + + } @else if (step.progressDone && isLastWizardStep) { + + @if (primaryEntityAction; as action) { + + } + } @else if (!step.progressError) { + + + } + } + } + } + + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss new file mode 100644 index 0000000000..aff20e4d58 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss @@ -0,0 +1,389 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-context(.tb-default .tb-dialog .mat-mdc-dialog-component-host), +:host-context(.tb-default .tb-fullscreen-dialog-lt-md .mat-mdc-dialog-component-host) { + .mat-mdc-dialog-content { + padding: 0; + max-height: 100%; + overflow: hidden; + } + @media #{$mat-gt-sm} { + width: 800px; + height: 1000px; + max-width: 100%; + } + @media #{$mat-gt-md} { + width: 1000px; + } +} + +:host { + display: flex; + flex-direction: column; + max-height: 90vh; + + @media #{$mat-lt-md} { + max-height: 100%; + height: 100%; + } + + .mat-mdc-tab-group { + height: 100%; + } + + ::ng-deep { + .mat-stepper-horizontal { + height: 100%; + } + .mat-horizontal-stepper-wrapper { + height: 100%; + } + .mat-horizontal-stepper-header-container { + padding: 24px 16px 16px; + } + .mat-horizontal-content-container { + overflow: auto; + flex: 1; + padding: 0; + } + .mat-horizontal-stepper-content.mat-horizontal-stepper-content-current { + height: 100%; + } + .mat-horizontal-stepper-header.mat-step-header { + padding: 0 12px; + } + } +} + +.tb-device-install-header { + .tb-device-install-title { + font-size: 20px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.87); + margin: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.tb-device-install-connectivity { + p { + color: rgba(0, 0, 0, 0.54); + font-size: 16px; + line-height: 24px; + margin: 0; + } + + // Card grid — design uses 276px cards with 12px gap, wrapping into rows. + // The 2-card variant uses a wider min-track so they don't squeeze. + .tb-connectivity-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; + + &.tb-connectivity-cards-pair { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } + } + + // Connection-card-vertical from the design: primary-tinted surface + // (4 % alpha), 6px radius, 1px subtle border that becomes primary on + // hover/select. + .tb-connectivity-card { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 16px; + height: 116px; + background: rgba($tb-primary-color, 0.04); + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; + text-align: left; + transition: border-color 0.12s ease-out, box-shadow 0.12s ease-out; + + &:hover, + &.selected { + border-color: $tb-primary-color; + } + + &.selected { + box-shadow: 0 0 0 1px $tb-primary-color inset; + + // Selected state inverts the icon: primary-coloured container + // with the masked glyph painted in white. + .tb-connectivity-card-icon { + background: $tb-primary-color; + } + + .tb-connectivity-card-icon-mask { + background-color: white; + } + } + } + + // 48×48 white icon container with subtle drop shadow + 4px radius, + // hosting a 40×40 mask-image rendered in the primary color. + .tb-connectivity-card-icon { + width: 48px; + height: 48px; + border-radius: 4px; + background: white; + display: flex; + align-items: center; + justify-content: center; + box-shadow: + 0 1px 2px 0 rgba(0, 0, 0, 0.06), + 0 1px 3px 0 rgba(0, 0, 0, 0.10); + } + + .tb-connectivity-card-icon-mask { + display: block; + width: 40px; + height: 40px; + background-color: $tb-primary-color; + -webkit-mask-image: var(--tb-icon-url); + mask-image: var(--tb-icon-url); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; + } + + .tb-connectivity-card-name { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.87); + } + + // PE-Only pill — primary 1px border + primary text on white background. + .tb-connectivity-card-badge { + position: absolute; + top: 16px; + right: 16px; + height: 24px; + padding: 0 8px; + border: 1px solid $tb-primary-color; + border-radius: 4px; + background: white; + color: $tb-primary-color; + font-size: 12px; + font-weight: 500; + line-height: 22px; + letter-spacing: 0.4px; + } +} + +// Form view +.tb-device-install-form { + mat-form-field { + width: 100%; + } + + .tb-form-field-help { + color: rgba(0, 0, 0, 0.54); + font-size: 12px; + line-height: 16px; + margin-top: -4px; + margin-bottom: 8px; + } + + .tb-form-field-help-image { + max-width: 100%; + margin-top: 4px; + margin-bottom: 8px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + } +} + +.tb-progress-divider { + width: 1px; + height: 16px; + background-color: rgba(0, 0, 0, 0.12); + margin-left: 24px; +} + +.tb-progress-row { + display: flex; + align-items: center; + gap: 16px; + padding: 8px 12px; + border-radius: 4px; + + &.tb-progress-row-active { + background: rgba(0, 0, 0, 0.03); + } + + &.tb-progress-row-alert { + background: rgba(246, 103, 22, 0.06); + align-items: flex-start; + flex-wrap: wrap; + gap: 12px; + } +} + +.tb-progress-icon { + flex-shrink: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + } + + .tb-progress-success { + color: #198038; + } + + .tb-progress-warning { + color: #f66716; + } +} + +.tb-progress-pending-circle { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.4px; + color: rgba(0, 0, 0, 0.54); + position: relative; + + &::after { + content: ''; + position: absolute; + width: 20px; + height: 20px; + border: 1px dashed rgba(0, 0, 0, 0.12); + border-radius: 999px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } +} + +.tb-progress-label { + flex: 1; + font-size: 16px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.76); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &.tb-progress-label-active { + font-weight: 500; + letter-spacing: 0.25px; + } + + &.tb-progress-label-pending { + color: rgba(0, 0, 0, 0.54); + } +} + +.tb-progress-status { + flex-shrink: 0; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + padding: 6px 12px; +} + +.tb-progress-status-done { + color: #198038; +} + +.tb-progress-status-progress { + color: rgba(0, 0, 0, 0.54); +} + + +.tb-progress-alert-msg { + font-size: 12px; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.4px; + + &.tb-progress-alert-msg-warning { + color: #f66716; + } + + &.tb-progress-alert-msg-error { + color: #dd2c00; + } +} + +.tb-progress-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 12px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: white; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + white-space: nowrap; + cursor: pointer; + + &.tb-progress-action-primary { + color: #00695c; + } + + &.tb-progress-action-warning { + color: #f66716; + } + + &:hover { + background: rgba(0, 0, 0, 0.04); + } +} + +mat-dialog-actions { + padding: 8px 24px 16px; + border-top-style: solid; + border-top-color: var(--mat-divider-color, var(--mat-app-outline)); + border-top-width: var(--mat-divider-width); +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts new file mode 100644 index 0000000000..96c5f9ed72 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts @@ -0,0 +1,1132 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { + PeConnectivityMethodPromptData, + TbPeConnectivityMethodPromptComponent +} from '@home/components/iot-hub/pe-connectivity-method-prompt.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; +import { MatStepper } from '@angular/material/stepper'; +import { firstValueFrom } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; +import { PageLink } from '@shared/models/page/page-link'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { DeviceProfileService } from '@core/http/device-profile.service'; +import { DeviceService } from '@core/http/device.service'; +import { DashboardService } from '@core/http/dashboard.service'; +import { RuleChainService } from '@core/http/rule-chain.service'; +import { AttributeService } from '@core/http/attribute.service'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { generateSecret } from '@core/utils'; +import { + DeviceInstallStep, + DevicePackageInfo, + ENTITY_STEP_TYPES, + EntityStepOutput, + EntityStepProgress, + FormFieldDefinition, + FormFieldType, + installMethodIcons as INSTALL_METHOD_ICONS, + installMethodLabels as INSTALL_METHOD_LABELS, + InstallStepType, + peOnlyInstallMethods, + stepTypeAliasMap +} from '@shared/models/iot-hub/device-package.models'; +import { mergeMap } from 'rxjs/operators'; + +export interface DeviceInstallDialogData { + item: MpItemVersionView; + reviewMode?: boolean; + selectedInstallMethod?: string; + installState?: Record; +} + +export type WizardStepType = 'connectivity' | 'placeholder' | 'instruction' | 'form' | 'progress'; + +export interface WizardStep { + type: WizardStepType; + label: string; + rawSteps: DeviceInstallStep[]; + completed: boolean; + // Instruction + markdown?: string; + // Form + formFields?: FormFieldDefinition[]; + formGroup?: UntypedFormGroup; + // Progress + entitySteps?: EntityStepProgress[]; + progressError?: string; + progressDone?: boolean; +} + +const ENTITY_STEP_MIN_DELAY = 2000; +const DEFAULT_RANDOM_SIZE = 20; + +@Component({ + selector: 'tb-device-install-dialog', + standalone: false, + templateUrl: './device-install-dialog.component.html', + styleUrls: ['./device-install-dialog.component.scss'] +}) +export class TbDeviceInstallDialogComponent extends DialogComponent implements OnInit, OnDestroy { + + @ViewChild('installStepper', {static: false}) stepper: MatStepper; + + loading = true; + packageInfo: DevicePackageInfo; + zipFiles = new Map(); + zipImages = new Map(); + + // Connectivity + availableInstallMethods: string[] = []; + selectedInstallMethod: string | null = null; + installMethodLabels = INSTALL_METHOD_LABELS; + installMethodIcons = INSTALL_METHOD_ICONS; + peOnlyInstallMethods = peOnlyInstallMethods; + + // Wizard + wizardSteps: WizardStep[] = []; + wizardStarted = false; + reviewMode = false; + + // Variable resolution state + formValues: Record = {}; + entityOutputs = new Map(); + transportVars: Record = {}; + gatewayDockerComposeContent: string | null = null; + + resolveMarkdownVariable: (key: string) => string | undefined = this._resolveMarkdownVariable.bind(this); + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: DeviceInstallDialogData, + private cdr: ChangeDetectorRef, + private deviceProfileService: DeviceProfileService, + private deviceService: DeviceService, + private dashboardService: DashboardService, + private ruleChainService: RuleChainService, + private attributeService: AttributeService, + private iotHubApiService: IotHubApiService, + private translate: TranslateService, + private dialog: MatDialog + ) { + super(store, router, dialogRef); + } + + async ngOnInit(): Promise { + try { + const zipData = await firstValueFrom(this.iotHubApiService.getVersionFileData(this.data.item.id, { ignoreLoading: true }).pipe( + mergeMap((blob: Blob) => blob.arrayBuffer()) + )); + const JSZip = (await import('jszip')).default; + const zip = await JSZip.loadAsync(zipData); + const imageExtensions = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp']); + for (const [path, entry] of Object.entries(zip.files) as [string, any][]) { + if (!entry.dir) { + const ext = path.split('.').pop()?.toLowerCase(); + if (imageExtensions.has(ext)) { + const blob = await entry.async('blob'); + const blobUrl = URL.createObjectURL(blob); + this.zipImages.set(path, blobUrl); + } else { + const content = await entry.async('string'); + this.zipFiles.set(path, content); + } + } + } + this.packageInfo = JSON.parse(this.zipFiles.get('device-info.json')); + this.availableInstallMethods = this.packageInfo.installMethods.filter( + ct => this.packageInfo.installSteps[ct]?.length > 0 + ); + } catch (e) { + console.error('Failed to parse device package ZIP', e); + } + // Fetch transport connectivity settings for variable resolution + try { + const connectivity = await firstValueFrom(this.iotHubApiService.getConnectivitySettings({ ignoreErrors: true })); + if (connectivity) { + for (const [protocol, info] of Object.entries(connectivity)) { + if (info) { + this.transportVars[`${protocol}.host`] = info.host || ''; + this.transportVars[`${protocol}.port`] = String(info.port || ''); + } + } + } + } catch (_e) { + console.error('Failed to fetch connectivity settings', _e); + } + // Review mode: restore state and start wizard with stored values + if (this.data.reviewMode && this.data.installState && this.data.selectedInstallMethod) { + this.reviewMode = true; + this.selectedInstallMethod = this.data.selectedInstallMethod; + this.restoreInstallState(this.data.installState); + this.startWizard(); + } else if (this.availableInstallMethods.length === 1 + && !peOnlyInstallMethods.has(this.availableInstallMethods[0])) { + // Single non-PE method: skip the selector and start the wizard. + this.selectedInstallMethod = this.availableInstallMethods[0]; + this.startWizard(); + } else { + // Multiple methods (or only PE-only ones): render the connection + // method step as the wizard's first step. PE-only cards open the + // pe-connectivity-method-prompt dialog and never set + // selectedInstallMethod. + this.startWizard(); + } + this.loading = false; + this.cdr.detectChanges(); + } + + ngOnDestroy() { + for (const url of this.zipImages.values()) { + URL.revokeObjectURL(url); + } + super.ngOnDestroy(); + } + + // --- Connectivity --- + + selectConnectivity(ct: string): void { + // PE-only methods open the upgrade prompt dialog instead of being + // selectable — they never become the active install method. + if (peOnlyInstallMethods.has(ct)) { + this.openPeConnectivityPrompt(ct); + return; + } + if (this.selectedInstallMethod === ct) { + // Re-clicking the already-selected card is a no-op; users who + // want to proceed without changing selection use the Next button. + return; + } + this.selectedInstallMethod = ct; + // Auto-advance: configure the wizard for the chosen method and + // jump to the next step. + if (this.currentWizardStep?.type === 'connectivity') { + this.confirmConnectivity(); + } + } + + private openPeConnectivityPrompt(ct: string): void { + this.dialog.open( + TbPeConnectivityMethodPromptComponent, + { + data: { connectorName: this.installMethodLabels.get(ct) || ct }, + autoFocus: false, + panelClass: ['tb-dialog'] + } + ); + } + + onTabChanged(index: number): void { + const ws = this.wizardSteps[index]; + if (!ws) return; + if (ws.type === 'instruction' && !ws.markdown) { + ws.markdown = this.zipFiles.get(ws.rawSteps[0].file) || ''; + } else if (ws.type === 'progress' && !ws.progressDone) { + this.showCompletedEntitySteps(ws); + } + } + + confirmConnectivity(): void { + if (!this.selectedInstallMethod) { + return; + } + // If we are already inside the wizard (connectivity step is the + // first one), append the remaining steps and advance the stepper. + const onConnectivityStep = this.currentWizardStep?.type === 'connectivity'; + if (onConnectivityStep) { + const connectivityStep = this.currentWizardStep; + // Drop anything that may have been appended on a previous pass + // (user came back to pick a different method). + this.wizardSteps.length = 1; + this.appendInstallSteps(); + connectivityStep.completed = true; + this.cdr.detectChanges(); + // Wait for new mat-step children to register before advancing. + setTimeout(() => { + this.stepper?.next(); + this.onStepActivated(); + }, 0); + return; + } + this.startWizard(); + } + + // --- Wizard --- + + get isFirstWizardStep(): boolean { + return !this.stepper || this.stepper.selectedIndex === 0; + } + + get allProgressDone(): boolean { + return this.wizardSteps.filter(s => s.type === 'progress').every(s => s.progressDone); + } + + get isLastWizardStep(): boolean { + return this.stepper && this.stepper.selectedIndex === this.wizardSteps.length - 1; + } + + get currentWizardStep(): WizardStep | null { + if (!this.stepper || !this.wizardSteps.length) { + return null; + } + return this.wizardSteps[this.stepper.selectedIndex] ?? null; + } + + get nextWizardStepLabel(): string { + if (!this.stepper || this.stepper.selectedIndex >= this.wizardSteps.length - 1) { + return ''; + } + return this.wizardSteps[this.stepper.selectedIndex + 1]?.label || ''; + } + + previousStep(): void { + if (!this.stepper || this.stepper.selectedIndex <= 0) { + return; + } + const step = this.currentWizardStep; + if (step?.type === 'progress' && step.progressError) { + this.goBackToForm(); + } else { + this.stepper.previous(); + } + } + + nextStep(): void { + const step = this.currentWizardStep; + if (!step) { + return; + } + if (step.type === 'form') { + if (step.formGroup?.invalid) { + step.formGroup.markAllAsTouched(); + return; + } + Object.assign(this.formValues, step.formGroup.getRawValue()); + } + if (this.isLastWizardStep) { + this.done(); + return; + } + step.completed = true; + this.cdr.detectChanges(); + this.stepper.next(); + this.onStepActivated(); + } + + get primaryEntityAction(): { url: string; label: string } | null { + // Priority: dashboard > device > gateway > integration + const dashboard = this.entityOutputs.get('dashboard'); + if (dashboard?.url) { + return { url: dashboard.url, label: 'Open Dashboard' }; + } + const device = this.entityOutputs.get('device'); + if (device?.url) { + return { url: device.url, label: 'Open Device' }; + } + const gateway = this.entityOutputs.get('gateway'); + if (gateway?.url) { + return { url: gateway.url, label: 'Open Gateway' }; + } + const integration = this.entityOutputs.get('integration'); + if (integration?.url) { + return { url: integration.url, label: 'Open Integration' }; + } + return null; + } + + openEntity(url: string): void { + this.dialogRef.close('installed'); + void this.router.navigateByUrl(url); + } + + done(): void { + this.dialogRef.close('installed'); + } + + cancel(): void { + this.dialogRef.close(false); + } + + retryEntitySteps(step: WizardStep): void { + step.progressError = null; + void this.runEntitySteps(step); + } + + async resolveConflict(ws: WizardStep, ep: EntityStepProgress, resolution: string): Promise { + ep.status = 'running'; + ep.errorMessage = null; + this.cdr.detectChanges(); + try { + let output: EntityStepOutput; + if (resolution === 'use-existing') { + output = ep.existingEntity; + // For devices/gateways, fetch credentials + if (ep.step.type === InstallStepType.DEVICE || ep.step.type === InstallStepType.GATEWAY) { + const creds = await firstValueFrom(this.deviceService.getDeviceCredentials(output.id, false, {ignoreErrors: true})); + output = { ...output, token: creds.credentialsId }; + if (ep.step.type === InstallStepType.GATEWAY) { + output.dockerComposeUrl = `/api/device-connectivity/gateway-launch/${output.id}/docker-compose/download`; + } + } + } else if (resolution === 'overwrite') { + output = await this.overwriteEntity(ep.step, ep.existingEntity); + } else { + // create-copy — just create normally + output = await this.createEntity(ep.step); + } + await this.saveStepAttributes(ep.step, output); + ep.existingEntity = null; + ep.conflictType = null; + this.markEntityStepSuccess(ws, ep, output, resolution); + // Resume running remaining steps + await this.runEntitySteps(ws); + } catch (err: any) { + ep.status = 'error'; + ep.errorMessage = err?.error?.message || err?.message || 'Unknown error'; + ws.progressError = ep.errorMessage; + this.cdr.detectChanges(); + } + } + + goBackToForm(): void { + // Find the last form step before the current progress step + const currentIdx = this.stepper.selectedIndex; + for (let i = currentIdx - 1; i >= 0; i--) { + if (this.wizardSteps[i].type === 'form') { + // Reset the progress step so it re-runs when we come back + const progressStep = this.wizardSteps[currentIdx]; + progressStep.entitySteps = null; + progressStep.progressError = null; + progressStep.progressDone = false; + progressStep.completed = false; + // Allow navigation back by making intermediate steps editable + for (let j = i; j < currentIdx; j++) { + this.wizardSteps[j].completed = false; + } + this.stepper.selectedIndex = i; + return; + } + } + } + + onMarkdownReady(container: HTMLElement): void { + // Download button handlers + const buttons = container.querySelectorAll('[data-action="download-gateway-docker-compose"]'); + buttons.forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + if (this.gatewayDockerComposeContent) { + const blob = new Blob([this.gatewayDockerComposeContent], { type: 'application/x-yaml' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'docker-compose.yml'; + link.click(); + URL.revokeObjectURL(url); + } else { + const gateway = this.entityOutputs.get('gateway'); + if (gateway?.id) { + this.deviceService.downloadGatewayDockerComposeFile(gateway.id).subscribe(); + } + } + }); + }); + } + + resolveImagePath(path: string): string { + if (path.startsWith('data:') || path.startsWith('http')) { + return path; + } + return this.zipImages.get(path) || path; + } + + // Bound function reference passed to 's [resolveImagePath] input. + // Defined as a property so the template binding stays stable across change detection. + readonly resolveImagePathFn: (path: string) => string = (path: string) => this.resolveImagePath(path); + + // --- Variable Resolution --- + + resolveVariables(content: string): string { + return content.replace(/\$\{([^}]+)}/g, (_match, key) => { + const res = this.resolveVariable(key); + if (res) { + return res; + } + return '${' + key + '}'; + }); + } + + resolveVariable(key: string): string | undefined { + if (key in this.formValues) { + return String(this.formValues[key]); + } + if (key in this.transportVars) { + return this.transportVars[key]; + } + const dotIdx = key.indexOf('.'); + if (dotIdx > 0) { + const alias = key.substring(0, dotIdx); + const prop = key.substring(dotIdx + 1); + const output = this.entityOutputs.get(alias); + if (output && prop in output) { + return String((output as any)[prop]); + } + } + return undefined; + } + + private _resolveMarkdownVariable(key: string): string | undefined { + const res = this.resolveVariable(key); + if (res) { + return res; + } + // Special action placeholders + if (key === 'gateway.downloadButton') { + return '⬇ Download docker-compose.yml'; + } + return undefined; + } + + // --- Private --- + + private restoreInstallState(installState: Record): void { + for (const [stepName, data] of Object.entries(installState)) { + if (data.formValues) { + Object.assign(this.formValues, data.formValues); + } + if (data.entityOutput) { + // Find the step type alias from the raw steps to set the correct entity output key + const rawSteps = this.packageInfo.installSteps[this.selectedInstallMethod] || []; + const matchingStep = rawSteps.find(s => s.name === stepName || this.resolveVariables(s.name) === stepName); + if (matchingStep) { + const alias = stepTypeAliasMap[matchingStep.type]; + if (alias) { + this.entityOutputs.set(alias, data.entityOutput); + } + } + } + } + // Resolve gateway docker-compose if available + const gatewayStep = (this.packageInfo.installSteps[this.selectedInstallMethod] || []).find(s => s.type === 'GATEWAY'); + if (gatewayStep?.dockerCompose) { + const raw = this.zipFiles.get(gatewayStep.dockerCompose); + if (raw) { + this.gatewayDockerComposeContent = this.resolveVariables(raw); + } + } + } + + private startWizard(): void { + if (!this.reviewMode) { + this.formValues = {}; + this.entityOutputs.clear(); + } + this.buildWizardSteps(); + this.wizardStarted = true; + + if (this.reviewMode) { + // Pre-activate all steps for tab mode (tabs render lazily on first select) + for (const ws of this.wizardSteps) { + if (ws.type === 'instruction') { + ws.markdown = this.zipFiles.get(ws.rawSteps[0].file) || ''; + } else if (ws.type === 'progress') { + this.showCompletedEntitySteps(ws); + } + } + } else { + // Activate the first step after the stepper renders + setTimeout(() => this.onStepActivated(), 0); + } + } + + private buildWizardSteps(): void { + this.wizardSteps = []; + if (!this.reviewMode && this.availableInstallMethods.length > 0 && !this.selectedInstallMethod) { + // First step: connection method selector. Remaining steps are + // appended via appendInstallSteps() once a method is confirmed. + this.wizardSteps.push({ + type: 'connectivity', + label: this.translate.instant('iot-hub.connection-method'), + rawSteps: [], + completed: false + }); + // Dummy placeholder steps shown in the indicator until the user + // picks a method. They are replaced with real steps for the + // chosen connectivity in confirmConnectivity(). + for (const key of [ + 'iot-hub.step-prerequisites', + 'iot-hub.step-configuration', + 'iot-hub.step-provisioning' + ]) { + this.wizardSteps.push({ + type: 'placeholder', + label: this.translate.instant(key), + rawSteps: [], + completed: false + }); + } + return; + } + this.appendInstallSteps(); + } + + private appendInstallSteps(): void { + const rawSteps = this.packageInfo.installSteps[this.selectedInstallMethod] || []; + let i = 0; + while (i < rawSteps.length) { + const step = rawSteps[i]; + if (step.type === InstallStepType.SHOW_INSTRUCTION) { + this.wizardSteps.push({ + type: 'instruction', + label: step.name, + rawSteps: [step], + completed: this.reviewMode + }); + i++; + } else if (step.type === InstallStepType.SHOW_FORM) { + this.wizardSteps.push({ + type: 'form', + label: step.name, + rawSteps: [step], + completed: this.reviewMode + }); + i++; + } else if (ENTITY_STEP_TYPES.has(step.type)) { + // Group consecutive entity steps + const group: DeviceInstallStep[] = []; + while (i < rawSteps.length && ENTITY_STEP_TYPES.has(rawSteps[i].type)) { + group.push(rawSteps[i]); + i++; + } + this.wizardSteps.push({ + type: 'progress', + label: 'Provisioning', + rawSteps: group, + completed: this.reviewMode + }); + } else { + // Skip unsupported steps (CONVERTER, INTEGRATION) + i++; + } + } + // Initialize form groups + for (const ws of this.wizardSteps) { + if (ws.type === 'form' && !ws.formGroup) { + this.initFormStep(ws); + } + } + } + + private initFormStep(ws: WizardStep): void { + const formJson = this.zipFiles.get(ws.rawSteps[0].file) || '[]'; + ws.formFields = JSON.parse(formJson) as FormFieldDefinition[]; + const controls: Record = {}; + // In review mode, use stored values; otherwise use defaults + const storedValues = this.reviewMode && this.data.installState?.[ws.label]?.formValues; + for (const field of ws.formFields) { + const validators = []; + if (field.required) { + validators.push(Validators.required); + } + if (field.validators?.length > 0) { + validators.push(Validators.pattern(field.validators[0].pattern)); + } + const initialValue = this.resolveInitialFieldValue(field, storedValues?.[field.key]); + controls[field.key] = new UntypedFormControl(initialValue, validators); + } + ws.formGroup = new UntypedFormGroup(controls); + if (this.reviewMode) { + ws.formGroup.disable(); + ws.completed = true; + } + } + + private resolveInitialFieldValue(field: FormFieldDefinition, storedValue: any): any { + if (storedValue !== undefined) { + return storedValue; + } + const isStringLike = field.type === FormFieldType.STRING || field.type === FormFieldType.PASSWORD; + if (isStringLike && field.randomByDefault) { + return generateSecret(field.randomSize ?? DEFAULT_RANDOM_SIZE); + } + return field.defaultValue ?? (field.type === FormFieldType.BOOLEAN ? false : ''); + } + + private onStepActivated(): void { + const step = this.currentWizardStep; + if (!step) { + return; + } + if (step.type === 'instruction') { + step.markdown = this.zipFiles.get(step.rawSteps[0].file) || ''; + } else if (step.type === 'progress' && !step.progressDone) { + if (this.reviewMode) { + this.showCompletedEntitySteps(step); + } else { + this.initAndRunEntitySteps(step); + } + } + } + + private showCompletedEntitySteps(ws: WizardStep): void { + ws.entitySteps = ws.rawSteps.map(s => { + const alias = stepTypeAliasMap[s.type]; + const output = alias ? this.entityOutputs.get(alias) : null; + const storedState = this.data.installState?.[s.name] || this.data.installState?.[this.resolveVariables(s.name)]; + return { + step: s, + status: 'success' as const, + resolvedName: this.resolveVariables(s.name), + entityOutput: output || undefined, + resolution: storedState?.resolution || 'created' + }; + }); + ws.progressDone = true; + ws.completed = true; + } + + private initAndRunEntitySteps(ws: WizardStep): void { + ws.entitySteps = ws.rawSteps.map(s => ({ + step: s, + status: 'pending' as const, + resolvedName: this.resolveVariables(s.name) + })); + ws.progressError = null; + ws.progressDone = false; + void this.runEntitySteps(ws); + } + + private markEntityStepSuccess(ws: WizardStep, ep: EntityStepProgress, + output: EntityStepOutput, resolution: string): void { + ep.entityOutput = output; + ep.resolution = resolution; + ep.status = 'success'; + const alias = stepTypeAliasMap[ep.step.type]; + if (alias) { + this.entityOutputs.set(alias, output); + } + for (const remaining of ws.entitySteps) { + if (remaining.status === 'pending') { + remaining.resolvedName = this.resolveVariables(remaining.step.name); + } + } + this.cdr.detectChanges(); + } + + private async runEntitySteps(ws: WizardStep): Promise { + for (const ep of ws.entitySteps) { + if (ep.status === 'success') { + continue; + } + ep.status = 'running'; + ep.errorMessage = null; + this.cdr.detectChanges(); + try { + // Pre-check for existing entity + const existing = await this.preCheckEntity(ep.step); + if (existing) { + ep.status = 'conflict'; + ep.existingEntity = existing; + ep.conflictType = this.getConflictType(ep.step.type); + this.cdr.detectChanges(); + return; // Pause — user must choose a resolution + } + const startTime = Date.now(); + const output = await this.createEntity(ep.step); + // Save optional attributes after entity creation + await this.saveStepAttributes(ep.step, output); + // Ensure minimum visible time per step + const elapsed = Date.now() - startTime; + if (elapsed < ENTITY_STEP_MIN_DELAY) { + await this.delay(ENTITY_STEP_MIN_DELAY - elapsed); + } + this.markEntityStepSuccess(ws, ep, output, 'created'); + } catch (err: any) { + ep.status = 'error'; + ep.errorMessage = err?.error?.message || err?.message || 'Unknown error'; + ws.progressError = ep.errorMessage; + this.cdr.detectChanges(); + return; + } + } + + // All done — register install with created entity IDs + ws.progressDone = true; + ws.completed = true; + try { + const createdEntityIds = this.collectCreatedEntityIds(); + const dashboardId = this.findCreatedDashboardId(); + await firstValueFrom( + this.iotHubApiService.registerDeviceInstall( + this.data.item.id as string, + { createdEntityIds, dashboardId, selectedInstallMethod: this.selectedInstallMethod, installState: this.buildInstallState() }, + { ignoreLoading: true } + ) + ); + } catch (_e) { + // Non-critical — entities are created, tracking registration failed + console.error('Failed to register device install', _e); + } + + // Auto-advance to next step after a short pause + if (!this.isLastWizardStep) { + await this.delay(500); + this.stepper.next(); + this.cdr.detectChanges(); + this.onStepActivated(); + } + } + + private async createEntity(step: DeviceInstallStep): Promise { + const raw = this.zipFiles.get(step.template); + if (!raw) { + throw new Error(`Template file not found: ${step.template}`); + } + const resolved = this.resolveVariables(raw); + const template = JSON.parse(resolved); + + switch (step.type) { + case InstallStepType.DEVICE_PROFILE: { + const existing = await this.findDeviceProfileByName(template.name); + if (existing) { + return existing; + } + const result = await firstValueFrom(this.deviceProfileService.saveDeviceProfile(template, {ignoreErrors: true})); + return { id: result.id.id, name: result.name, url: `/profiles/deviceProfiles/${result.id.id}` }; + } + case InstallStepType.DEVICE: { + const result = await firstValueFrom(this.deviceService.saveDevice(template, {ignoreErrors: true})); + const creds = await this.resolveCredentials(step, result.id.id); + return { id: result.id.id, name: result.name, url: `/entities/devices/${result.id.id}`, token: creds.credentialsId }; + } + case InstallStepType.GATEWAY: { + const result = await firstValueFrom(this.deviceService.saveDevice(template, {ignoreErrors: true})); + const creds = await this.resolveCredentials(step, result.id.id); + const output = { + id: result.id.id, + name: result.name, + url: '/entities/gateways', + token: creds.credentialsId, + dockerComposeUrl: `/api/device-connectivity/gateway-launch/${result.id.id}/docker-compose/download` + }; + // Store entity output early so docker-compose template can resolve ${gateway.token} etc. + this.entityOutputs.set('gateway', output); + // Resolve custom docker-compose template if provided + if (step.dockerCompose) { + const raw = this.zipFiles.get(step.dockerCompose); + if (raw) { + this.gatewayDockerComposeContent = this.resolveVariables(raw); + } + } + return output; + } + case InstallStepType.GATEWAY_CONNECTOR: { + const gatewayOutput = this.entityOutputs.get('gateway'); + if (!gatewayOutput) { + throw new Error('GATEWAY step must precede GATEWAY_CONNECTOR'); + } + const gatewayEntityId = { entityType: 'DEVICE', id: gatewayOutput.id } as EntityId; + const connectorName = template.name; + + // Fetch current active_connectors + let activeConnectors: string[] = []; + try { + const attrs = await firstValueFrom( + this.attributeService.getEntityAttributes(gatewayEntityId, AttributeScope.SHARED_SCOPE, ['active_connectors'], {ignoreErrors: true}) + ); + const existing = attrs?.find(a => a.key === 'active_connectors'); + if (existing?.value && Array.isArray(existing.value)) { + activeConnectors = existing.value; + } + } catch (_e) { + // First connector — no existing attributes + } + + // Append connector name + if (!activeConnectors.includes(connectorName)) { + activeConnectors.push(connectorName); + } + + // Save attributes + await firstValueFrom(this.attributeService.saveEntityAttributes( + gatewayEntityId, AttributeScope.SHARED_SCOPE, + [ + { key: 'active_connectors', value: activeConnectors }, + { key: connectorName, value: template } + ], + {ignoreErrors: true} + )); + + return { id: gatewayOutput.id, name: connectorName }; + } + case InstallStepType.DASHBOARD: { + const result = await firstValueFrom(this.dashboardService.saveDashboard(template, {ignoreErrors: true})); + return { id: result.id.id, name: result.title, url: `/dashboards/${result.id.id}` }; + } + case InstallStepType.RULE_CHAIN: { + const ruleChain = template.ruleChain || template; + const metadata = template.metadata; + const existing = await this.findRuleChainByName(ruleChain.name); + if (existing) { + return existing; + } + return this.saveRuleChainWithMetadata(ruleChain, metadata); + } + default: + throw new Error(`Unsupported entity step type: ${step.type}`); + } + } + + private async saveRuleChainWithMetadata(ruleChain: any, metadata: any): Promise { + const saved = await firstValueFrom(this.ruleChainService.saveRuleChain(ruleChain, {ignoreErrors: true})); + if (metadata) { + metadata.ruleChainId = saved.id; + await firstValueFrom(this.ruleChainService.saveRuleChainMetadata(metadata, {ignoreErrors: true})); + } + return { id: saved.id.id, name: saved.name, url: `/ruleChains/${saved.id.id}` }; + } + + private async resolveCredentials(step: DeviceInstallStep, deviceId: string): Promise { + const creds = await firstValueFrom(this.deviceService.getDeviceCredentials(deviceId, false, {ignoreErrors: true})); + if (step.credentials) { + const raw = this.zipFiles.get(step.credentials); + if (raw) { + const credTemplate = JSON.parse(this.resolveVariables(raw)); + creds.credentialsType = credTemplate.credentialsType || creds.credentialsType; + if (credTemplate.credentialsValue) { + creds.credentialsValue = typeof credTemplate.credentialsValue === 'string' + ? credTemplate.credentialsValue + : JSON.stringify(credTemplate.credentialsValue); + } + if (credTemplate.credentialsId) { + creds.credentialsId = credTemplate.credentialsId; + } + await firstValueFrom(this.deviceService.saveDeviceCredentials(creds, {ignoreErrors: true})); + } + } + return creds; + } + + private async findDeviceProfileByName(name: string): Promise { + const profiles = await firstValueFrom(this.deviceProfileService.getDeviceProfileNames()); + const match = profiles.find(p => p.name === name); + return match ? { id: match.id.id, name: match.name, url: `/profiles/deviceProfiles/${match.id.id}` } : null; + } + + private async findRuleChainByName(name: string): Promise { + const page = await firstValueFrom(this.ruleChainService.getRuleChains(new PageLink(100, 0, name))); + const match = page.data.find(rc => rc.name === name); + return match ? { id: match.id.id, name: match.name, url: `/ruleChains/${match.id.id}` } : null; + } + + private async findDeviceByName(name: string): Promise { + try { + const device = await firstValueFrom(this.deviceService.findByName(name, {ignoreErrors: true})); + if (device) { + return { id: device.id.id, name: device.name, url: `/entities/devices/${device.id.id}` }; + } + } catch (_e) { + // 404 = not found + } + return null; + } + + private async findDashboardByTitle(title: string): Promise { + const page = await firstValueFrom(this.dashboardService.getTenantDashboards(new PageLink(10, 0, title), {ignoreErrors: true})); + const match = page.data.find(d => d.title === title); + return match ? { id: match.id.id, name: match.title, url: `/dashboards/${match.id.id}` } : null; + } + + private async overwriteEntity(step: DeviceInstallStep, existing: EntityStepOutput): Promise { + const raw = this.zipFiles.get(step.template); + if (!raw) throw new Error(`Template file not found: ${step.template}`); + const resolved = this.resolveVariables(raw); + const template = JSON.parse(resolved); + + switch (step.type) { + case InstallStepType.DEVICE_PROFILE: { + template.id = { id: existing.id, entityType: 'DEVICE_PROFILE' }; + const result = await firstValueFrom(this.deviceProfileService.saveDeviceProfile(template, {ignoreErrors: true})); + return { id: result.id.id, name: result.name, url: `/profiles/deviceProfiles/${result.id.id}` }; + } + case InstallStepType.DEVICE: { + template.id = { id: existing.id, entityType: 'DEVICE' }; + const result = await firstValueFrom(this.deviceService.saveDevice(template, {ignoreErrors: true})); + const creds = await this.resolveCredentials(step, result.id.id); + return { id: result.id.id, name: result.name, url: `/entities/devices/${result.id.id}`, token: creds.credentialsId }; + } + case InstallStepType.GATEWAY: { + template.id = { id: existing.id, entityType: 'DEVICE' }; + const result = await firstValueFrom(this.deviceService.saveDevice(template, {ignoreErrors: true})); + const creds = await this.resolveCredentials(step, result.id.id); + const output: EntityStepOutput = { + id: result.id.id, + name: result.name, + url: '/entities/gateways', + token: creds.credentialsId, + dockerComposeUrl: `/api/device-connectivity/gateway-launch/${result.id.id}/docker-compose/download` + }; + this.entityOutputs.set('gateway', output); + if (step.dockerCompose) { + const dcRaw = this.zipFiles.get(step.dockerCompose); + if (dcRaw) this.gatewayDockerComposeContent = this.resolveVariables(dcRaw); + } + return output; + } + case InstallStepType.DASHBOARD: { + template.id = { id: existing.id, entityType: 'DASHBOARD' }; + const result = await firstValueFrom(this.dashboardService.saveDashboard(template, {ignoreErrors: true})); + return { id: result.id.id, name: result.title, url: `/dashboards/${result.id.id}` }; + } + case InstallStepType.RULE_CHAIN: { + const ruleChain = template.ruleChain || template; + const metadata = template.metadata; + ruleChain.id = { id: existing.id, entityType: 'RULE_CHAIN' }; + return this.saveRuleChainWithMetadata(ruleChain, metadata); + } + default: + throw new Error(`Unsupported overwrite for step type: ${step.type}`); + } + } + + private async preCheckEntity(step: DeviceInstallStep): Promise { + const raw = this.zipFiles.get(step.template); + if (!raw) return null; + const resolved = this.resolveVariables(raw); + const template = JSON.parse(resolved); + + switch (step.type) { + case InstallStepType.DEVICE_PROFILE: + return this.findDeviceProfileByName(template.name); + case InstallStepType.RULE_CHAIN: { + const name = template.ruleChain?.name || template.name; + return this.findRuleChainByName(name); + } + case InstallStepType.DEVICE: + case InstallStepType.GATEWAY: + return this.findDeviceByName(template.name); + case InstallStepType.DASHBOARD: + return this.findDashboardByTitle(template.title); + default: + return null; + } + } + + private getConflictType(stepType: InstallStepType): 'use-or-overwrite' | 'overwrite-or-copy' { + return stepType === InstallStepType.DASHBOARD ? 'overwrite-or-copy' : 'use-or-overwrite'; + } + + private buildInstallState(): Record { + const state: Record = {}; + for (const ws of this.wizardSteps) { + if (ws.type === 'form' && ws.formGroup) { + state[ws.label] = { formValues: ws.formGroup.getRawValue() }; + } else if (ws.type === 'progress' && ws.entitySteps) { + for (const ep of ws.entitySteps) { + if (ep.status === 'success' && ep.entityOutput) { + state[ep.step.name] = { entityOutput: ep.entityOutput, resolution: ep.resolution || 'created' }; + } + } + } + } + return state; + } + + private collectCreatedEntityIds(): { entityType: string; id: string }[] { + const ids: { entityType: string; id: string }[] = []; + for (const ws of this.wizardSteps) { + if (ws.type === 'progress' && ws.entitySteps) { + for (const ep of ws.entitySteps) { + if (ep.status === 'success' && ep.entityOutput) { + const entityType = this.stepTypeToEntityType(ep.step.type); + if (entityType) { + ids.push({ entityType, id: ep.entityOutput.id }); + } + } + } + } + } + return ids; + } + + private findCreatedDashboardId(): { entityType: string; id: string } | undefined { + for (const ws of this.wizardSteps) { + if (ws.type === 'progress' && ws.entitySteps) { + for (const ep of ws.entitySteps) { + if (ep.step.type === InstallStepType.DASHBOARD && ep.status === 'success' && ep.entityOutput) { + return { entityType: 'DASHBOARD', id: ep.entityOutput.id }; + } + } + } + } + return undefined; + } + + private async saveStepAttributes(step: DeviceInstallStep, output: EntityStepOutput): Promise { + const entityType = this.stepTypeToEntityType(step.type); + if (!entityType) { + return; + } + const entityId = { entityType, id: output.id } as EntityId; + if (step.serverAttributes) { + const raw = this.zipFiles.get(step.serverAttributes); + if (raw) { + const resolved = JSON.parse(this.resolveVariables(raw)); + const attrs = Object.entries(resolved).map(([key, value]) => ({ key, value })); + await firstValueFrom(this.attributeService.saveEntityAttributes( + entityId, AttributeScope.SERVER_SCOPE, attrs, {ignoreErrors: true} + )); + } + } + if (step.sharedAttributes) { + const raw = this.zipFiles.get(step.sharedAttributes); + if (raw) { + const resolved = JSON.parse(this.resolveVariables(raw)); + const attrs = Object.entries(resolved).map(([key, value]) => ({ key, value })); + await firstValueFrom(this.attributeService.saveEntityAttributes( + entityId, AttributeScope.SHARED_SCOPE, attrs, {ignoreErrors: true} + )); + } + } + } + + private stepTypeToEntityType(stepType: InstallStepType): string | null { + switch (stepType) { + case InstallStepType.DEVICE_PROFILE: return 'DEVICE_PROFILE'; + case InstallStepType.DEVICE: return 'DEVICE'; + case InstallStepType.GATEWAY: return 'DEVICE'; + case InstallStepType.GATEWAY_CONNECTOR: return 'DEVICE'; + case InstallStepType.DASHBOARD: return 'DASHBOARD'; + case InstallStepType.RULE_CHAIN: return 'RULE_CHAIN'; + default: return null; + } + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/install-form-renderer/install-form-renderer.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/install-form-renderer/install-form-renderer.component.html new file mode 100644 index 0000000000..b1a28b545c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/install-form-renderer/install-form-renderer.component.html @@ -0,0 +1,119 @@ + +@if (formGroup) { +
+ @for (field of fields; track field.key; let i = $index) { + + + @switch (field.type) { + @case (FormFieldType.BOOLEAN) { + {{ field.label }} + } + @case (FormFieldType.SELECT) { + + {{ field.label }} + + @for (opt of field.options; track opt.value) { + {{ opt.label }} + } + + @if (field.helpText) { + {{ field.helpText }} + } + @if (formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + + } + @case (FormFieldType.PASSWORD) { + + {{ field.label }} + + + @if (field.randomGenerator) { + + } + @if (field.helpText) { + {{ field.helpText }} + } + @if (formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + @if (formGroup.controls[field.key]?.hasError('pattern')) { + {{ getPatternErrorMessage(field) }} + } + + } + @case (FormFieldType.INTEGER) { + + {{ field.label }} + + @if (field.helpText) { + {{ field.helpText }} + } + @if (formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + @if (formGroup.controls[field.key]?.hasError('pattern')) { + {{ getPatternErrorMessage(field) }} + } + + } + @default { + + {{ field.label }} + + @if (field.randomGenerator) { + + } + @if (field.helpText) { + {{ field.helpText }} + } + @if (formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + @if (formGroup.controls[field.key]?.hasError('pattern')) { + {{ getPatternErrorMessage(field) }} + } + + } + } + + @if (field.type === FormFieldType.BOOLEAN && field.helpText) { +
{{ field.helpText }}
+ } + @if (field.helpImage) { + + } + } +
+} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/install-form-renderer/install-form-renderer.component.scss similarity index 63% rename from ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.scss rename to ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/install-form-renderer/install-form-renderer.component.scss index 99285e46db..daaa824570 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/install-form-renderer/install-form-renderer.component.scss @@ -14,23 +14,16 @@ * limitations under the License. */ :host { - width: 100%; - height: 100%; - min-width: 200px; - overflow: hidden; - background: #fff; - border-radius: 4px; - box-shadow: - 0 7px 8px -4px rgba(0, 0, 0, .2), - 0 13px 19px 2px rgba(0, 0, 0, .14), - 0 5px 24px 4px rgba(0, 0, 0, .12); + display: block; +} - .mat-content { - overflow: hidden; - background-color: #fff; - } +.tb-install-form-field-help { + font-size: 12px; + color: rgba(0, 0, 0, 0.6); + margin-top: 4px; +} - .mat-padding { - padding: 16px; - } +.tb-install-form-field-help-image { + max-width: 100%; + margin-top: 8px; } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/install-form-renderer/install-form-renderer.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/install-form-renderer/install-form-renderer.component.ts new file mode 100644 index 0000000000..ea129ce0ea --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/install-form-renderer/install-form-renderer.component.ts @@ -0,0 +1,82 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, OnChanges, SimpleChanges } from '@angular/core'; +import { UntypedFormGroup } from '@angular/forms'; +import { FormFieldDefinition, FormFieldType } from '@shared/models/iot-hub/device-package.models'; +import { generateSecret } from '@core/utils'; + +const DEFAULT_RANDOM_SIZE = 20; + +/** + * Reusable form renderer for the device install dialog's SHOW_FORM step. Consumes + * the FormFieldDefinition[] parsed from the package's form.json directly. + * + * The renderer is presentation-only. The caller owns the FormGroup and supplies + * the FormFieldDefinition[] array. Optional resolveImagePath callback maps + * relative help-image paths to data URIs (the dialog supplies it from the parsed + * package ZIP). + */ +@Component({ + selector: 'tb-install-form-renderer', + templateUrl: './install-form-renderer.component.html', + styleUrls: ['./install-form-renderer.component.scss'], + standalone: false +}) +export class InstallFormRendererComponent implements OnChanges { + + @Input() fields: FormFieldDefinition[] = []; + @Input() formGroup!: UntypedFormGroup; + @Input() resolveImagePath?: (path: string) => string; + /** When true, PASSWORD inputs render unmasked by default (used by review mode). */ + @Input() reviewMode = false; + + passwordVisible: Record = {}; + + readonly FormFieldType = FormFieldType; + + ngOnChanges(changes: SimpleChanges): void { + if (changes.fields || changes.reviewMode) { + this.passwordVisible = {}; + if (this.reviewMode) { + for (const f of this.fields) { + if (f.type === FormFieldType.PASSWORD) { + this.passwordVisible[f.key] = true; + } + } + } + } + } + + togglePasswordVisible(key: string): void { + this.passwordVisible[key] = !this.passwordVisible[key]; + } + + regenerate(field: FormFieldDefinition): void { + const control = this.formGroup.controls[field.key]; + if (!control) return; + control.patchValue(generateSecret(field.randomSize ?? DEFAULT_RANDOM_SIZE)); + control.markAsDirty(); + } + + getPatternErrorMessage(field: FormFieldDefinition): string { + return field.validators?.[0]?.message || 'Invalid format'; + } + + imagePath(path: string): string { + return this.resolveImagePath ? this.resolveImagePath(path) : path; + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts new file mode 100644 index 0000000000..bae080a464 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts @@ -0,0 +1,152 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Observable, of, EMPTY } from 'rxjs'; +import { filter, mergeMap } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; +import { DialogService } from '@core/services/dialog.service'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { DeviceInstalledItemDescriptor, IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { EntityId } from '@shared/models/id/entity-id'; +import { TbIotHubAddItemDialogComponent, IotHubAddItemDialogData, IotHubAddItemDialogResult } from './iot-hub-add-item-dialog.component'; +import { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData, IotHubItemDetailDialogMode } from './iot-hub-item-detail-dialog.component'; +import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component'; +import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component'; +import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from './iot-hub-delete-dialog.component'; +import { TbDeviceInstallDialogComponent, DeviceInstallDialogData } from './device-install-dialog/device-install-dialog.component'; +import { TbIotHubInstalledItemsDialogComponent, IotHubInstalledItemsDialogData } from './iot-hub-installed-items-dialog.component'; + +@Injectable() +export class IotHubActionsService { + + constructor( + private dialog: MatDialog, + private iotHubApiService: IotHubApiService, + private dialogService: DialogService, + private translate: TranslateService + ) {} + + openItemDetail(item: MpItemVersionView, installedItem?: IotHubInstalledItem, installedItemsCount?: number, + mode?: IotHubItemDetailDialogMode, showCreator?: boolean, preview?: boolean): Observable { + return this.dialog.open(TbIotHubItemDetailDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + disableClose: true, + autoFocus: false, + data: { item, installedItem, installedItemsCount, mode, showCreator, preview } as IotHubItemDetailDialogData + }).afterClosed(); + } + + openInstalledItems(item: MpItemVersionView): Observable { + return this.dialog.open(TbIotHubInstalledItemsDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + disableClose: true, + autoFocus: false, + data: { item } as IotHubInstalledItemsDialogData + }).afterClosed(); + } + + addItem(itemType: ItemType, options?: { itemSubType?: string; entityId?: EntityId }): Observable { + return this.dialog.open(TbIotHubAddItemDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog-lt-md'], + disableClose: true, + autoFocus: false, + data: { + itemType, + itemSubType: options?.itemSubType, + entityId: options?.entityId + } as IotHubAddItemDialogData + }).afterClosed(); + } + + installItem(item: MpItemVersionView): Observable { + if (item.type === ItemType.ALARM_RULE) { + this.dialogService.alert( + this.translate.instant('iot-hub.alarm-rule-install-update-required'), + this.translate.instant('iot-hub.alarm-rule-install-update-required-text') + ); + return EMPTY; + } + if (item.type === ItemType.DEVICE) { + return this.installDevice(item); + } + return this.dialog.open(TbIotHubInstallDialogComponent, { + panelClass: ['tb-dialog'], + disableClose: true, + autoFocus: false, + data: { item } as IotHubInstallDialogData + }).afterClosed(); + } + + updateItem(installedItem: IotHubInstalledItem, version: string, versionId: string): Observable { + if (!installedItem) { + return EMPTY; + } + return this.dialog.open(TbIotHubUpdateDialogComponent, { + panelClass: ['tb-dialog'], + disableClose: true, + autoFocus: false, + data: { + installedItemId: installedItem.id.id, + itemName: installedItem.itemName, + itemType: installedItem.itemType as ItemType, + version, + versionId + } as IotHubUpdateDialogData + }).afterClosed(); + } + + deleteItem(installedItem: IotHubInstalledItem): Observable { + if (!installedItem) { + return of(false); + } + return this.dialog.open(TbIotHubDeleteDialogComponent, { + panelClass: ['tb-dialog'], + disableClose: true, + autoFocus: false, + data: { itemName: installedItem.itemName, itemType: installedItem.itemType } as IotHubDeleteDialogData + }).afterClosed().pipe( + filter(confirmed => !!confirmed), + mergeMap(() => this.iotHubApiService.deleteInstalledItem(installedItem.id.id)), + mergeMap(() => of(true)) + ); + } + + installDevice(item: MpItemVersionView): Observable { + return this.openDeviceInstallDialog(item); + } + + reviewDevice(item: MpItemVersionView, deviceDescriptor: DeviceInstalledItemDescriptor): Observable { + return this.openDeviceInstallDialog(item, { + reviewMode: true, + selectedInstallMethod: deviceDescriptor.selectedInstallMethod, + installState: deviceDescriptor.installState + }); + } + + private openDeviceInstallDialog(item: MpItemVersionView, + options?: { reviewMode?: boolean; selectedInstallMethod?: string; installState?: any }): Observable { + return this.dialog.open(TbDeviceInstallDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog-lt-md'], + disableClose: true, + autoFocus: false, + data: { item, ...options } + }).afterClosed(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.html new file mode 100644 index 0000000000..ebe3f7baef --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.html @@ -0,0 +1,50 @@ + +
+ +

{{ getTitle() }}

+ + +
+
+ @if (isInstalling) { +
+ +
+ } + + +
+
+ +
+
diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.scss similarity index 53% rename from ui-ngx/src/app/shared/components/widgets-bundle-search.component.scss rename to ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.scss index 649f082696..58852ae71b 100644 --- a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.scss @@ -13,43 +13,35 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -.input-wrapper { - background: hsla(0, 0%, 100%, .2); - border-radius: 8px; - color: rgb(255, 255, 255, .84); - padding: 5px 8px 5px 16px; - height: 46px; - input { - width: 100%; - height: 100%; +@import "../../../../../scss/constants"; + +:host-context(.mat-mdc-dialog-container) { + .mat-mdc-dialog-content { + position: relative; padding: 0; - font-size: 20px; - outline: none; - border: none; - background-color: transparent; - color: #fff; + overflow-x: hidden; + height: 70vh; - &::placeholder { - color: #fff; - opacity: .6; - line-height: 26px; + tb-iot-hub-browse { + height: 100%; } - } - &.focus { - background: #fff; - color: rgba(0,0,0,.54); + @media #{$mat-gt-sm} { + width: 80vw; + max-height: 70vh; + } - input { - color: #000; - opacity: .60; + @media #{$mat-gt-md} { + width: 1000px; } - .close { - color: #000 !important; - opacity: .54; + @media #{$mat-gt-xmd} { + width: 1200px; } } -} + .mat-mdc-dialog-actions { + border-top: 1px solid rgba(0, 0, 0, 0.12); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.ts new file mode 100644 index 0000000000..e83580e271 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.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 { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { DialogService } from '@core/services/dialog.service'; +import { TranslateService } from '@ngx-translate/core'; +import { EntityId } from '@shared/models/id/entity-id'; +import { IotHubActionsService } from './iot-hub-actions.service'; + +export interface IotHubAddItemDialogData { + itemType: ItemType; + itemSubType?: string; + entityId?: EntityId; +} + +export interface IotHubAddItemDialogResult { + item: MpItemVersionView; + descriptor: any; +} + +@Component({ + selector: 'tb-iot-hub-add-item-dialog', + standalone: false, + templateUrl: './iot-hub-add-item-dialog.component.html', + styleUrls: ['./iot-hub-add-item-dialog.component.scss'] +}) +export class TbIotHubAddItemDialogComponent extends DialogComponent { + + itemType: ItemType; + itemSubType: string; + isInstalling = false; + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: IotHubAddItemDialogData, + private translate: TranslateService, + private iotHubApiService: IotHubApiService, + private dialogService: DialogService, + private iotHubActions: IotHubActionsService + ) { + super(store, router, dialogRef); + this.itemType = data.itemType; + this.itemSubType = data.itemSubType; + } + + getTitle(): string { + const typeKey = itemTypeTranslations.get(this.itemType); + const typeLabel = typeKey ? this.translate.instant(typeKey) : ''; + return this.translate.instant('iot-hub.add-item-from-iot-hub', { type: typeLabel }); + } + + onAddItem(item: MpItemVersionView): void { + if (this.itemType === ItemType.DEVICE) { + this.installDeviceItem(item); + return; + } + this.isInstalling = true; + const versionId = item.id as string; + const installData = this.data.entityId ? { entityId: this.data.entityId } : undefined; + this.iotHubApiService.installItemVersion(versionId, { ignoreLoading: true }, installData).subscribe({ + next: (result) => { + this.isInstalling = false; + if (result.success) { + this.dialogRef.close({ item, descriptor: result.descriptor } as IotHubAddItemDialogResult); + } else { + const message = result.errorMessage || this.translate.instant('iot-hub.install-error', { name: item.name }); + this.dialogService.alert( + this.translate.instant('iot-hub.install-error-title'), + message + ); + } + }, + error: (err) => { + this.isInstalling = false; + const message = err?.error?.message || err?.message || this.translate.instant('iot-hub.install-error', { name: item.name }); + this.dialogService.alert( + this.translate.instant('iot-hub.install-error-title'), + message + ); + } + }); + } + + private installDeviceItem(item: MpItemVersionView): void { + this.iotHubActions.installDevice(item).subscribe(result => { + if (result === 'installed') { + this.dialogRef.close({ item, descriptor: { type: 'DEVICE' } } as IotHubAddItemDialogResult); + } + }); + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html new file mode 100644 index 0000000000..0448de834c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html @@ -0,0 +1,497 @@ + +
+ + + + + + + + + +
+ @if (!fixedSubType && subtypeOptions.length > 0 && activeType !== ItemType.ALARM_RULE) { + + + {{ 'iot-hub.type-filter-label' | translate }} + + @if (subtypeOptions.length >= 10) { + + } +
+ @for (opt of getFilteredItems(subtypeOptions, 'subtype'); track opt.key) { + + {{ getSubtypeLabel(opt.key) }} + + } +
+
+ } + + @if (activeType === ItemType.DEVICE) { + @if (!fixedSubType && subtypeOptions.length > 0) { +
+ } + @if (vendorOptions.length > 0) { + + + {{ 'iot-hub.vendor' | translate }} + + @if (vendorOptions.length >= 10) { + + } +
+ @for (group of getGroupedFilterItems(vendorOptions, 'vendor'); track group.label) { + @if (group.label) { +
{{ group.label | translate }}
+ } + @for (v of group.items; track v.key) { + + {{ v.key }} + + } + } +
+
+
+ } + @if (hardwareTypeOptions.length > 0) { + + + {{ 'iot-hub.hardware-type' | translate }} + + @if (hardwareTypeOptions.length >= 10) { + + } +
+ @for (ht of getFilteredItems(hardwareTypeOptions, 'hardwareType'); track ht.key) { + + {{ ht.key }} + + } +
+
+
+ } + @if (connectivityGroups.length > 0) { + + + {{ 'iot-hub.connectivity' | translate }} + + @if (allConnectivityCount >= 10) { + + } +
+ @for (group of getFilteredConnectivityGroups('connectivity'); track group.group) { +
{{ group.group }}
+ @for (val of group.values; track val.key) { + + {{ val.key }} + + } + } +
+
+ } + } @else { + @if (categoryOptions.length > 0) { + @if (!fixedSubType && subtypeOptions.length > 0) { +
+ } + + + {{ 'iot-hub.category' | translate }} + + @if (categoryOptions.length >= 10) { + + } +
+ @for (cat of getFilteredItems(categoryOptions, 'category'); track cat.key) { + + {{ cat.key }} + + } +
+
+ } + } + + @if (useCaseOptions.length > 0) { + @if (activeType === ItemType.DEVICE || (!fixedSubType && subtypeOptions.length > 0) || categoryOptions.length > 0) { +
+ } + + + {{ 'iot-hub.use-case' | translate }} + + @if (useCaseOptions.length >= 10) { + + } +
+ @for (group of getGroupedFilterItems(useCaseOptions, 'useCase'); track group.label) { + @if (group.label) { +
{{ group.label | translate }}
+ } + @for (uc of group.items; track uc.key) { + + {{ uc.key }} + + } + } +
+
+
+ } +
+
+ + + + + +
+ {{ 'iot-hub.filter' | translate }} + +
+ @if (hasActiveDropdownFilters()) { +
+
+ + @for (key of getActiveSubtypesArray(); track key) { + + {{ getSubtypeLabel(key) }} + close + + } + @for (key of getActiveCategoriesArray(); track key) { + + {{ key }} + close + + } + @for (key of getActiveHardwareTypesArray(); track key) { + + {{ key }} + close + + } + @for (key of getActiveConnectivityArray(); track key) { + + {{ key }} + close + + } + @for (v of getActiveVendorsArray(); track v) { + + {{ v }} + close + + } + @for (key of getActiveUseCasesArray(); track key) { + + {{ key }} + close + + } + + +
+ } +
+
+ +
+
+ + + +
+
+ +
+
+ + +
+ +
+
+ + @if (textSearch) { + + } @else { + + } +
+ + + @for (option of sortOptions; track option.value; let i = $index) { + + } + +
+ + +
+ + {{ 'iot-hub.results-count' | translate:{ count: totalElements } }} + + + @if (hasActiveDropdownFilters()) { + + @for (key of getActiveSubtypesArray(); track key) { + + {{ getSubtypeLabel(key) }} + close + + } + @for (key of getActiveCategoriesArray(); track key) { + + {{ key }} + close + + } + @for (key of getActiveHardwareTypesArray(); track key) { + + {{ key }} + close + + } + @for (key of getActiveConnectivityArray(); track key) { + + {{ key }} + close + + } + @for (v of getActiveVendorsArray(); track v) { + + {{ v }} + close + + } + @for (key of getActiveUseCasesArray(); track key) { + + {{ key }} + close + + } + + + } + +
+ +
+ + + @if (hasError) { +
+
+

{{ 'iot-hub.network-server-unavailable' | translate }}

+

{{ 'iot-hub.network-server-unavailable-text' | translate }}

+ +
+ } + + + @if (!hasError && items.length === 0) { +
+
+

{{ 'iot-hub.no-items-found' | translate }}

+

{{ 'iot-hub.no-items-found-text' | translate }}

+ @if (hasActiveFilters()) { + + } +
+ } + + + @if (!hasError && items.length > 0) { +
+ @for (item of items; track item.id) { + + + } +
+ + +
+
+ + @for (p of getPageNumbers(); track p) { + + } + +
+
+ {{ 'iot-hub.items-per-page' | translate }}: + + @for (size of pageSizeOptions; track size) { + {{ size }} + } + +
+
+ } + + + @if (isLoading) { +
+
+ +
+
+ } +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.scss new file mode 100644 index 0000000000..fc685f5b90 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.scss @@ -0,0 +1,630 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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"; +@import "./iot-hub-common"; + +:host { + display: block; +} + +.tb-iot-hub-browse { + &.tabs-hidden:not(.embedded) { + border-top: 1px solid rgba(0, 0, 0, 0.12); + } +} + +.hidden { + display: none !important; +} + +// Tabs — Design: px-40, bottom border, labels auto-width (not stretched) +.tb-iot-hub-tabs { + padding: 0 40px; + flex-shrink: 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + + .mat-mdc-tab-link { + min-width: auto; + flex: 0 0 auto; + } +} + +// Two-column layout below tabs +.tb-iot-hub-content { + display: flex; + flex: 1; + height: 100%; + background: transparent; + overflow: visible; + + ::ng-deep .mat-drawer-backdrop { + bottom: -48px; + + .embedded & { + bottom: 0; + } + + &.mat-drawer-shown { + background: transparent; + } + } +} + +// tb-iot-hub-drawer-content layout handled via Tailwind (flex flex-1); overflow stays default visible + +// Left sidebar filters — Design: 280px wide, right border, own scroll +.tb-iot-hub-filters { + width: 300px; + flex-shrink: 0; + border-right: 1px solid rgba(0, 0, 0, 0.12); + position: relative; + padding-bottom: 48px; + margin-bottom: -48px; + + .embedded & { + padding-bottom: 0; + margin-bottom: 0; + } +} + +.tb-iot-hub-filters-wrapper, +.tb-iot-hub-filter-drawer-body { + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background-color: rgba(0, 0, 0, 0.04); + } + + &::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.2); + border-radius: 3px; + + &:hover { + background-color: rgba(0, 0, 0, 0.35); + } + } + + &.filter-items-hovered { + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: transparent; + } + } +} + +.tb-iot-hub-filters-wrapper { + position: sticky; + max-height: calc(100vh - var(--mat-toolbar-standard-height, 64px)); + inset: 0; + overflow-y: auto; +} + +// Filter toggle button — hidden on large screens, shown on lt-lg +.tb-iot-hub-filter-toggle-btn { + display: none; + flex-shrink: 0; +} + +// Embedded mode — always use mobile filter layout (no left sidebar, filter button + drawer) +.tb-iot-hub-browse.embedded { + .tb-iot-hub-filters-desktop { + display: none; + } + + .tb-iot-hub-filter-toggle-btn { + display: inline-flex; + } + + .tb-iot-hub-sort-row { + .tb-iot-hub-active-filters, + .tb-iot-hub-clear-btn { + display: none; + } + } +} + +// Filter drawer — right-side overlay +.tb-iot-hub-filter-drawer { + width: 60%; + max-width: 550px; + min-width: 300px; + &:not(.embedded &) { + position: fixed; + top: var(--mat-toolbar-standard-height, 64px); + @media #{$mat-xs} { + top: var(--mat-toolbar-mobile-height, 56px); + } + } +} + +.tb-iot-hub-filter-drawer-header { + padding: 12px 12px 12px 24px; + font-size: 18px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + + .mat-mdc-icon-button { + --mat-icon-button-state-layer-size: 40px; + padding: 8px; + } +} + +.tb-iot-hub-filter-drawer-chips { + padding: 4px 24px 12px; +} + +.tb-iot-hub-filter-drawer-body { + overflow-y: auto; + flex: 1; + padding: 8px 0 0; + + .tb-iot-hub-filters-inner { + padding: 0; + } + + .tb-iot-hub-filter-panel.mat-expansion-panel { + .mat-expansion-panel-header { + padding-left: 24px; + } + + mat-checkbox { + padding-left: 0; + } + + .tb-iot-hub-filter-items { + padding-left: 16px; + } + + .tb-iot-hub-filter-search { + margin-left: 24px; + } + } +} + +.tb-iot-hub-filters-inner { + display: flex; + flex-direction: column; + gap: 8px; + padding: 24px 0; +} + +// Filter divider between sections +.tb-iot-hub-filter-divider { + height: 1px; + background: rgba(0, 0, 0, 0.12); +} + +// Filter expansion panels — Design: no shadow, pl-40 for content +.tb-iot-hub-filter-panel.mat-expansion-panel { + box-shadow: none; + border-radius: 0; + background: transparent; + + .mat-expansion-panel-header { + padding: 8px 16px 8px 56px; + height: 40px; + min-height: 40px; + } + + ::ng-deep .mat-expansion-panel-header-title { + font-size: 14px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + } + + ::ng-deep .mat-expansion-panel-body { + padding: 0 8px 0 0; + } + + mat-checkbox { + display: block; + overflow: hidden; + + &:hover { + background: rgba(0, 0, 0, 0.04); + } + } + + .tb-iot-hub-filter-items { + padding-left: 44px; + max-height: 340px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: transparent; + border-radius: 3px; + } + + &:hover { + &::-webkit-scrollbar-track { + background-color: rgba(0, 0, 0, 0.04); + } + + &::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.2); + + &:hover { + background-color: rgba(0, 0, 0, 0.35); + } + } + } + } + + .tb-iot-hub-filter-search { + @include tb-iot-hub-search-container; + margin: 4px 8px 4px 56px; + + mat-icon { + @include tb-iot-hub-search-icon(rgba(0, 0, 0, 0.54)); + } + + input { + @include tb-iot-hub-search-input-text(0.2px); + } + + .tb-iot-hub-filter-search-clear.mat-mdc-icon-button { + @include tb-iot-hub-search-clear; + } + } + + .tb-iot-hub-filter-group-label { + font-size: 14px; + font-weight: 500; + color: rgba(0, 0, 0, 0.54); + letter-spacing: 0.5px; + padding: 12px 0 4px 12px; + } +} + +// Right content area — Design: flex-1, pr-40, scrollable +.tb-iot-hub-results { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 0 40px 0 20px; + + .embedded & { + padding-bottom: 20px; + } +} + + +// Results count — Design: 14px Medium, rgba(0,0,0,0.76), tracking 0.25 +.tb-iot-hub-results-count { + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.76); +} + +.tb-iot-hub-search-field { + @include tb-iot-hub-page-search-container; +} + +.tb-iot-hub-search-input { + @include tb-iot-hub-page-search-input; +} + +.tb-iot-hub-search-action { + @include tb-iot-hub-page-search-action; +} + +// Sort button — Design: same row as search, 40px h, 88px min-w, 4px radius, +// 14px Medium primary color, trailing arrow_drop_down 24px icon +.tb-iot-hub-sort-btn { + flex-shrink: 0; + height: 40px; + min-width: 88px; + padding: 8px; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + border-radius: 4px; + + .mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + } +} + +// Filter chips row — Design: 32px tall, no padding, centered +.tb-iot-hub-sort-row { + height: 32px; + align-items: center; + padding: 0; + margin-top: 16px; + margin-bottom: 12px; + + .mat-mdc-chip { + margin-top: 0; + margin-bottom: 0; + } +} + +// Active filter chips — Design: mat-chip-set, gap-8, chip bg rgba(0,0,0,0.04) +.tb-iot-hub-active-filters { + display: inline; + + .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) { + --mat-chip-elevated-container-color: rgba(0, 0, 0, 0.04); + } +} + +// Clear all button — Design: 14px Regular, rgba(0,0,0,0.54) +.tb-iot-hub-clear-btn { + font-size: 14px; + font-weight: 400; + color: rgba(0, 0, 0, 0.54); + cursor: pointer; + background: none; + border: none; + padding: 6px 8px; + font-family: inherit; + + &:hover { + color: rgba(0, 0, 0, 0.87); + } +} + +// Card grid — Design: 5 columns, gap 20px; compact: 4 columns +.tb-iot-hub-card-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 20px; + padding: 8px 0 24px; + + &.tb-iot-hub-card-grid-compact { + grid-template-columns: repeat(4, 1fr); + } + + // Probe: zero-height element used only to read the computed column count. + // Keeps display: grid and non-zero width so grid-template-columns resolves. + &.tb-iot-hub-card-grid-probe { + width: 100%; + height: 0; + padding: 0; + gap: 0; + overflow: hidden; + visibility: hidden; + pointer-events: none; + } +} + +// Empty state text + no-data image +.tb-iot-hub-empty-state { + .tb-no-data-bg, .tb-no-service-bg { + flex: auto; + height: 100px; + } + + h3 { + font-size: 18px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + margin: 16px 0 8px; + } + + p { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); + margin: 0 0 24px; + } +} + +// Pagination — centered page numbers via absolute positioning +.tb-iot-hub-pagination { + position: relative; +} + +// Page buttons — centered via absolute positioning +.tb-iot-hub-pagination-pages { + position: absolute; + left: 50%; + transform: translateX(-50%); + overflow: hidden; + + .mat-mdc-icon-button { + width: 40px; + height: 40px; + padding: 8px; + --mdc-icon-button-state-layer-size: 40px; + } +} + +.tb-iot-hub-page-btn { + height: 40px; + min-width: 40px; + padding: 8px 16px; + border-radius: 4px; + border: none; + background: transparent; + color: rgba(0, 0, 0, 0.76); + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + font-family: inherit; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; + + &:hover:not(.active) { + background: rgba(0, 0, 0, 0.04); + } + + &.active { + background: $tb-primary-color; + color: white; + } +} + +// Items per page — Design: gap-8, 14px Regular, right-aligned +.tb-iot-hub-pagination-size { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + margin-left: auto; +} + +// Page size dropdown — Design: 77×40, border rgba(0,0,0,0.12), rounded-4, px-12 py-6 +.tb-iot-hub-page-size-select { + width: 77px; + height: 40px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 6px 12px; + + ::ng-deep .mat-mdc-select-trigger { + font-size: 14px; + } +} + +// Responsive + +@media #{$mat-lt-xxl} { + .tb-iot-hub-card-grid { + grid-template-columns: repeat(4, 1fr); + + &.tb-iot-hub-card-grid-compact { + grid-template-columns: repeat(3, 1fr); + } + } +} + +@media #{$mat-lt-xl} { + .tb-iot-hub-card-grid { + grid-template-columns: repeat(3, 1fr); + + &.tb-iot-hub-card-grid-compact { + grid-template-columns: repeat(2, 1fr); + } + } +} + +@media #{$mat-lt-xmd} { + .tb-iot-hub-card-grid { + grid-template-columns: repeat(2, 1fr); + + &.tb-iot-hub-card-grid-compact { + grid-template-columns: repeat(1, 1fr); + } + } +} + +@media #{$mat-lt-lg} { + .tb-iot-hub-filters-desktop { + display: none; + } + + .tb-iot-hub-filter-toggle-btn { + display: inline-flex; + } + + .tb-iot-hub-sort-row { + .tb-iot-hub-active-filters, + .tb-iot-hub-clear-btn { + display: none; + } + } + + .tb-iot-hub-results { + padding-left: 24px; + padding-right: 24px; + } + + .tb-iot-hub-pagination-pages { + position: static; + transform: none; + } +} + +@media #{$mat-lt-md} { + .tb-iot-hub-filter-drawer { + width: 100%; + max-width: 100%; + } +} + +@media #{$mat-xs} { + .tb-iot-hub-card-grid { + grid-template-columns: repeat(1, 1fr); + } + + .tb-iot-hub-pagination { + flex-direction: column; + align-items: center; + gap: 24px; + } + + .tb-iot-hub-pagination-pages { + position: static; + transform: none; + } + + .tb-iot-hub-pagination-size { + margin-left: 0; + } + + .tb-iot-hub-results { + padding-left: 16px; + padding-right: 16px; + } +} + +// Embedded mode responsive overrides +.embedded { + .tb-iot-hub-card-grid.tb-iot-hub-card-grid-compact { + grid-template-columns: repeat(1, 1fr); + } + + @media #{$mat-gt-sm} { + .tb-iot-hub-card-grid { + grid-template-columns: repeat(3, 1fr); + } + } + + @media #{$mat-gt-md} { + .tb-iot-hub-card-grid.tb-iot-hub-card-grid-compact { + grid-template-columns: repeat(2, 1fr); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts new file mode 100644 index 0000000000..2efea716bf --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts @@ -0,0 +1,730 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, ElementRef, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; +import { Subject } from 'rxjs'; +import { debounceTime, takeUntil } from 'rxjs/operators'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { PageData } from '@shared/models/page/page-data'; +import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { ItemType, FilterParamInfo, CREATOR_VISIBLE_ITEM_TYPES } from '@shared/models/iot-hub/iot-hub-item.models'; +import { widgetTypeTranslations, cfTypeTranslations, ruleChainTypeTranslations } from '@shared/models/iot-hub/iot-hub-version.models'; +import { IotHubInstalledItem, DeviceInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { TranslateService } from '@ngx-translate/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; +import { filterIotHubItemsBySearch, groupIotHubFilterItems, IotHubFilterGroup } from '@home/components/iot-hub/iot-hub-utils'; + +interface SortOption { + value: string; + label: string; + direction: Direction; +} + +@Component({ + selector: 'tb-iot-hub-browse', + standalone: false, + templateUrl: './iot-hub-browse.component.html', + styleUrls: ['./iot-hub-browse.component.scss'], + host: { '[class.embedded]': 'embedded' } +}) +export class TbIotHubBrowseComponent implements OnInit, AfterViewInit, OnDestroy { + + private static typeTabLabel(t: ItemType): string { + switch (t) { + case ItemType.WIDGET: return 'item.type-widget-plural'; + case ItemType.DASHBOARD: return 'item.type-dashboard-plural'; + case ItemType.SOLUTION_TEMPLATE: return 'item.type-solution-template-plural'; + case ItemType.CALCULATED_FIELD: return 'item.type-calculated-field-plural'; + case ItemType.ALARM_RULE: return 'item.type-alarm-rule-plural'; + case ItemType.RULE_CHAIN: return 'item.type-rule-chain-plural'; + case ItemType.DEVICE: return 'item.type-device-plural'; + } + } + + readonly ItemType = ItemType; + + @Input() creatorId: string; + @Input() embedded = false; + @Input() hideTabs = false; + @Input() mode: 'default' | 'add' = 'default'; + @Input() fixedSubType: string; + @Output() addItem = new EventEmitter(); + @Input() set activeType(value: ItemType) { + if (value && value !== this._activeType) { + const wasInit = !!this._activeType; + this._activeType = value; + // When set after init (e.g., parent resolves config in its ngOnInit), + // trigger type-specific loading since our ngOnInit already ran + if (wasInit) { + this.loadFilterInfo(); + if (value === ItemType.WIDGET) { + this.loadInstalledWidgets(); + } else if (value === ItemType.SOLUTION_TEMPLATE) { + this.loadInstalledSolutionTemplates(); + } else { + this.loadInstalledItemCounts(); + } + } + } + } + get activeType(): ItemType { + return this._activeType; + } + + get isCompactType(): boolean { + return this._activeType === ItemType.CALCULATED_FIELD + || this._activeType === ItemType.ALARM_RULE + || this._activeType === ItemType.RULE_CHAIN; + } + + get searchPlaceholderKey(): string { + switch (this._activeType) { + case ItemType.WIDGET: return 'iot-hub.search-widgets'; + case ItemType.DASHBOARD: return 'iot-hub.search-dashboards'; + case ItemType.SOLUTION_TEMPLATE: return 'iot-hub.search-solution-templates'; + case ItemType.CALCULATED_FIELD: return 'iot-hub.search-calculated-fields'; + case ItemType.ALARM_RULE: return 'iot-hub.search-alarm-rules'; + case ItemType.RULE_CHAIN: return 'iot-hub.search-rule-chains'; + case ItemType.DEVICE: return 'iot-hub.search-devices'; + default: return 'iot-hub.search'; + } + } + + items: MpItemVersionView[] = []; + totalElements = 0; + pageSize = 12; + pageIndex = 0; + isLoading = false; + hasError = false; + filterDrawerOpened = false; + private retryTimer: any = null; + + textSearch = ''; + _activeType: ItemType = ItemType.WIDGET; + + @ViewChild('cardGridProbe', { static: true }) cardGridProbe!: ElementRef; + probeCols = 4; + + get cols(): number { + return this.isCompactType ? Math.max(1, this.probeCols - 1) : this.probeCols; + } + + get pageSizeOptions(): number[] { + return this.cols >= 5 ? [15, 30, 60] : [12, 24, 48]; + } + + private resizeObserver?: ResizeObserver; + private resize$ = new Subject(); + activeCategories = new Set(); + activeUseCases = new Set(); + activeCfTypes = new Set(); + activeWidgetTypes = new Set(); + activeRuleChainTypes = new Set(); + activeConnectivity = new Set(); + activeHardwareTypes = new Set(); + activeVendors = new Set(); + + sortOptions: SortOption[] = [ + { value: 'totalInstallCount', label: 'iot-hub.sort-most-installed', direction: Direction.DESC }, + { value: 'publishedTime', label: 'iot-hub.sort-newest', direction: Direction.DESC }, + { value: 'name', label: 'iot-hub.sort-name', direction: Direction.ASC } + ]; + + typeTabs: { type: ItemType; label: string }[] = CREATOR_VISIBLE_ITEM_TYPES.map(t => ({ + type: t, + label: TbIotHubBrowseComponent.typeTabLabel(t) + })); + selectedSortIndex = 0; + + subtypeOptions: FilterParamInfo[] = []; + categoryOptions: FilterParamInfo[] = []; + useCaseOptions: FilterParamInfo[] = []; + vendorOptions: FilterParamInfo[] = []; + hardwareTypeOptions: FilterParamInfo[] = []; + connectivityGroups: { group: string; values: FilterParamInfo[] }[] = []; + + filterSearch: Record = {}; + filterItemsHovered = false; + + installedWidgets: IotHubInstalledItem[] = null; + installedSolutionTemplates: IotHubInstalledItem[] = null; + installedItemCounts: Record = {}; + + private searchSubject = new Subject(); + private destroy$ = new Subject(); + + constructor( + private iotHubApiService: IotHubApiService, + private iotHubActions: IotHubActionsService, + private translate: TranslateService, + private router: Router, + private route: ActivatedRoute, + private zone: NgZone + ) {} + + ngOnInit(): void { + this.searchSubject.pipe( + debounceTime(300), + takeUntil(this.destroy$) + ).subscribe(() => { + this.pageIndex = 0; + this.loadItems(); + }); + const params = this.route.snapshot.queryParams; + if (params['search']) { + this.textSearch = params['search']; + } + if (this.fixedSubType) { + const subtypes = this.getActiveSubtypes(); + if (subtypes) { + subtypes.add(this.fixedSubType); + } + } + this.loadFilterInfo(); + if (this.activeType === ItemType.WIDGET) { + this.loadInstalledWidgets(); + } else if (this.activeType === ItemType.SOLUTION_TEMPLATE) { + this.loadInstalledSolutionTemplates(); + } else { + this.loadInstalledItemCounts(); + } + } + + ngAfterViewInit(): void { + this.probeCols = this.measureProbeCols(); + this.adjustPageSize(); + this.resize$.pipe( + debounceTime(150), + takeUntil(this.destroy$) + ).subscribe(() => this.handleResize()); + this.resizeObserver = new ResizeObserver(() => { + this.zone.run(() => this.resize$.next()); + }); + this.resizeObserver.observe(this.cardGridProbe.nativeElement); + this.loadItems(); + } + + ngOnDestroy(): void { + this.resizeObserver?.disconnect(); + this.destroy$.next(); + this.destroy$.complete(); + } + + private measureProbeCols(): number { + const el = this.cardGridProbe?.nativeElement; + if (!el) { + return this.probeCols; + } + // Force layout so `grid-template-columns` resolves to pixel tracks, not `repeat(...)`. + void el.offsetWidth; + const tracks = getComputedStyle(el).gridTemplateColumns; + if (!tracks || tracks === 'none') { + return 1; + } + if (tracks.startsWith('repeat(')) { + const match = tracks.match(/^repeat\(\s*(\d+)\s*,/); + return match ? parseInt(match[1], 10) : 1; + } + return Math.max(1, tracks.trim().split(/\s+/).filter(t => t.length > 0).length); + } + + private adjustPageSize(): void { + const options = this.pageSizeOptions; + if (options.includes(this.pageSize)) { + return; + } + const oldPageSize = this.pageSize; + const oldPageIndex = this.pageIndex; + this.pageSize = options.reduce((best, cur) => + Math.abs(cur - oldPageSize) < Math.abs(best - oldPageSize) ? cur : best); + this.pageIndex = Math.floor((oldPageIndex * oldPageSize) / this.pageSize); + } + + private handleResize(): void { + const newProbeCols = this.measureProbeCols(); + if (newProbeCols === this.probeCols) { + return; + } + const oldOptions = this.pageSizeOptions; + this.probeCols = newProbeCols; + const newOptions = this.pageSizeOptions; + if (oldOptions[0] === newOptions[0]) { + return; + } + this.adjustPageSize(); + this.loadItems(); + } + + onSearchChange(value: string): void { + this.textSearch = value; + this.searchSubject.next(value); + } + + onTypeTabChange(type: ItemType): void { + this.activeType = type; + this.clearActiveFilterSets(); + this.filterSearch = {}; + this.loadFilterInfo(); + this.pageIndex = 0; + this.adjustPageSize(); + if (type === ItemType.WIDGET) { + this.loadInstalledWidgets(); + } else if (type === ItemType.SOLUTION_TEMPLATE) { + this.loadInstalledSolutionTemplates(); + } else { + this.loadInstalledItemCounts(); + } + this.loadItems(); + } + + isSubtypeActive(key: string): boolean { + return this.getActiveSubtypes().has(key); + } + + isCategoryActive(key: string): boolean { + return this.activeCategories.has(key); + } + + isUseCaseActive(key: string): boolean { + return this.activeUseCases.has(key); + } + + onCategoryToggle(category: string): void { + if (this.activeCategories.has(category)) { + this.activeCategories.delete(category); + } else { + this.activeCategories.add(category); + } + this.pageIndex = 0; + this.loadItems(); + } + + onSortChange(index: number): void { + this.selectedSortIndex = index; + this.pageIndex = 0; + this.loadItems(); + } + + onPageChange(event: { pageIndex: number; pageSize: number }): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + this.loadItems(); + } + + getTotalPages(): number { + return Math.ceil(this.totalElements / this.pageSize); + } + + getPageNumbers(): number[] { + const total = this.getTotalPages(); + const pages: number[] = []; + const maxVisible = 5; + let start = Math.max(0, this.pageIndex - Math.floor(maxVisible / 2)); + const end = Math.min(total, start + maxVisible); + if (end - start < maxVisible) { + start = Math.max(0, end - maxVisible); + } + for (let i = start; i < end; i++) { + pages.push(i); + } + return pages; + } + + goToPage(page: number): void { + if (page >= 0 && page < this.getTotalPages()) { + this.pageIndex = page; + this.loadItems(); + } + } + + onPageSizeChange(size: number): void { + this.pageSize = size; + this.pageIndex = 0; + this.loadItems(); + } + + onUseCaseToggle(useCase: string): void { + if (this.activeUseCases.has(useCase)) { + this.activeUseCases.delete(useCase); + } else { + this.activeUseCases.add(useCase); + } + this.pageIndex = 0; + this.loadItems(); + } + + onConnectivityToggle(value: string): void { + if (this.activeConnectivity.has(value)) { + this.activeConnectivity.delete(value); + } else { + this.activeConnectivity.add(value); + } + this.pageIndex = 0; + this.loadItems(); + } + + isConnectivityActive(value: string): boolean { + return this.activeConnectivity.has(value); + } + + onHardwareTypeToggle(value: string): void { + if (this.activeHardwareTypes.has(value)) { + this.activeHardwareTypes.delete(value); + } else { + this.activeHardwareTypes.add(value); + } + this.pageIndex = 0; + this.loadItems(); + } + + isHardwareTypeActive(value: string): boolean { + return this.activeHardwareTypes.has(value); + } + + onVendorToggle(vendor: string): void { + if (this.activeVendors.has(vendor)) { + this.activeVendors.delete(vendor); + } else { + this.activeVendors.add(vendor); + } + this.pageIndex = 0; + this.loadItems(); + } + + isVendorActive(vendor: string): boolean { + return this.activeVendors.has(vendor); + } + + getActiveVendorsArray(): string[] { + return Array.from(this.activeVendors); + } + + getActiveConnectivityArray(): string[] { + return Array.from(this.activeConnectivity); + } + + getActiveHardwareTypesArray(): string[] { + return Array.from(this.activeHardwareTypes); + } + + getActiveSubtypes(): Set { + switch (this.activeType) { + case ItemType.WIDGET: return this.activeWidgetTypes; + case ItemType.CALCULATED_FIELD: return this.activeCfTypes; + case ItemType.RULE_CHAIN: return this.activeRuleChainTypes; + default: return new Set(); + } + } + + onSubtypeToggle(subtype: string): void { + const active = this.getActiveSubtypes(); + if (active.has(subtype)) { + active.delete(subtype); + } else { + active.add(subtype); + } + this.pageIndex = 0; + this.loadItems(); + } + + getActiveSubtypesArray(): string[] { + if (this.fixedSubType) { + return Array.from(this.getActiveSubtypes()).filter(s => s !== this.fixedSubType); + } + return Array.from(this.getActiveSubtypes()); + } + + getActiveCategoriesArray(): string[] { + return Array.from(this.activeCategories); + } + + getActiveUseCasesArray(): string[] { + return Array.from(this.activeUseCases); + } + + getFilteredItems(items: FilterParamInfo[], searchKey: string): FilterParamInfo[] { + return filterIotHubItemsBySearch(items, this.filterSearch[searchKey]); + } + + getGroupedFilterItems(items: FilterParamInfo[], searchKey: string): IotHubFilterGroup[] { + return groupIotHubFilterItems(items, this.filterSearch[searchKey]); + } + + getFilteredConnectivityGroups(searchKey: string): { group: string; values: FilterParamInfo[] }[] { + const search = (this.filterSearch[searchKey] || '').toLowerCase(); + if (!search) { return this.connectivityGroups; } + return this.connectivityGroups + .map(g => ({ group: g.group, values: g.values.filter(v => v.key.toLowerCase().includes(search)) })) + .filter(g => g.values.length > 0); + } + + get allConnectivityCount(): number { + return this.connectivityGroups.reduce((sum, g) => sum + g.values.length, 0); + } + + getSubtypeLabel(key: string): string { + let translationKey: string; + switch (this.activeType) { + case ItemType.WIDGET: translationKey = widgetTypeTranslations.get(key); break; + case ItemType.CALCULATED_FIELD: translationKey = cfTypeTranslations.get(key); break; + case ItemType.RULE_CHAIN: translationKey = ruleChainTypeTranslations.get(key); break; + default: return key; + } + return translationKey ? this.translate.instant(translationKey) : key; + } + + clearAllFilters(): void { + this.clearActiveFilterSets(); + if (this.fixedSubType) { + this.getActiveSubtypes()?.add(this.fixedSubType); + } + this.textSearch = ''; + this.filterSearch = {}; + this.loadFilterInfo(); + this.pageIndex = 0; + this.loadItems(); + } + + private clearActiveFilterSets(): void { + this.activeCategories.clear(); + this.activeUseCases.clear(); + this.activeCfTypes.clear(); + this.activeWidgetTypes.clear(); + this.activeRuleChainTypes.clear(); + this.activeConnectivity.clear(); + this.activeHardwareTypes.clear(); + this.activeVendors.clear(); + } + + get activeFilterCount(): number { + const subtypeCount = this.fixedSubType ? this.getActiveSubtypesArray().length : (this.getActiveSubtypes()?.size || 0); + return subtypeCount + this.activeCategories.size + this.activeUseCases.size + + this.activeConnectivity.size + this.activeHardwareTypes.size + this.activeVendors.size; + } + + hasActiveDropdownFilters(): boolean { + const subtypeCount = this.fixedSubType ? this.getActiveSubtypesArray().length : (this.getActiveSubtypes()?.size || 0); + return this.activeCategories.size > 0 || + this.activeUseCases.size > 0 || subtypeCount > 0 || + this.activeConnectivity.size > 0 || this.activeHardwareTypes.size > 0 || + this.activeVendors.size > 0; + } + + hasActiveFilters(): boolean { + return this.hasActiveDropdownFilters() || this.textSearch.length > 0; + } + + getTitle(): string { + switch (this.activeType) { + case ItemType.WIDGET: return 'iot-hub.title-widgets'; + case ItemType.DASHBOARD: return 'iot-hub.title-dashboards'; + case ItemType.SOLUTION_TEMPLATE: return 'iot-hub.title-solution-templates'; + case ItemType.CALCULATED_FIELD: return 'iot-hub.title-calculated-fields'; + case ItemType.ALARM_RULE: return 'iot-hub.title-alarm-rules'; + case ItemType.RULE_CHAIN: return 'iot-hub.title-rule-chains'; + case ItemType.DEVICE: return 'iot-hub.title-devices'; + } + } + + getInstalledItem(item: MpItemVersionView): IotHubInstalledItem | undefined { + if (this.activeType === ItemType.WIDGET && this.installedWidgets) { + return this.installedWidgets.find(i => i.itemId === item.itemId); + } + if (this.activeType === ItemType.SOLUTION_TEMPLATE && this.installedSolutionTemplates) { + return this.installedSolutionTemplates.find(i => i.itemId === item.itemId); + } + return undefined; + } + + getInstalledItemsCount(item: MpItemVersionView): number { + return this.installedItemCounts[item.itemId] || 0; + } + + openItemDetail(item: MpItemVersionView): void { + this.iotHubActions.openItemDetail(item, this.getInstalledItem(item), this.getInstalledItemsCount(item), this.mode).subscribe(result => { + if (result?.action === 'add') { + this.addItem.emit(result.item); + } else if (result === 'installed' || result === 'updated' || result === 'deleted') { + this.reloadInstalledItems(); + } + }); + } + + onItemAdd(item: MpItemVersionView): void { + this.addItem.emit(item); + } + + installItem(item: MpItemVersionView): void { + this.iotHubActions.installItem(item).subscribe(result => { + if (result === 'installed') { + this.reloadInstalledItems(); + } + }); + } + + updateItem(item: MpItemVersionView): void { + const installedItem = this.getInstalledItem(item); + if (!installedItem) { return; } + this.iotHubActions.updateItem(installedItem, item.version, item.id as string).subscribe(result => { + if (result === 'updated') { + this.reloadInstalledItems(); + } + }); + } + + deleteInstalledItem(item: MpItemVersionView): void { + const installedItem = this.getInstalledItem(item); + if (!installedItem) { return; } + this.iotHubActions.deleteItem(installedItem).subscribe(() => { + this.reloadInstalledItems(); + }); + } + + openInstallGuide(item: MpItemVersionView): void { + const installedItem = this.getInstalledItem(item); + if (!installedItem) { return; } + const descriptor = installedItem.descriptor as DeviceInstalledItemDescriptor; + this.iotHubApiService.getVersionInfo(installedItem.itemVersionId, {ignoreLoading: true, ignoreErrors: true}).subscribe(versionView => { + this.iotHubActions.reviewDevice(versionView, descriptor).subscribe(); + }); + } + + navigateToCreator(creatorId: string): void { + void this.router.navigate(['/iot-hub/creator', creatorId]); + } + + private loadInstalledWidgets(): void { + if (this.installedWidgets !== null) { + return; + } + const pageLink = new PageLink(10000, 0); + this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, undefined, {ignoreLoading: true}).subscribe({ + next: (data) => { + this.installedWidgets = data.data; + } + }); + } + + private loadInstalledItemCounts(): void { + this.iotHubApiService.getInstalledItemCounts(this.activeType, {ignoreLoading: true}).subscribe({ + next: (counts) => { + this.installedItemCounts = counts; + } + }); + } + + private loadInstalledSolutionTemplates(): void { + if (this.installedSolutionTemplates !== null) { + return; + } + const pageLink = new PageLink(10000, 0); + this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, undefined, {ignoreLoading: true}).subscribe({ + next: (data) => { + this.installedSolutionTemplates = data.data; + } + }); + } + + private reloadInstalledItems(): void { + const config = {ignoreLoading: true}; + const pageLink = new PageLink(10000, 0); + if (this.activeType === ItemType.WIDGET) { + this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, undefined, config).subscribe(data => { + this.installedWidgets = data.data; + }); + } else if (this.activeType === ItemType.SOLUTION_TEMPLATE) { + this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, undefined, config).subscribe(data => { + this.installedSolutionTemplates = data.data; + }); + } else { + this.iotHubApiService.getInstalledItemCounts(this.activeType, config).subscribe(counts => { + this.installedItemCounts = counts; + }); + } + } + + private loadFilterInfo(): void { + const hasItems = (i: FilterParamInfo) => i.totalItems > 0; + this.iotHubApiService.getFilterInfo(this.activeType, { ignoreLoading: true }).subscribe(info => { + this.subtypeOptions = (info.types || []).filter(hasItems); + this.categoryOptions = (info.categories || []).filter(hasItems); + this.useCaseOptions = (info.useCases || []).filter(hasItems); + this.vendorOptions = (info.vendors || []).filter(hasItems); + this.hardwareTypeOptions = (info.hardwareTypes || []).filter(hasItems); + if (info.connectivities) { + this.connectivityGroups = Object.entries(info.connectivities) + .map(([group, values]) => ({ group, values: values.filter(hasItems) })) + .filter(g => g.values.length > 0); + } else { + this.connectivityGroups = []; + } + }); + } + + retryLoadItems(): void { + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + } + this.isLoading = true; + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.loadItems(); + }, 350); + } + + loadItems(): void { + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + this.isLoading = true; + const sort = this.sortOptions[this.selectedSortIndex]; + const sortOrder: SortOrder = { property: sort.value, direction: sort.direction }; + const pageLink = new PageLink(this.pageSize, this.pageIndex, this.textSearch || null, sortOrder); + const query = new MpItemVersionQuery(pageLink, { + type: this.activeType, + creatorId: this.creatorId || undefined, + categories: this.activeCategories.size > 0 ? Array.from(this.activeCategories) : undefined, + useCases: this.activeUseCases.size > 0 ? Array.from(this.activeUseCases) : undefined, + cfTypes: this.activeCfTypes.size > 0 ? Array.from(this.activeCfTypes) : undefined, + widgetTypes: this.activeWidgetTypes.size > 0 ? Array.from(this.activeWidgetTypes) : undefined, + ruleChainTypes: this.activeRuleChainTypes.size > 0 ? Array.from(this.activeRuleChainTypes) : undefined, + hardwareTypes: this.activeHardwareTypes.size > 0 ? Array.from(this.activeHardwareTypes) : undefined, + connectivity: this.activeConnectivity.size > 0 ? Array.from(this.activeConnectivity) : undefined, + vendors: this.activeVendors.size > 0 ? Array.from(this.activeVendors) : undefined + }); + this.iotHubApiService.getPublishedVersions( + query, + { ignoreLoading: true, ignoreErrors: true } + ).subscribe({ + next: (data: PageData) => { + this.items = data.data; + this.totalElements = data.totalElements; + this.isLoading = false; + this.hasError = false; + }, + error: () => { + this.isLoading = false; + this.hasError = true; + this.items = []; + this.totalElements = 0; + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts new file mode 100644 index 0000000000..2df12eedce --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts @@ -0,0 +1,90 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { TbIotHubItemDetailDialogComponent } from './iot-hub-item-detail-dialog.component'; +import { TbIotHubBrowseComponent } from './iot-hub-browse.component'; +import { TbIotHubItemCardComponent } from './iot-hub-item-card.component'; +import { TbIotHubAddItemDialogComponent } from './iot-hub-add-item-dialog.component'; +import { TbIotHubInstallDialogComponent } from './iot-hub-install-dialog.component'; +import { TbIotHubUpdateDialogComponent } from './iot-hub-update-dialog.component'; +import { TbIotHubDeleteDialogComponent } from './iot-hub-delete-dialog.component'; +import { TbIotHubUnpublishedWarningDialogComponent } from './iot-hub-unpublished-warning-dialog.component'; +import { TbDeviceInstallDialogComponent } from './device-install-dialog/device-install-dialog.component'; +import { InstallFormRendererComponent } from './device-install-dialog/install-form-renderer/install-form-renderer.component'; +import { TbIotHubSearchComponent } from './iot-hub-search.component'; +import { TbIotHubInstalledItemsTableComponent } from './iot-hub-installed-items-table.component'; +import { TbIotHubInstalledItemsDialogComponent } from './iot-hub-installed-items-dialog.component'; +import { TbPeConnectivityMethodPromptComponent } from './pe-connectivity-method-prompt.component'; +import { TbIotHubPeRequiredDialogComponent } from './iot-hub-pe-required-dialog.component'; +import { TbIotHubUpgradeRequiredDialogComponent } from './iot-hub-upgrade-required-dialog.component'; +import { TbIotHubMarkdownComponent } from './iot-hub-markdown.component'; +import { SolutionInstallDialogComponent } from './solution-install-dialog.component'; +import { IotHubActionsService } from './iot-hub-actions.service'; +import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module'; + +@NgModule({ + declarations: [ + TbIotHubItemDetailDialogComponent, + TbIotHubBrowseComponent, + TbIotHubItemCardComponent, + TbIotHubAddItemDialogComponent, + TbIotHubInstallDialogComponent, + TbIotHubUpdateDialogComponent, + TbIotHubDeleteDialogComponent, + TbIotHubUnpublishedWarningDialogComponent, + TbDeviceInstallDialogComponent, + TbIotHubSearchComponent, + TbIotHubInstalledItemsTableComponent, + TbIotHubInstalledItemsDialogComponent, + TbPeConnectivityMethodPromptComponent, + TbIotHubMarkdownComponent, + SolutionInstallDialogComponent, + InstallFormRendererComponent, + TbIotHubPeRequiredDialogComponent, + TbIotHubUpgradeRequiredDialogComponent + ], + imports: [ + CommonModule, + SharedModule, + IotHubItemLinkModule + ], + providers: [ + IotHubActionsService + ], + exports: [ + TbIotHubItemDetailDialogComponent, + TbIotHubBrowseComponent, + TbIotHubItemCardComponent, + TbIotHubAddItemDialogComponent, + TbIotHubInstallDialogComponent, + TbIotHubUpdateDialogComponent, + TbIotHubDeleteDialogComponent, + TbIotHubUnpublishedWarningDialogComponent, + TbDeviceInstallDialogComponent, + TbIotHubSearchComponent, + TbIotHubInstalledItemsTableComponent, + TbIotHubInstalledItemsDialogComponent, + TbPeConnectivityMethodPromptComponent, + TbIotHubMarkdownComponent, + SolutionInstallDialogComponent, + TbIotHubPeRequiredDialogComponent, + TbIotHubUpgradeRequiredDialogComponent + ] +}) +export class IotHubComponentsModule { } diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-delete-dialog.component.html similarity index 52% rename from ui-ngx/src/app/shared/components/widgets-bundle-search.component.html rename to ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-delete-dialog.component.html index 8332d0a268..dfbbbf5d79 100644 --- a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-delete-dialog.component.html @@ -15,15 +15,11 @@ limitations under the License. --> -
- search - - +
+

{{ 'iot-hub.remove-confirm-title' | translate:{ name: data.itemName } }}

+

{{ (data.itemType === 'SOLUTION_TEMPLATE' ? 'iot-hub.remove-desc-solution-template' : 'iot-hub.remove-desc') | translate }}

+
+
+ +
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-delete-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-delete-dialog.component.scss new file mode 100644 index 0000000000..bb7b7fe893 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-delete-dialog.component.scss @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-iot-hub-delete-title { + font-size: 20px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.87); + margin: 0; +} + +.tb-iot-hub-delete-desc { + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.54); + margin: 0; +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-delete-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-delete-dialog.component.ts new file mode 100644 index 0000000000..4353f1a9f6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-delete-dialog.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, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; + +export interface IotHubDeleteDialogData { + itemName: string; + itemType?: string; +} + +@Component({ + selector: 'tb-iot-hub-delete-dialog', + standalone: false, + templateUrl: './iot-hub-delete-dialog.component.html', + styleUrls: ['./iot-hub-delete-dialog.component.scss'] +}) +export class TbIotHubDeleteDialogComponent extends DialogComponent { + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: IotHubDeleteDialogData + ) { + super(store, router, dialogRef); + } + + confirm(): void { + this.dialogRef.close(true); + } + + cancel(): void { + this.dialogRef.close(false); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html new file mode 100644 index 0000000000..bb551a4106 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html @@ -0,0 +1,231 @@ + +
+ @switch (state) { + @case ('confirm') { +

{{ 'iot-hub.install-confirm-title' | translate:{ name: item.name } }}

+

{{ 'iot-hub.install-desc' | translate }}

+ } + @case ('select-entity') { +

{{ 'iot-hub.install-confirm-title' | translate:{ name: item.name } }}

+ @if (item.type === ItemType.RULE_CHAIN) { +

{{ 'iot-hub.rule-chain-install-subtitle' | translate }}

+
+
+ +
+ {{ 'iot-hub.rule-chain-set-as-profile-default-toggle' | translate }} +
+
+ @if (ruleChainInstallForm.controls.setAsDefault.value) { +
+ + + + +
+ } +
+
+ } @else { + @if (activeSelectEntityConfig?.promptKey) { +

+ } + + + } + } + @case ('confirm-overwrite') { +

{{ 'iot-hub.rule-chain-overwrite-title' | translate }}

+

+ } + @case ('plan') { +

{{ 'iot-hub.install-plan-title' | translate:{ name: item.name } }}

+

+ @if (planSummary.willInstall > 0) { + {{ 'iot-hub.install-plan-will-install' | translate:{ count: planSummary.willInstall } }} + } + @if (planSummary.alreadyInstalled > 0) { + {{ 'iot-hub.install-plan-will-update' | translate:{ count: planSummary.alreadyInstalled } }} + } +

+ @if (planSummary.missing > 0) { +

{{ 'iot-hub.install-plan-missing-warning' | translate:{ count: planSummary.missing } }}

+ } +
    + @for (entry of installPlan?.entries; track entry.itemId) { +
  • +
    + {{ entry.name || entry.itemId }} + @if (entry.type) { + {{ (typeTranslations.get(entry.type) || entry.type) | translate }} + } + @if (entry.version) { + v{{ entry.version }} + } +
    + + @switch (entry.status) { + @case (PlanStatus.WILL_INSTALL) { {{ 'iot-hub.install-plan-status-will' | translate }} } + @case (PlanStatus.ALREADY_INSTALLED) { {{ 'iot-hub.install-plan-status-installed' | translate }} } + @case (PlanStatus.MISSING) { {{ 'iot-hub.install-plan-status-missing' | translate }} } + } + +
  • + } +
+ } + @case ('installing') { +

{{ 'iot-hub.install-confirm-title' | translate:{ name: item.name } }}

+

{{ 'iot-hub.install-desc' | translate }}

+ } + @case ('success') { +

{{ 'iot-hub.install-success-title' | translate }}

+

{{ 'iot-hub.install-success-desc' | translate:{ name: item.name } }}

+ } + @case ('partial') { +

{{ 'iot-hub.install-partial-title' | translate }}

+

{{ 'iot-hub.install-partial-desc' | translate:{ name: item.name } }}

+
    + @for (entry of missingEntries; track entry.itemId) { +
  • + {{ entry.name || entry.itemId }} + @if (entry.errorMessage) { + {{ entry.errorMessage }} + } +
  • + } +
+ } + @case ('error') { +

{{ 'iot-hub.install-error-title' | translate }}

+

{{ 'iot-hub.install-error-message' | translate:{ name: item.name } }}

+ @if (item.type === ItemType.SOLUTION_TEMPLATE) { + + } @else { +
+ {{ errorMessage }} +
+ } + } + } +
+ + @switch (state) { + @case ('confirm') { + + + } + @case ('select-entity') { + + @if (item.type === ItemType.RULE_CHAIN) { + + } @else { + + } + } + @case ('confirm-overwrite') { + + + } + @case ('plan') { + + @if (planSummary.willInstall > 0) { + + } + } + @case ('installing') { + + + } + @case ('success') { + + @if (entityDetailsUrl) { + + } + } + @case ('partial') { + + @if (entityDetailsUrl) { + + } + } + @case ('error') { + + } + } + diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.scss new file mode 100644 index 0000000000..bf6356a331 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.scss @@ -0,0 +1,148 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-entity-select { + @media #{$mat-gt-sm} { + min-width: 512px; + } +} + +.tb-iot-hub-install-title { + font-size: 20px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.87); + margin: 0; +} + +.tb-iot-hub-install-desc { + color: rgba(0, 0, 0, 0.54); + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + margin: 0; +} + +.tb-iot-hub-error-details { + max-height: 200px; + overflow-y: auto; + padding: 12px; + background: #f5f5f5; + border-radius: 4px; + font-family: monospace; + font-size: 12px; + color: rgba(0, 0, 0, 0.7); + white-space: pre-wrap; + word-break: break-word; +} + +.tb-iot-hub-error-details-markdown { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + padding-bottom: 24px; +} + +.tb-iot-hub-install-warning { + color: #b26a00; + background: #fff8e1; + border-left: 3px solid #f5b324; + padding: 8px 12px; + font-size: 13px; + line-height: 18px; + border-radius: 4px; + margin: 0; +} + +.tb-iot-hub-plan-list { + list-style: none; + padding: 0; + margin: 0; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + max-height: 320px; + overflow-y: auto; +} + +.tb-iot-hub-plan-entry { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + + &:last-child { + border-bottom: none; + } +} + +.tb-iot-hub-plan-entry-main { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} + +.tb-iot-hub-plan-entry-name { + font-weight: 500; + color: rgba(0, 0, 0, 0.87); +} + +.tb-iot-hub-plan-entry-type { + font-size: 12px; + color: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, 0.05); + padding: 2px 6px; + border-radius: 10px; +} + +.tb-iot-hub-plan-entry-version { + font-size: 12px; + color: rgba(0, 0, 0, 0.54); +} + +.tb-iot-hub-plan-entry-error { + font-size: 12px; + color: #b26a00; + margin-left: 8px; +} + +.tb-iot-hub-plan-entry-status { + font-size: 12px; + font-weight: 500; + padding: 2px 8px; + border-radius: 10px; + white-space: nowrap; + + &.tb-status-will-install { + color: #1565c0; + background: rgba(21, 101, 192, 0.08); + } + + &.tb-status-installed { + color: #2e7d32; + background: rgba(46, 125, 50, 0.08); + } + + &.tb-status-missing { + color: #b26a00; + background: rgba(245, 179, 36, 0.12); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts new file mode 100644 index 0000000000..43ed8d21b4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts @@ -0,0 +1,401 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { + getInstalledItemUrl, + InstallPlan, + InstallPlanEntry, + InstallPlanEntryStatus, + InstallPlanResult, + IotHubInstalledItemDescriptor, + SolutionTemplateInstalledItemDescriptor +} from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { TranslateService } from '@ngx-translate/core'; +import { EntityType } from '@shared/models/entity-type.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { SolutionInstallDialogComponent } from '@home/components/iot-hub/solution-install-dialog.component'; +import { Observable, of } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { DeviceProfileService } from '@core/http/device-profile.service'; +import { AssetProfileService } from '@core/http/asset-profile.service'; +import { RuleChainService } from '@core/http/rule-chain.service'; +import { RuleChainId } from '@shared/models/id/rule-chain-id'; + +interface SelectEntityConfig { + allowed: EntityType[]; + defaultType: EntityType; + required: boolean; + promptKey?: string; +} + +interface PendingOverwrite { + entityId: EntityId; + profileName: string; + existingRuleChainName: string; +} + +export interface IotHubInstallDialogData { + item: MpItemVersionView; +} + +export type InstallState = + | 'select-entity' + | 'confirm-overwrite' + | 'confirm' + | 'plan' + | 'installing' + | 'success' + | 'partial' + | 'error'; + +@Component({ + selector: 'tb-iot-hub-install-dialog', + standalone: false, + templateUrl: './iot-hub-install-dialog.component.html', + styleUrls: ['./iot-hub-install-dialog.component.scss'] +}) +export class TbIotHubInstallDialogComponent extends DialogComponent { + + ItemType = ItemType; + PlanStatus = InstallPlanEntryStatus; + EntityType = EntityType; + + item: MpItemVersionView; + typeTranslations = itemTypeTranslations; + state!: InstallState; + errorMessage = ''; + entityDetailsUrl: string | null = null; + + selectedEntityId: EntityId | null = null; + pendingOverwrite: PendingOverwrite | null = null; + ruleChainInstallForm!: FormGroup<{ + setAsDefault: FormControl; + entityType: FormControl; + entityId: FormControl; + }>; + + installPlan: InstallPlan | null = null; + planSummary: { willInstall: number; alreadyInstalled: number; missing: number } = { + willInstall: 0, + alreadyInstalled: 0, + missing: 0 + }; + missingEntries: InstallPlanEntry[] = []; + resolvingPlan = false; + + private readonly selectEntityConfig: Partial> = { + [ItemType.CALCULATED_FIELD]: { + allowed: [EntityType.DEVICE, EntityType.ASSET, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE], + defaultType: EntityType.DEVICE_PROFILE, + required: true, + promptKey: 'iot-hub.select-entity-for-cf', + }, + }; + + get activeSelectEntityConfig(): SelectEntityConfig | null { + return this.selectEntityConfig[this.item.type] ?? null; + } + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: IotHubInstallDialogData, + private dialog: MatDialog, + private translate: TranslateService, + private iotHubApiService: IotHubApiService, + private deviceProfileService: DeviceProfileService, + private assetProfileService: AssetProfileService, + private ruleChainService: RuleChainService, + private fb: FormBuilder, + private destroyRef: DestroyRef + ) { + super(store, router, dialogRef); + this.item = data.item; + this.state = this.computeInitialState(); + if (this.item.type === ItemType.RULE_CHAIN) { + this.initRuleChainForm(); + } + } + + private computeInitialState(): InstallState { + return this.activeSelectEntityConfig || this.item.type === ItemType.RULE_CHAIN + ? 'select-entity' + : 'confirm'; + } + + private initRuleChainForm(): void { + this.ruleChainInstallForm = this.fb.group({ + setAsDefault: this.fb.nonNullable.control(false), + entityType: this.fb.nonNullable.control(EntityType.DEVICE_PROFILE), + entityId: this.fb.control(null) + }); + this.ruleChainInstallForm.controls.setAsDefault.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(setAsDefault => { + const entityIdCtrl = this.ruleChainInstallForm.controls.entityId; + if (setAsDefault) { + entityIdCtrl.setValidators(Validators.required); + } else { + entityIdCtrl.clearValidators(); + entityIdCtrl.setValue(null); + } + entityIdCtrl.updateValueAndValidity(); + }); + this.ruleChainInstallForm.controls.entityType.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.ruleChainInstallForm.controls.entityId.setValue(null); + }); + } + + getTypeLabel(): string { + const key = this.typeTranslations.get(this.item.type); + return key ? this.translate.instant(key) : ''; + } + + onEntitySelectInstall(): void { + if (!this.selectedEntityId) { + return; + } + this.install(); + } + + onRuleChainInstall(): void { + const { setAsDefault, entityType, entityId } = this.ruleChainInstallForm.getRawValue(); + if (!setAsDefault) { + this.selectedEntityId = null; + this.install(); + return; + } + if (!entityId) { + return; + } + const profileEntityId: EntityId = { entityType, id: entityId }; + this.selectedEntityId = profileEntityId; + this.resolveOverwrite(profileEntityId).subscribe({ + next: (pending) => { + if (pending) { + this.pendingOverwrite = pending; + this.state = 'confirm-overwrite'; + } else { + this.pendingOverwrite = null; + this.install(); + } + }, + error: (err) => this.handleApiError(err) + }); + } + + confirmOverwriteReplace(): void { + this.install(); + } + + confirmOverwriteCancel(): void { + this.pendingOverwrite = null; + this.state = 'select-entity'; + } + + private resolveOverwrite(profileEntityId: EntityId): Observable { + const lookupProfile$: Observable<{ name: string; defaultRuleChainId: RuleChainId | null }> = + profileEntityId.entityType === EntityType.DEVICE_PROFILE + ? this.deviceProfileService.getDeviceProfile(profileEntityId.id, { ignoreLoading: true }).pipe( + map(p => ({ name: p.name, defaultRuleChainId: p.defaultRuleChainId ?? null })) + ) + : this.assetProfileService.getAssetProfile(profileEntityId.id, { ignoreLoading: true }).pipe( + map(p => ({ name: p.name, defaultRuleChainId: p.defaultRuleChainId ?? null })) + ); + + return lookupProfile$.pipe( + switchMap(profile => { + if (!profile.defaultRuleChainId) { + return of(null); + } + return this.ruleChainService.getRuleChain(profile.defaultRuleChainId.id, { ignoreLoading: true }).pipe( + map(existing => ({ + entityId: profileEntityId, + profileName: profile.name, + existingRuleChainName: existing.name, + })) + ); + }) + ); + } + + install(): void { + // Keep the current state visible (confirm / select-entity / confirm-overwrite) while resolving — + // a transient 'resolving-plan' state caused a visible "blink" for items without dependencies. + this.resolvingPlan = true; + const versionId = this.item.id as string; + this.iotHubApiService.resolveInstallPlan(versionId, { ignoreLoading: true }).subscribe({ + next: (plan) => { + this.resolvingPlan = false; + const summary = this.summarizePlan(plan); + // Show the plan only when there's something worth telling the user about: extra items to + // install, items already installed, or missing dependencies. A lone root with nothing to + // skip installs directly so the dialog doesn't flash a one-line "plan". + const shouldShowPlan = plan.entries.length > 1 + || summary.alreadyInstalled > 0 + || summary.missing > 0; + + if (!shouldShowPlan) { + this.installItem(); + return; + } + + this.installPlan = plan; + this.planSummary = summary; + this.missingEntries = plan.entries.filter(e => e.status === InstallPlanEntryStatus.MISSING); + this.state = 'plan'; + }, + error: (err) => { + this.resolvingPlan = false; + this.handleApiError(err); + } + }); + } + + installItemWithDependencies(): void { + if (!this.installPlan) { + return; + } + this.state = 'installing'; + this.iotHubApiService.installPlan(this.installPlan, this.installData(), { ignoreLoading: true }).subscribe({ + next: (result) => this.handlePlanResult(result), + error: (err) => this.handleApiError(err) + }); + } + + private installItem(): void { + this.state = 'installing'; + const versionId = this.item.id as string; + this.iotHubApiService.installItemVersion(versionId, { ignoreLoading: true }, this.installData()).subscribe({ + next: (result) => { + if (!result.success) { + this.state = 'error'; + this.errorMessage = result.errorMessage || this.translate.instant('iot-hub.install-error', { name: this.item.name }); + return; + } + this.handleInstalledDescriptor(result.descriptor); + }, + error: (err) => this.handleApiError(err) + }); + } + + private summarizePlan(plan: InstallPlan): { willInstall: number; alreadyInstalled: number; missing: number } { + const summary = { willInstall: 0, alreadyInstalled: 0, missing: 0 }; + for (const entry of plan.entries) { + switch (entry.status) { + case InstallPlanEntryStatus.WILL_INSTALL: summary.willInstall++; break; + case InstallPlanEntryStatus.ALREADY_INSTALLED: summary.alreadyInstalled++; break; + case InstallPlanEntryStatus.MISSING: summary.missing++; break; + } + } + return summary; + } + + private handleApiError(err: any): void { + this.state = 'error'; + this.errorMessage = err?.error?.message || err?.message || + this.translate.instant('iot-hub.install-error', { name: this.item.name }); + } + + private handlePlanResult(result: InstallPlanResult): void { + if (!result.success) { + this.state = 'error'; + let message = result.errorMessage || this.translate.instant('iot-hub.install-error', { name: this.item.name }); + // A failed cascade rolls back the items installed so far. When something was actually being + // installed and the rollback came back partial (rolledBack === false), some entities are left + // behind — tell the admin so they know manual cleanup may be needed. The willInstall guard + // avoids the misleading warning on the "empty plan" failure, where nothing was installed. + if (!result.rolledBack && this.planSummary.willInstall > 0) { + message += ' ' + this.translate.instant('iot-hub.install-rollback-partial'); + } + this.errorMessage = message; + return; + } + if (this.installPlan) { + this.installPlan = { ...this.installPlan, entries: result.entries ?? this.installPlan.entries }; + this.missingEntries = (result.entries ?? []).filter(e => e.status === InstallPlanEntryStatus.MISSING); + } + const hasMissing = (result.missingItemIds?.length ?? 0) > 0; + if (result.rootDescriptor) { + this.handleInstalledDescriptor(result.rootDescriptor, hasMissing); + } else { + this.state = hasMissing ? 'partial' : 'success'; + } + } + + private handleInstalledDescriptor(descriptor: IotHubInstalledItemDescriptor, partial = false): void { + if (descriptor?.type === 'SOLUTION_TEMPLATE') { + const timeout = this.item.dataDescriptor?.installTimeoutMs; + const openSolutionDialog = () => { + this.dialogRef.close('installed'); + this.dialog.open(SolutionInstallDialogComponent, { + disableClose: true, + autoFocus: false, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { descriptor: descriptor as SolutionTemplateInstalledItemDescriptor } + }); + }; + if (timeout > 0) { + setTimeout(openSolutionDialog, timeout); + } else { + openSolutionDialog(); + } + return; + } + this.entityDetailsUrl = getInstalledItemUrl(descriptor); + this.state = partial ? 'partial' : 'success'; + } + + private installData(): any | undefined { + return this.selectedEntityId ? { entityId: this.selectedEntityId } : undefined; + } + + cancelPlan(): void { + this.installPlan = null; + this.state = this.computeInitialState(); + } + + openEntityDetails(): void { + if (this.entityDetailsUrl) { + this.dialogRef.close('installed'); + void this.router.navigateByUrl(this.entityDetailsUrl); + } + } + + close(): void { + const installedStates: InstallState[] = ['success', 'partial']; + this.dialogRef.close(installedStates.includes(this.state) ? 'installed' : false); + } + + cancel(): void { + this.dialogRef.close(false); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.html new file mode 100644 index 0000000000..586156ae53 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.html @@ -0,0 +1,41 @@ + +
+
+

+ {{ 'iot-hub.installed-items-for' | translate:{ name: item.name } }} +

+ +
+
+ + +
+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.scss new file mode 100644 index 0000000000..d417ed0ec9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.scss @@ -0,0 +1,74 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-direction: column; + height: 100%; + min-height: 0; +} + +.dlg-header { + border-bottom: 1px solid rgba(0, 0, 0, 0.12); +} + +.dlg-title { + margin: 0; + font-size: 20px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.87); +} + +:host-context(.mat-mdc-dialog-container) { + .mat-mdc-dialog-content { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + padding: 24px; + overflow: hidden; + + tb-iot-hub-installed-items-table { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + width: 100%; + overflow: hidden; + } + + @media #{$mat-gt-xs} { + height: 500px; + } + + @media #{$mat-gt-sm} { + width: 700px; + } + + @media #{$mat-gt-md} { + width: 800px; + } + } + + .mat-mdc-dialog-actions { + border-top: 1px solid rgba(0, 0, 0, 0.12); + padding: 8px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.ts new file mode 100644 index 0000000000..4c0527821c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.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 { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; + +export interface IotHubInstalledItemsDialogData { + item: MpItemVersionView; +} + +@Component({ + selector: 'tb-iot-hub-installed-items-dialog', + standalone: false, + templateUrl: './iot-hub-installed-items-dialog.component.html', + styleUrls: ['./iot-hub-installed-items-dialog.component.scss'] +}) +export class TbIotHubInstalledItemsDialogComponent extends DialogComponent { + + item: MpItemVersionView; + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: IotHubInstalledItemsDialogData + ) { + super(store, router, dialogRef); + this.item = data.item; + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html new file mode 100644 index 0000000000..a64f9ab73e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html @@ -0,0 +1,169 @@ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'iot-hub.item-name' | translate }}{{ item.itemName }}{{ 'iot-hub.item-type' | translate }} + + {{ getItemTypeIcon(item.itemType) }} + {{ getItemTypeLabel(item.itemType) }} + + {{ 'iot-hub.item-version' | translate }}{{ item.version }}{{ 'iot-hub.installed-date' | translate }}{{ item.createdTime | date:'MMM d, y, h:mm a' }}@if (updatesChecked) { {{ 'iot-hub.updates' | translate }} } + @if (updatesChecked) { + @if (getPublishedVersionInfo(item); as pubInfo) { + @if (pubInfo.publishedVersionId !== item.itemVersionId) { +
+ @if (!itemVersion) { + + } + +
+ } @else { + + check + {{ 'iot-hub.up-to-date' | translate }} + + } + } @else { + + check + {{ 'iot-hub.up-to-date' | translate }} + + } + } +
+
+
+
+ +
+ @if (!itemVersion || item.itemType === 'DEVICE') { + + } + + +
+ +
+ + + @if (!itemVersion || item.itemType === 'DEVICE') { + + } + + + +
+
+ + @if (isLoading) { +
+ +
+ } + + @if (!isLoading && dataSource.length === 0) { +
+
+

{{ 'iot-hub.no-installed-items' | translate }}

+

{{ 'iot-hub.no-installed-items-text' | translate }}

+ @if (hasActiveFilters()) { + + } +
+ } +
+ + + + + +
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss new file mode 100644 index 0000000000..400bb5778b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss @@ -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 "../../../../../scss/constants"; + +:host { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +// Table + paginator container — border +.tb-installed-table-container { + border: 1px solid rgba(0, 0, 0, 0.12); +} + +.tb-installed-table-wrapper { + table { + width: 100%; + } + + // Header cells — Design: 16px Medium, rgba(0,0,0,0.87), tracking 0.25 + th.mat-mdc-header-cell { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + + // Body cells — Design: 14px Regular, rgba(0,0,0,0.76), tracking 0.2 + td.mat-mdc-cell { + font-size: 14px; + font-weight: 400; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + } +} + + +// Column sizing +.mat-column-itemName { + width: 50%; + min-width: 200px; + max-width: 50%; +} + +.mat-column-itemType { + width: 50%; + min-width: 50%; + max-width: 50%; +} + +.mat-column-version { + width: 80px; + min-width: 80px; + max-width: 80px; +} + +.mat-column-createdTime { + width: 160px; + min-width: 160px; + max-width: 160px; +} + +.mat-column-updates { + width: 250px; + min-width: 250px; + max-width: 250px; +} + +.mat-column-actions { + width: 108px; + min-width: 108px; + max-width: 108px; +} + +::ng-deep .mat-mdc-paginator-range-label { + white-space: nowrap; +} + +// Nowrap columns +.mat-column-itemType, +.mat-column-createdTime, +.mat-column-updates { + white-space: nowrap; +} + +// Type chip — Design: pill, h-32, rounded-16, px-12 py-6, gap-4, bg at 6% opacity +.tb-installed-type { + display: inline-flex; + align-items: center; + gap: 4px; + height: 32px; + padding: 6px 12px; + border-radius: 16px; + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.2px; +} + +.tb-installed-type-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: inherit !important; + --mat-icon-color: currentColor; +} + +// Colors mirror itemTypeChipColors from the iot-hub mp-item.models. +.tb-type-device { color: #3d4ca6; background: rgba(61, 76, 166, 0.06); } +.tb-type-solution-template { color: #2c6cb4; background: rgba(44, 108, 180, 0.06); } +.tb-type-widget { color: #20733d; background: rgba(46, 166, 88, 0.06); } +.tb-type-calc-field { color: #3db5e0; background: rgba(61, 181, 224, 0.06); } +.tb-type-alarm-rule { color: #d7702f; background: rgba(215, 112, 47, 0.06); } +.tb-type-rule-chain { color: #aa5be3; background: rgba(170, 91, 227, 0.06); } +.tb-type-dashboard { color: #607d8b; background: rgba(96, 125, 139, 0.06); } + +// "vX.X.X Available" button — Design: border rgba(0,0,0,0.12), rounded-4, px-12 py-6, gap-8 +.tb-installed-version-btn { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.76); + background: none; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 6px 12px; + cursor: pointer; + font-family: inherit; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: inherit !important; + --mat-icon-color: currentColor; + } + + &:hover { + background: rgba(0, 0, 0, 0.04); + } +} + +// "Update" button — Design: same height as available btn, border matches text color +.tb-installed-update-btn.mat-mdc-outlined-button { + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + padding: 6px 12px; + height: auto; + --mat-button-outlined-outline-color: var(--mat-button-outlined-label-text-color); +} + +// "Up to date" — Design: 14px Medium, #198038, gap-8, py-6 +.tb-installed-up-to-date { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + color: #198038; + padding: 6px 0; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: inherit !important; + --mat-icon-color: currentColor; + } +} + +// Actions column — icon-button tweaks +.tb-installed-actions { + .mat-mdc-icon-button { + width: 36px; + height: 36px; + padding: 6px; + --mdc-icon-button-state-layer-size: 36px; + --mdc-icon-button-icon-color: rgba(0, 0, 0, 0.54); + color: rgba(0, 0, 0, 0.54); + } +} + +// Empty state — mirrors the browse component's tb-iot-hub-empty-state +.tb-iot-hub-empty-state { + .tb-no-data-bg { + flex: auto; + height: 100px; + } + + h3 { + font-size: 18px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + margin: 16px 0 8px; + } + + p { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); + margin: 0 0 24px; + } +} + +:host(:not(.tb-installed-with-version)) { + @media #{$mat-lt-lg} { + .mat-column-actions { + width: 36px; + min-width: 36px; + max-width: 36px; + } + } +} + +:host(.tb-installed-with-version) { + .mat-column-version { + width: 160px; + min-width: 0; + max-width: 160px; + } + + .mat-column-createdTime { + width: 200px; + min-width: 0; + max-width: 200px; + } + + .mat-column-updates { + min-width: 0; + } + + @media #{$mat-lt-md} { + .mat-column-actions { + width: 36px; + min-width: 36px; + max-width: 36px; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts new file mode 100644 index 0000000000..bb5e1ecd7a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts @@ -0,0 +1,279 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, + ElementRef, + EventEmitter, + HostBinding, + Input, + NgZone, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, + ViewChild +} from '@angular/core'; +import { Router } from '@angular/router'; +import { MatSort } from '@angular/material/sort'; +import { MatPaginator } from '@angular/material/paginator'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { TranslateService } from '@ngx-translate/core'; +import { switchMap } from 'rxjs/operators'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { + DeviceInstalledItemDescriptor, + getInstalledItemUrl, + IotHubInstalledItem, + ItemPublishedVersionInfo +} from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { getItemTypeIcon, ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; + +@Component({ + selector: 'tb-iot-hub-installed-items-table', + standalone: false, + templateUrl: './iot-hub-installed-items-table.component.html', + styleUrls: ['./iot-hub-installed-items-table.component.scss'] +}) +export class TbIotHubInstalledItemsTableComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy { + + @Input() textSearch = ''; + @Input() typeFilters: string[] = []; + @Input() itemVersion: MpItemVersionView; + + @Output() clearFilters = new EventEmitter(); + + hasActiveFilters(): boolean { + return !!this.textSearch || (this.typeFilters?.length ?? 0) > 0; + } + + @HostBinding('class.tb-installed-with-version') get withVersionClass() { + return !!this.itemVersion; + } + + displayedColumns: string[] = ['itemName', 'itemType', 'version', 'createdTime', 'updates', 'actions']; + dataSource: IotHubInstalledItem[] = []; + totalElements = 0; + pageSize = 10; + pageIndex = 0; + hidePageSize = false; + isLoading = false; + + publishedVersionMap = new Map(); + updatesChecked = false; + isCheckingUpdates = false; + + private widgetResize$: ResizeObserver; + + @ViewChild(MatSort, {static: true}) sort: MatSort; + @ViewChild(MatPaginator, {static: true}) paginator: MatPaginator; + + constructor( + private iotHubApiService: IotHubApiService, + private translate: TranslateService, + private router: Router, + private iotHubActions: IotHubActionsService, + private elementRef: ElementRef, + private zone: NgZone, + private cd: ChangeDetectorRef + ) { + this.widgetResize$ = new ResizeObserver(() => { + this.zone.run(() => { + const shouldHide = this.elementRef.nativeElement.offsetWidth < 640; + if (shouldHide !== this.hidePageSize) { + this.hidePageSize = shouldHide; + this.cd.markForCheck(); + } + }); + }); + this.widgetResize$.observe(this.elementRef.nativeElement); + } + + ngOnInit(): void { + if (this.itemVersion) { + this.displayedColumns = ['version', 'createdTime', 'updates', 'actions']; + this.updatesChecked = true; + this.publishedVersionMap.set(this.itemVersion.itemId, { + itemId: this.itemVersion.itemId, + publishedVersionId: this.itemVersion.id, + publishedVersion: this.itemVersion.version + }); + } + this.loadData(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.textSearch || changes.typeFilters) { + if (!changes.textSearch?.firstChange || !changes.typeFilters?.firstChange) { + this.pageIndex = 0; + if (this.paginator) { + this.paginator.pageIndex = 0; + } + this.loadData(); + } + } + } + + ngAfterViewInit(): void { + this.sort.sortChange.subscribe(() => { + this.pageIndex = 0; + this.paginator.pageIndex = 0; + this.loadData(); + }); + } + + ngOnDestroy(): void { + this.widgetResize$?.disconnect(); + } + + onPageChange(event: any): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + this.loadData(); + } + + getItemTypeIcon(itemType: string): string { + return getItemTypeIcon(itemType); + } + + deleteItem(item: IotHubInstalledItem): void { + this.iotHubActions.deleteItem(item).subscribe(confirmed => { + if (confirmed) { + this.loadData(); + } + }); + } + + getItemTypeLabel(itemType: string): string { + const key = itemTypeTranslations.get(itemType as ItemType); + return key ? this.translate.instant(key) : itemType; + } + + getItemTypeChipClass(itemType: string): string { + switch (itemType) { + case 'WIDGET': return 'tb-type-widget'; + case 'DASHBOARD': return 'tb-type-dashboard'; + case 'CALCULATED_FIELD': return 'tb-type-calc-field'; + case 'ALARM_RULE': return 'tb-type-alarm-rule'; + case 'RULE_CHAIN': return 'tb-type-rule-chain'; + case 'DEVICE': return 'tb-type-device'; + case 'SOLUTION_TEMPLATE': return 'tb-type-solution-template'; + default: return ''; + } + } + + viewItemDetails(item: IotHubInstalledItem): void { + if (item.itemType === 'DEVICE') { + this.openDeviceReviewDialog(item); + return; + } + this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true}).subscribe(versionView => { + this.iotHubActions.openItemDetail(versionView, item).subscribe(result => { + if (result === 'updated' || result === 'deleted') { + this.loadData(); + } + }); + }); + } + + private openDeviceReviewDialog(item: IotHubInstalledItem): void { + const descriptor = item.descriptor as DeviceInstalledItemDescriptor; + if (!descriptor.installState || !descriptor.selectedInstallMethod) { + this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true}).subscribe(versionView => { + this.iotHubActions.openItemDetail(versionView, item).subscribe(); + }); + return; + } + this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true, ignoreErrors: true}).subscribe(versionView => { + this.iotHubActions.reviewDevice(versionView, descriptor).subscribe(); + }); + } + + openEntity(item: IotHubInstalledItem): void { + const url = getInstalledItemUrl(item?.descriptor); + if (url) { + const urlTree = this.router.parseUrl(url); + window.open(this.router.serializeUrl(urlTree), '_blank'); + } + } + + checkForUpdates(): void { + this.isCheckingUpdates = true; + this.iotHubApiService.getInstalledItemIds({ ignoreLoading: true }).pipe( + switchMap(itemIds => this.iotHubApiService.getItemsPublishedVersions(itemIds, { ignoreLoading: true })) + ).subscribe({ + next: (infos) => { + this.publishedVersionMap.clear(); + infos.forEach(info => this.publishedVersionMap.set(info.itemId, info)); + this.updatesChecked = true; + this.isCheckingUpdates = false; + }, + error: () => { + this.isCheckingUpdates = false; + } + }); + } + + viewUpdateDetails(publishedInfo: ItemPublishedVersionInfo, installedItem: IotHubInstalledItem): void { + this.iotHubApiService.getVersionInfo(publishedInfo.publishedVersionId, {ignoreLoading: true}).subscribe(versionView => { + this.iotHubActions.openItemDetail(versionView, installedItem).subscribe(result => { + if (result === 'updated' || result === 'deleted') { + this.loadData(); + } + }); + }); + } + + updateItem(item: IotHubInstalledItem, publishedInfo: ItemPublishedVersionInfo): void { + this.iotHubActions.updateItem(item, publishedInfo.publishedVersion, publishedInfo.publishedVersionId).subscribe(result => { + if (result === 'updated') { + this.loadData(); + } + }); + } + + getPublishedVersionInfo(item: IotHubInstalledItem): ItemPublishedVersionInfo | undefined { + return this.publishedVersionMap.get(item.itemId); + } + + private loadData(): void { + this.isLoading = true; + const sortOrder: SortOrder = { + property: this.sort?.active || 'createdTime', + direction: this.sort?.direction === 'asc' ? Direction.ASC : Direction.DESC + }; + const pageLink = new PageLink(this.pageSize, this.pageIndex, this.textSearch || null, sortOrder); + const typeFilters = this.typeFilters?.length ? this.typeFilters : null; + this.iotHubApiService.getInstalledItems(pageLink, typeFilters, this.itemVersion?.itemId, {ignoreLoading: true}).subscribe({ + next: (data) => { + this.dataSource = data.data; + this.totalElements = data.totalElements; + this.isLoading = false; + }, + error: () => { + this.dataSource = []; + this.totalElements = 0; + this.isLoading = false; + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.html new file mode 100644 index 0000000000..dc2030b00a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.html @@ -0,0 +1,152 @@ + +@if (isCompactLayout()) { + +
+
+ {{ getPlaceholderIcon() }} +
+
+ @if (showSubtype && getSubtypeLabel(); as subtypeLabel) { + {{ subtypeLabel }} + } +

{{ item.name }}

+
+ @if (showCreator) { + @if (item.creatorVerified) { + verified + } @else { + person + } + {{ item.creatorDisplayName }} + · + } + download + {{ item.totalInstallCount | shortNumber }} +
+
+ @if (mode === 'add') { + @if (isInstalled()) { + check{{ 'iot-hub.installed' | translate }} + } @else { + + } + } @else if (isInstalled()) { + @if (isSameVersion()) { + check{{ 'iot-hub.installed' | translate }} + @if (hasInstallGuide()) { + + } + + } @else { + + @if (hasInstallGuide()) { + + } + + } + } @else { + + } +
+} @else { + +
+
+ @if (getPreviewUrl(); as previewUrl) { + + } @else { +
+ {{ getPlaceholderIcon() }} +
+ } +
+
+ @if (showSubtype && getSubtypeLabel(); as subtypeLabel) { + {{ subtypeLabel }} + } +

{{ item.name }}

+ +
+
+} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.scss new file mode 100644 index 0000000000..71f3ea051d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.scss @@ -0,0 +1,403 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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: block; + height: 100%; + min-width: 0; +} + +// Card — Design: white bg, border rgba(0,0,0,0.12), rounded-8, overflow-clip, flex-col, gap-16, pb-16 +.tb-iot-hub-item-card { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: box-shadow 0.2s, transform 0.2s; + background: white; + height: 100%; + display: flex; + flex-direction: column; + gap: 16px; + + &:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); + } + +} + +// Preview area — Design: aspect ~305:172, bg rgba(0,0,0,0.04), overflow-clip, p-12, dot pattern bg +.tb-iot-hub-card-preview { + aspect-ratio: 305 / 172; + background: radial-gradient(circle, rgba(0, 0, 0, 0.06) 1px, transparent 1px) 0 0 / 20px 20px rgba(0, 0, 0, 0.04); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + padding: 12px; +} + +.tb-iot-hub-preview-image { + width: 100%; + height: 100%; + object-fit: contain; +} + +.tb-iot-hub-preview-placeholder { + width: 62px; + height: 62px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.06); + + tb-icon { + font-size: 36px; + width: 36px; + height: 36px; + color: rgba(0, 0, 0, 0.38); + } +} + +.tb-iot-hub-pe-badge { + position: absolute; + top: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.6); + color: white; + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} + +// Compact card (CF / RC) — Design: horizontal flex, gap-16, p-16, rounded-8 +.tb-iot-hub-card-compact { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + background: white; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + cursor: pointer; + height: 100%; + transition: box-shadow 0.2s, transform 0.2s; + + &:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); + } +} + +// Compact icon — Design: 72x72, rounded-6, colored bg, white icon 36px centered +.tb-iot-hub-compact-icon { + width: 72px; + height: 72px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + tb-icon { + font-size: 36px; + width: 36px; + height: 36px; + color: #fff; + } +} + +// Compact content — Design: flex-1, flex-col, gap-8 +.tb-iot-hub-compact-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +// Compact name — Design: 16px Medium, line-height 24, tracking 0.25, ellipsis +.tb-iot-hub-compact-name { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// Compact meta row — Design: flex row, gap-8, items-center +.tb-iot-hub-compact-meta { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); +} + +.tb-iot-hub-compact-author-icon { + font-size: 16px; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.tb-iot-hub-verified-icon { + color: #00695c; +} + +.tb-iot-hub-compact-creator { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.clickable { + cursor: pointer; + + &:hover { + color: $tb-primary-color; + text-decoration: underline; + } + } +} + +.tb-iot-hub-compact-dot { + margin: 0 4px; + flex-shrink: 0; +} + +.tb-iot-hub-compact-download-icon { + font-size: 12px; + width: 12px; + height: 12px; + flex-shrink: 0; +} + +.tb-iot-hub-compact-count { + font-weight: 500; + text-transform: uppercase; +} + +// Subtype label — Design: 12px Medium uppercase, rgba(0,0,0,0.54), tracking 0.25 +.tb-iot-hub-card-subtype { + font-size: 12px; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.25px; + text-transform: uppercase; + color: rgba(0, 0, 0, 0.54); +} + +// Name — Design: 16px Medium, line-height 24, tracking 0.25, ellipsis +.tb-iot-hub-card-name { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: rgba(0, 0, 0, 0.87); +} + + +// Footer — Design: flex, justify-between, no border-top, inline with content +.tb-iot-hub-card-footer { + display: flex; + align-items: center; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: auto; + + // Author icon — Design: person icon, 16px + .tb-iot-hub-card-author-icon { + font-size: 16px; + width: 16px; + height: 16px; + flex-shrink: 0; + margin-right: 4px; + } + + .tb-iot-hub-card-creator { + overflow: hidden; + text-overflow: ellipsis; + + &.clickable { + cursor: pointer; + + &:hover { + color: $tb-primary-color; + text-decoration: underline; + } + } + } + + // Dot separator — Design: 3px circle, rgba(0,0,0,0.38) + .tb-iot-hub-card-dot { + margin: 0 8px; + flex-shrink: 0; + } + + .tb-iot-hub-card-stats { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + flex-shrink: 0; + } + + // Download stat — Design: download icon 12px, count 12px Medium uppercase + .tb-iot-hub-card-stat { + display: flex; + align-items: center; + gap: 2px; + font-size: 12px; + font-weight: 500; + color: rgba(0, 0, 0, 0.54); + text-transform: uppercase; + margin-right: 8px; + + tb-icon { + font-size: 12px; + width: 12px; + height: 12px; + } + } + +} + +// Install button — Design: border rgba(0,0,0,0.12), rounded-4, px-16 py-6, text #00695c 14px Medium +.tb-iot-hub-card-install-btn { + padding: 6px 16px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: transparent; + color: $tb-primary-color; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: background 0.15s; + + &:hover { + background: rgba($tb-primary-color, 0.04); + } +} + +// Installed chip — Design: pill, bg rgba(25,128,56,0.06), check 18px + "Installed" 14px Regular, #198038 +.tb-iot-hub-card-installed-chip { + display: inline-flex; + align-items: center; + gap: 4px; + height: 32px; + padding: 6px 12px 6px 8px; + border-radius: 16px; + background: rgba(25, 128, 56, 0.06); + color: #198038; + font-size: 14px; + font-weight: 400; + line-height: 20px; + white-space: nowrap; + flex-shrink: 0; + + tb-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +%tb-iot-hub-card-icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 4px; + border: none; + background: none; + cursor: pointer; + flex-shrink: 0; + border-radius: 50%; + transition: background 0.15s; + + &:hover { + background: rgba(0, 0, 0, 0.04); + } +} + +// Guide icon button +.tb-iot-hub-card-guide-btn { + @extend %tb-iot-hub-card-icon-btn; + + tb-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: rgba(0, 0, 0, 0.54); + } +} + +// Remove icon button — Design: trash-can-outline 24px, rgba(0,0,0,0.54) +.tb-iot-hub-card-remove-btn { + @extend %tb-iot-hub-card-icon-btn; + + tb-icon { + font-size: 24px; + width: 24px; + height: 24px; + color: rgba(0, 0, 0, 0.54); + } +} + +.tb-iot-hub-card-update-btn { + padding: 6px 16px; + border-radius: 4px; + border: 1px solid #f57c00; + background: transparent; + color: #f57c00; + font-size: 14px; + font-weight: 500; + line-height: 20px; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: background 0.15s; + + &:hover { + background: rgba(245, 124, 0, 0.04); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts new file mode 100644 index 0000000000..2d9c13d8d7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts @@ -0,0 +1,159 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, Output } from '@angular/core'; +import { MpItemVersionView, cfTypeTranslations, cfTypeIcons, ruleChainTypeTranslations, widgetTypeTranslations } from '@shared/models/iot-hub/iot-hub-version.models'; +import { getItemTypeIcon, ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { TranslateService } from '@ngx-translate/core'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; + +@Component({ + selector: 'tb-iot-hub-item-card', + standalone: false, + templateUrl: './iot-hub-item-card.component.html', + styleUrls: ['./iot-hub-item-card.component.scss'] +}) +export class TbIotHubItemCardComponent { + + readonly ItemType = ItemType; + + @Input() item: MpItemVersionView; + @Input() installedItem: IotHubInstalledItem; + @Input() installedItemsCount = 0; + @Input() showCreator = true; + @Input() showTypeChip = true; + @Input() showSubtype = false; + @Input() mode: 'default' | 'add' = 'default'; + @Output() cardClick = new EventEmitter(); + @Output() creatorClick = new EventEmitter(); + @Output() installClick = new EventEmitter(); + @Output() updateClick = new EventEmitter(); + @Output() deleteClick = new EventEmitter(); + @Output() addClick = new EventEmitter(); + @Output() guideClick = new EventEmitter(); + + constructor( + private translate: TranslateService, + private iotHubApiService: IotHubApiService + ) {} + + isCompactLayout(): boolean { + return this.item.type === ItemType.CALCULATED_FIELD + || this.item.type === ItemType.ALARM_RULE + || this.item.type === ItemType.RULE_CHAIN; + } + + getPreviewUrl(): string | null { + if (!this.item.image) { + return null; + } + const [path, query] = this.item.image.split('?'); + const withPreview = path.endsWith('/preview') ? path : `${path}/preview`; + const resolved = query ? `${withPreview}?${query}` : withPreview; + return this.iotHubApiService.resolveResourceUrl(resolved); + } + + getPlaceholderIcon(): string { + switch (this.item.type) { + case ItemType.CALCULATED_FIELD: + return this.item.icon + || cfTypeIcons.get(this.item.dataDescriptor?.cfType) + || getItemTypeIcon(ItemType.CALCULATED_FIELD); + case ItemType.ALARM_RULE: + return this.item.icon || getItemTypeIcon(ItemType.ALARM_RULE); + case ItemType.RULE_CHAIN: + return this.item.icon + || (this.item.dataDescriptor?.ruleChainType === 'EDGE' + ? 'router' + : (this.item.dataDescriptor?.ruleChainType === 'CORE' + ? 'device_hub' + : getItemTypeIcon(ItemType.RULE_CHAIN))); + default: + return getItemTypeIcon(this.item.type); + } + } + + getSubtypeLabel(): string { + switch (this.item.type) { + case ItemType.WIDGET: { + const wt = this.item.dataDescriptor?.widgetType; + const key = wt ? widgetTypeTranslations.get(wt) : null; + return key ? this.translate.instant(key) : wt || ''; + } + case ItemType.CALCULATED_FIELD: + case ItemType.ALARM_RULE: { + const cfType = this.item.dataDescriptor?.cfType; + const key = cfType ? cfTypeTranslations.get(cfType) : null; + return key ? this.translate.instant(key) : cfType || ''; + } + case ItemType.RULE_CHAIN: { + const rcType = this.item.dataDescriptor?.ruleChainType; + const key = rcType ? ruleChainTypeTranslations.get(rcType) : null; + return key ? this.translate.instant(key) : rcType || ''; + } + default: + return ''; + } + } + + isInstalled(): boolean { + return !!this.installedItem; + } + + isSameVersion(): boolean { + return this.installedItem?.itemVersionId === this.item.id; + } + + onCardClick(): void { + this.cardClick.emit(this.item); + } + + onInstallClick(event: MouseEvent): void { + event.stopPropagation(); + this.installClick.emit(this.item); + } + + onAddClick(event: MouseEvent): void { + event.stopPropagation(); + this.addClick.emit(this.item); + } + + onUpdateClick(event: MouseEvent): void { + event.stopPropagation(); + this.updateClick.emit(this.item); + } + + onDeleteClick(event: MouseEvent): void { + event.stopPropagation(); + this.deleteClick.emit(this.item); + } + + hasInstallGuide(): boolean { + return !!this.installedItem && this.installedItem.itemType === 'DEVICE'; + } + + onGuideClick(event: MouseEvent): void { + event.stopPropagation(); + this.guideClick.emit(this.item); + } + + onCreatorClick(event: Event): void { + event.stopPropagation(); + this.creatorClick.emit(this.item.creatorId); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html new file mode 100644 index 0000000000..e42f1521aa --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html @@ -0,0 +1,402 @@ + +
+ +
+ @if (isCompactLayout()) { +
+ {{ getCompactIcon() }} +
+ } +
+ {{ item.name }} +
+ + {{ getTypeIcon() }} + {{ typeTranslations.get(item.type) | translate }} + + + + download + {{ item.totalInstallCount | shortNumber }} {{ 'iot-hub.installs' | translate }} + + + + update + v {{ item.version }} + + @if (preview) { + + + warning + {{ 'iot-hub.unpublished-preview' | translate }} + + } + @if (item.publishedTime) { + + + today + {{ item.publishedTime | date:'mediumDate' }} + + } +
+
+
+ @if (isInstalled()) { + @if (hasUpdate()) { + + } @else { + + check + {{ 'iot-hub.installed' | translate }} v{{ installedItem.version }} + + } + } + +
+
+ + + + +
+ +
+ @if (isCompactLayout()) { + +
+
+ @if (item.description) { +
+ +
+ } +
+
+ } @else { + +
+
+ @if (item.type === ItemType.SOLUTION_TEMPLATE && carouselImages.length > 1) { + + } @else { + @if (getPreviewUrl(); as url) { + + } @else { +
+ {{ getTypeIcon() }} +
+ } + } +
+
+
+ @if (item.description) { +
+ +
+ } +
+
+
+ } +
+ + +
+
+
+ @if (showCreator) { +
+ {{ 'iot-hub.creator' | translate }} +
+
+ @if (getCreatorAvatarUrl(); as avatarUrl) { + + } @else { + person + } + @if (item.creatorVerified) { + verified + } +
+ {{ item.creatorDisplayName }} +
+
+ } + @if (getSubtypeLabel(); as subtypeLabel) { +
+ {{ 'iot-hub.type' | translate }} +
+ {{ subtypeLabel }} +
+
+ } + @if (item.type === ItemType.DEVICE) { + @if (item.dataDescriptor?.hardwareType) { +
+ {{ 'iot-hub.hardware-type' | translate }} +
+ {{ item.dataDescriptor.hardwareType }} +
+
+ } + @if (item.dataDescriptor?.connectivity?.length) { +
+ {{ 'iot-hub.connectivity' | translate }} +
+ @for (conn of item.dataDescriptor.connectivity; track conn) { + {{ conn }} + } +
+
+ } + } @else { + @if (item.categories?.length) { +
+ {{ 'iot-hub.category' | translate }} +
+ @for (cat of item.categories; track cat) { + {{ cat }} + } +
+
+ } + } + @if (item.useCases?.length) { +
+ {{ 'iot-hub.use-cases' | translate }} +
+ @for (uc of item.useCases; track uc) { + {{ uc }} + } +
+
+ } + @if ((isInstalled() || installedItemsCount > 0) && mode !== 'add') { +
+ {{ 'iot-hub.manage' | translate }} +
+ @if (item.type === ItemType.SOLUTION_TEMPLATE) { + + + } @else { + @if (installedItemsCount > 0) { + + } @else { + + } + } +
+
+ } + @if (hasDetails()) { +
+ {{ 'iot-hub.details' | translate }} + +
+ } +
+
+
+ + + @if (item.type === ItemType.RULE_CHAIN && getNodes().length) { +
+ @for (node of getNodes(); track node.name) { + {{ node.name }} + } +
+
+ } + + + @if (readmeContent || shouldShowChangelog()) { +
+ @if (readmeContent) { + + } + @if (shouldShowChangelog()) { +

{{ 'iot-hub.changelog' | translate }}

+ + } +
+ } +
+ + + +
+ + +
+ {{ 'iot-hub.details' | translate }} +
+ @if (getSubtypeLabel(); as subtypeLabel) { +
+ {{ 'iot-hub.type' | translate }} +
+ {{ subtypeLabel }} +
+
+ } + @if (item.type === ItemType.DEVICE) { + @if (item.dataDescriptor?.hardwareType) { +
+ {{ 'iot-hub.hardware-type' | translate }} +
+ {{ item.dataDescriptor.hardwareType }} +
+
+ } + @if (item.dataDescriptor?.connectivity?.length) { +
+ {{ 'iot-hub.connectivity' | translate }} +
+ @for (conn of item.dataDescriptor.connectivity; track conn) { + {{ conn }} + } +
+
+ } + } @else { + @if (item.categories?.length) { +
+ {{ 'iot-hub.category' | translate }} +
+ @for (cat of item.categories; track cat) { + {{ cat }} + } +
+
+ } + } + @if (item.useCases?.length) { +
+ {{ 'iot-hub.use-cases' | translate }} +
+ @for (uc of item.useCases; track uc) { + {{ uc }} + } +
+
+ } +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss new file mode 100644 index 0000000000..5b7a182a01 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss @@ -0,0 +1,690 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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"; + +// Dialog container +.tb-iot-hub-detail-dialog { + display: flex; + flex-direction: column; + background: white; + border-radius: 8px; + overflow: hidden; + max-height: 80vh; + width: 1200px; + + @media #{$mat-lt-xxl} { + width: 900px; + } + + @media #{$mat-lt-lg} { + width: 768px; + } + + @media #{$mat-lt-md} { + width: 100%; + } + + @media #{$mat-xs} { + max-height: 100%; + height: 100%; + } +} + +// Header — Design: pl-24, pr-12, py-16, gap between icon+content=16, content+close=8 +.dlg-header { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 16px 12px 16px 24px; + flex-shrink: 0; + +} + +.dlg-close-btn-desktop { + @media #{$mat-lt-sm} { + display: none; + } +} + +.dlg-close-btn-mobile { + position: absolute; + top: 16px; + right: 12px; + @media #{$mat-gt-xs} { + display: none; + } +} + +// Header icon for CF/RC — Design: 64px, rounded-8, colored bg, white icon 36px +.dlg-header-icon { + width: 64px; + height: 64px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + tb-icon { + font-size: 36px; + width: 36px; + height: 36px; + color: #fff; + } +} + +// Title — Design: 24px Medium, rgba(0,0,0,0.87) +.dlg-title { + font-size: 24px; + font-weight: 500; + line-height: 32px; + color: rgba(0, 0, 0, 0.87); +} + +// Subtitle row — Design: 13px Regular, rgba(0,0,0,0.54), gap-12 between groups +.dlg-subtitle { + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; + line-height: 16px; + letter-spacing: 0.4px; + color: rgba(0, 0, 0, 0.54); + flex-wrap: wrap; +} + +.dlg-subtitle-group { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.dlg-subtitle-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: rgba(0, 0, 0, 0.54); +} + +// Dot separator — Design: 3px circle, rgba(0,0,0,0.38) +.dlg-dot { + width: 3px; + height: 3px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.38); + flex-shrink: 0; +} + +// Scrollable body +.dlg-body { + overflow-y: auto; + overflow-x: hidden; + flex: 1; + min-height: 0; +} + +// Main content — Design: px-24, pb-16 +.dlg-main { + padding: 0 24px 16px; +} + +// Preview image — Design: flex-1, aspect 305:172, bg rgba(0,0,0,0.06), rounded-8, p-16, dot pattern +.dlg-preview { + flex: 1; + min-width: 0; + aspect-ratio: 305 / 172; + background: radial-gradient(circle, rgba(0, 0, 0, 0.06) 1px, transparent 1px) 0 0 / 20px 20px rgba(0, 0, 0, 0.06); + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + + &.dlg-preview-carousel { + aspect-ratio: 1.5; + } +} + +.dlg-preview-img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.dlg-preview-placeholder { + tb-icon { + font-size: 48px; + width: 48px; + height: 48px; + color: rgba(0, 0, 0, 0.2); + } +} + + +// Description — strip padding from tb-iot-hub-markdown so the body +// sits flush with the surrounding info layout. +.dlg-description { + ::ng-deep tb-iot-hub-markdown .tb-markdown-view { + padding: 0; + } +} + +// Author — Design: person icon 16px + name 12px Medium $tb-primary-color +.dlg-author { + display: flex; + align-items: center; + gap: 4px; + + tb-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: rgba(0, 0, 0, 0.54); + } + + span { + font-size: 12px; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.25px; + color: $tb-primary-color; + } + + &.clickable { + cursor: pointer; + + &:hover span { + text-decoration: underline; + } + } +} + +// Rule Chain node chips — Design: colored pill chips in a row, px-24 py-16 +.dlg-nodes { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 16px 24px; +} + +// Node chips — Design: pill shape, 1px border at full color, bg at 16% opacity, black text +.dlg-node-chip { + display: inline-flex; + align-items: center; + height: 32px; + padding: 6px 12px; + border-radius: 16px; + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.2px; + white-space: nowrap; + border: 1px solid; + color: rgba(0, 0, 0, 0.76); + + &.dlg-node-filter { background: rgba(241, 232, 97, 0.16); border-color: rgb(241, 232, 97); } + &.dlg-node-enrichment { background: rgba(205, 241, 78, 0.16); border-color: rgb(205, 241, 78); } + &.dlg-node-transformation { background: rgba(121, 206, 241, 0.16); border-color: rgb(121, 206, 241); } + &.dlg-node-action { background: rgba(241, 146, 143, 0.16); border-color: rgb(241, 146, 143); } + &.dlg-node-analytics { background: rgba(49, 210, 204, 0.16); border-color: rgb(49, 210, 204); } + &.dlg-node-external { background: rgba(251, 199, 102, 0.16); border-color: rgb(251, 199, 102); } + &.dlg-node-flow { background: rgba(214, 196, 241, 0.16); border-color: rgb(214, 196, 241); } + &.dlg-node-unknown { background: rgba(0, 0, 0, 0.04); border-color: rgba(0, 0, 0, 0.12); } +} + +// Divider +.dlg-divider { + height: 1px; + background: rgba(0, 0, 0, 0.12); +} + +.dlg-meta-bar { + position: sticky; + top: 0; + z-index: 1; + background: white; +} + +// Meta section (categories + use cases) — Design: px-24, py-16, gap-24, grid row +.dlg-meta { + display: grid; + gap: 24px; + padding: 16px 24px 12px; + grid-template-columns: minmax(0, max-content) minmax(0, max-content) minmax(auto, max-content) minmax(auto, 1fr) max-content; + + &.dlg-meta-4-col { + grid-template-columns: minmax(0, max-content) minmax(auto, max-content) minmax(auto, 1fr) max-content; + } + + .dlg-meta-details { + display: none; + } + + .dlg-meta-creator-section { + min-width: 0; + } + + @media #{$mat-lt-md} { + grid-template-columns: minmax(0, 1fr) max-content max-content; + + &.dlg-meta-4-col { + grid-template-columns: minmax(0, 1fr) max-content max-content; + } + + .dlg-meta-details-section { + display: none; + } + + .dlg-meta-details { + display: flex; + } + } +} + +.dlg-details-popover { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + + .dlg-details-popover-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + + .dlg-details-popover-body { + display: flex; + flex-direction: column; + gap: 8px; + } +} + +.dlg-meta-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dlg-meta-creator { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + + &.clickable { + cursor: pointer; + } +} + +.dlg-meta-creator-avatar { + position: relative; + width: 32px; + height: 32px; + flex-shrink: 0; + + img { + width: 32px; + height: 32px; + border-radius: 9999px; + object-fit: cover; + } + + mat-icon:not(.dlg-meta-creator-verified) { + font-size: 32px; + width: 32px; + height: 32px; + color: rgba(0, 0, 0, 0.38); + } + + .dlg-meta-creator-verified { + position: absolute; + top: -6px; + left: 18px; + font-size: 20px; + width: 20px; + height: 20px; + color: #00695c; + } +} + +.dlg-meta-creator-name { + font-size: 12px; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.76); + white-space: nowrap; +} + +// Meta label — Design: 12px Medium, rgba(0,0,0,0.54), tracking 0.25 +.dlg-meta-label { + font-size: 12px; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dlg-meta-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 8px 0; +} + +.dlg-meta-manage { + margin-left: auto; + flex-shrink: 0; +} + +.dlg-meta-manage-actions { + display: flex; + align-items: center; +} + +.dlg-manage-icon-btn.mat-mdc-icon-button { + width: 40px; + height: 40px; + padding: 8px; + --mdc-icon-button-state-layer-size: 40px; + color: rgba(0, 0, 0, 0.54); +} + +.dlg-manage-remove-btn { + color: rgba(0, 0, 0, 0.54); +} + +// Chip — Design: 12px Medium, h-24, p-4 px-8, rounded-4 +.dlg-chip { + display: inline-flex; + align-items: center; + height: 24px; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.25px; + white-space: nowrap; +} + +.dlg-chip.dlg-chip-inline { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; +} + +// Category chip — Design: bg rgba(16,108,200,0.06), color #106cc8 +.dlg-chip-category { + background: rgba(16, 108, 200, 0.06); + color: #106cc8; +} + +// Type chip — Design: bg rgba(0,105,92,0.06), color #00695c +.dlg-chip-type { + background: rgba(0, 105, 92, 0.06); + color: #00695c; +} + +// Use case chip — Design: bg rgba(25,128,56,0.06), color #198038 +.dlg-chip-usecase { + background: rgba(25, 128, 56, 0.06); + color: #198038; +} + +// Readme content — wraps tb-iot-hub-markdown, which carries the +// markdown-view styles. Only the changelog heading layout lives here. +.dlg-readme { + > h3 { + padding-left: 24px; + } +} + +// Carousel (solution templates) +.dlg-hero-carousel { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + animation: dlg-carousel-fade-in 0s 0.3s forwards; + visibility: hidden; +} + +@keyframes dlg-carousel-fade-in { + to { visibility: visible; } +} + +.dlg-carousel { + height: 100%; + + ::ng-deep .carousel { + display: flex !important; + flex-direction: column !important; + flex: 1; + + > section { flex: 1; min-height: 0; } + + ul.indicators { display: none !important; } + .direction { display: none !important; } + } + + .dlg-carousel-container { + display: flex; + height: 100%; + + .dlg-carousel-item { + position: relative; + overflow: hidden; + + img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + } + } + } +} + +.dlg-carousel-hidden-nav { + display: none; +} + +.dlg-carousel-controls { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + padding-top: 16px; +} + +.dlg-carousel-dots { + display: flex; + align-items: center; + justify-content: center; + flex: 1; +} + +.dlg-carousel-nav { + width: 40px; + height: 40px; + background: white; + color: rgba(0, 0, 0, 0.76); + flex-shrink: 0; + + &:hover { background: rgba(245, 245, 245, 1); } +} + +.dlg-carousel-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.38); + margin: 9px 12px; + cursor: pointer; + transition: background 0.2s; + + &.active { background: rgba(0, 0, 0, 0.76); } +} + +// Info panel — Design: flex-col, space-between, py-16, gap-32 +.dlg-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 32px; + padding: 16px 0; + + &.dlg-info-compact { + justify-content: flex-start; + padding: 0; + } +} + +// Text container — Design: flex-col, gap-16 +.dlg-info-text { + display: flex; + flex-direction: column; + gap: 16px; +} + +// Installed status row — Design: flex, justify-between, full width +.dlg-info-status-row { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +// Installed badge — Design: pill, bg rgba(25,128,56,0.06), rounded-16, px-8 py-4 +.dlg-installed-badge { + display: inline-flex; + align-items: center; + flex-shrink: 0; + gap: 4px; + padding: 4px 8px; + border-radius: 16px; + background: rgba(25, 128, 56, 0.06); + font-size: 12px; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.4px; + color: #198038; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } +} + +// Update button — Design: border #ff5722, text #ff5722, rounded-4, px-16 py-6 +.dlg-update-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 6px 16px; + border: 1px solid #ff5722; + border-radius: 4px; + background: transparent; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + color: #ff5722; + white-space: nowrap; + cursor: pointer; + + &:hover { + background: rgba(255, 87, 34, 0.04); + } +} + +// Open details / Solution instructions link — Design: 12px Medium, $tb-primary-color +.dlg-info-link { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.25px; + color: $tb-primary-color; + cursor: pointer; + text-decoration: none; + border: none; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &:hover { + text-decoration: underline; + } +} + +// Solution template links row +.dlg-info-links-row { + display: flex; + align-items: center; + gap: 16px; +} + +// Footer +.dlg-footer { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border-top: 1px solid rgba(0, 0, 0, 0.12); + flex-shrink: 0; +} + +.dlg-footer-spacer { + flex: 1; +} + +// Responsive +@media #{$mat-xs} { + .dlg-header { + flex-direction: column; + position: relative; + padding-right: 48px; + } +} + +.dlg-subtitle-group.tb-unpublished-preview-badge { + color: #d32f2f; + font-weight: 600; + + .dlg-subtitle-icon { + color: #d32f2f; + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts new file mode 100644 index 0000000000..04d49b7f86 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts @@ -0,0 +1,283 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, Type } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { MpItemVersionView, cfTypeTranslations, cfTypeIcons, ruleChainTypeTranslations, widgetTypeTranslations, NodeInfo } from '@shared/models/iot-hub/iot-hub-version.models'; +import { getItemTypeIcon, ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { getInstalledItemUrl, IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { TranslateService } from '@ngx-translate/core'; +import { SolutionInstallDialogComponent } from '@home/components/iot-hub/solution-install-dialog.component'; +import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; + +export type IotHubItemDetailDialogMode = 'default' | 'add'; + +export interface IotHubItemDetailDialogData { + item: MpItemVersionView; + installedItem?: IotHubInstalledItem; + installedItemsCount?: number; + mode?: IotHubItemDetailDialogMode; + showCreator?: boolean; + preview?: boolean; +} + +@Component({ + selector: 'tb-iot-hub-item-detail-dialog', + standalone: false, + templateUrl: './iot-hub-item-detail-dialog.component.html', + styleUrls: ['./iot-hub-item-detail-dialog.component.scss'] +}) +export class TbIotHubItemDetailDialogComponent extends DialogComponent { + + readonly ItemType = ItemType; + item: MpItemVersionView; + mode: IotHubItemDetailDialogMode; + showCreator: boolean; + preview: boolean; + typeTranslations = itemTypeTranslations; + readmeContent: string = ''; + installedItem?: IotHubInstalledItem; + installedItemsCount = 0; + carouselImages: string[] = []; + carouselIndex = 0; + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: IotHubItemDetailDialogData, + private dialog: MatDialog, + private translate: TranslateService, + private iotHubApiService: IotHubApiService, + private iotHubActions: IotHubActionsService + ) { + super(store, router, dialogRef); + this.item = data.item; + this.mode = data.mode || 'default'; + this.showCreator = data.showCreator !== false; + this.preview = data.preview === true; + this.installedItem = data.installedItem; + this.installedItemsCount = data.installedItemsCount || 0; + this.buildCarouselImages(); + this.loadReadme(); + } + + isCompactLayout(): boolean { + return this.item.type === ItemType.CALCULATED_FIELD + || this.item.type === ItemType.ALARM_RULE + || this.item.type === ItemType.RULE_CHAIN; + } + + getPreviewUrl(): string | null { + return this.item.image ? this.iotHubApiService.resolveResourceUrl(this.item.image) : null; + } + + getCreatorAvatarUrl(): string | null { + return this.item.creatorAvatarUrl ? this.iotHubApiService.resolveResourceUrl(this.item.creatorAvatarUrl) : null; + } + + getTypeLabel(): string { + const key = this.typeTranslations.get(this.item.type); + return key ? this.translate.instant(key) : ''; + } + + getTypeIcon(): string { + return getItemTypeIcon(this.item.type); + } + + getCompactIcon(): string { + if (this.item.icon) { + return this.item.icon; + } + if (this.item.type === ItemType.CALCULATED_FIELD) { + const cfType = this.item.dataDescriptor?.cfType; + return cfTypeIcons.get(cfType) || getItemTypeIcon(ItemType.CALCULATED_FIELD); + } + if (this.item.type === ItemType.ALARM_RULE) { + return getItemTypeIcon(ItemType.ALARM_RULE); + } + switch (this.item.dataDescriptor?.ruleChainType) { + case 'CORE': return 'device_hub'; + case 'EDGE': return 'router'; + default: return getItemTypeIcon(ItemType.RULE_CHAIN); + } + } + + getCompactSubtypeLabel(): string { + if (this.item.type === ItemType.CALCULATED_FIELD || this.item.type === ItemType.ALARM_RULE) { + const cfType = this.item.dataDescriptor?.cfType; + const key = cfType ? cfTypeTranslations.get(cfType) : null; + return key ? this.translate.instant(key) : cfType || ''; + } + const rcType = this.item.dataDescriptor?.ruleChainType; + const key = rcType ? ruleChainTypeTranslations.get(rcType) : null; + return key ? this.translate.instant(key) : rcType || ''; + } + + getSubtypeLabel(): string { + switch (this.item.type) { + case ItemType.CALCULATED_FIELD: + case ItemType.ALARM_RULE: + case ItemType.RULE_CHAIN: + return this.getCompactSubtypeLabel(); + case ItemType.WIDGET: { + const wt = this.item.dataDescriptor?.widgetType; + const key = wt ? widgetTypeTranslations.get(wt) : null; + return key ? this.translate.instant(key) : ''; + } + default: + return ''; + } + } + + getNodes(): NodeInfo[] { + return this.item.dataDescriptor?.nodes || []; + } + + isInstalled(): boolean { + return this.installedItem != null; + } + + shouldShowChangelog(): boolean { + const changelog = this.item.changelog?.trim(); + return !!changelog && changelog !== 'Initial release'; + } + + hasUpdate(): boolean { + return this.installedItem != null + && this.installedItem.itemVersionId !== this.item.id; + } + + install(): void { + this.iotHubActions.installItem(this.item).subscribe(result => { + if (result === 'installed') { + this.dialogRef.close('installed'); + } + }); + } + + installDevice(): void { + this.iotHubActions.installDevice(this.item).subscribe(result => { + if (result === 'installed') { + this.dialogRef.close('installed'); + } + }); + } + + updateItem(): void { + this.iotHubActions.updateItem(this.installedItem, this.item.version, this.item.id as string).subscribe(result => { + if (result === 'updated') { + this.dialogRef.close('updated'); + } + }); + } + + navigateToCreator(): void { + this.dialogRef.close(); + void this.router.navigate(['/iot-hub/creator', this.item.creatorId]); + } + + openEntityDetails(): void { + const url = getInstalledItemUrl(this.installedItem?.descriptor); + if (url) { + this.dialogRef.close(); + void this.router.navigateByUrl(url); + } + } + + openSolutionInstructions(): void { + const descriptor = this.installedItem?.descriptor; + if (descriptor?.type === 'SOLUTION_TEMPLATE') { + this.dialog.open(SolutionInstallDialogComponent, { + disableClose: true, + autoFocus: false, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + descriptor: descriptor as SolutionTemplateInstalledItemDescriptor, + instructions: true + } + }); + } + } + + deleteItem(): void { + this.iotHubActions.deleteItem(this.installedItem).subscribe(confirmed => { + if (confirmed) { + this.dialogRef.close('deleted'); + } + }); + } + + addItem(): void { + this.dialogRef.close({ action: 'add', item: this.item }); + } + + openInstalledItemsDialog(): void { + this.iotHubActions.openInstalledItems(this.item).subscribe(); + } + + hasDetails(): boolean { + if (this.getSubtypeLabel()) { + return true; + } + if (this.item.useCases?.length) { + return true; + } + if (this.item.type === ItemType.DEVICE) { + return !!this.item.dataDescriptor?.hardwareType || !!this.item.dataDescriptor?.connectivity?.length; + } + return !!this.item.categories?.length; + } + + goToPrevSlide(): void { + this.carouselIndex = (this.carouselIndex - 1 + this.carouselImages.length) % this.carouselImages.length; + } + + goToNextSlide(): void { + this.carouselIndex = (this.carouselIndex + 1) % this.carouselImages.length; + } + + close(): void { + this.dialogRef.close(); + } + + private buildCarouselImages(): void { + if (this.item.type !== ItemType.SOLUTION_TEMPLATE || !this.item.resources?.length) { + return; + } + const screenshotResources = this.item.resources.filter(r => r.type === 'SCREENSHOT'); + const allResources = screenshotResources.length > 0 + ? screenshotResources + : this.item.resources.filter(r => r.type === 'ICON'); + this.carouselImages = allResources.map(r => + this.iotHubApiService.resolveResourceUrl(`/api/resources/${r.id}`) + ); + } + + private loadReadme(): void { + const versionId = this.item.id as string; + this.iotHubApiService.getVersionReadme(versionId, { ignoreLoading: true }).subscribe( + content => this.readmeContent = content + ); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html new file mode 100644 index 0000000000..fbf00c7dd7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html @@ -0,0 +1,70 @@ + +@switch (state) { + @case ('loading') { + + } + @case ('loaded') { + + @if (isCompact()) { + + } @else { + + } + + + } + @case ('unavailable') { + + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss new file mode 100644 index 0000000000..cdf4005199 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss @@ -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. + */ + +:host { + display: block; + margin: 12px 0; +} + +.tb-iot-hub-item-link-card { + display: inline-flex; + align-items: center; + gap: 12px; + width: 360px; + max-width: 100%; + padding: 8px 12px; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 8px; + background: #fff; + text-decoration: none; + color: inherit; + transition: background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease; + + &:hover:not(.tb-iot-hub-item-link-card--unavailable):not(.tb-iot-hub-item-link-card--skeleton) { + background: rgba(4, 138, 211, 0.04); + border-color: rgba(4, 138, 211, 0.32); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + .tb-iot-hub-item-link-name, .tb-iot-hub-item-link-author { + text-decoration: underline; + } + } + + &--unavailable { + opacity: 0.6; + cursor: default; + } + + &--skeleton { + cursor: default; + } +} + +.tb-iot-hub-item-link-thumb { + flex-shrink: 0; + width: 99px; + height: 56px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: rgba(0, 0, 0, 0.04); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + } + + &--compact { + width: 56px; + height: 56px; + border: none; + border-radius: 4.667px; + color: #fff; + } + + &--unavailable { + width: 56px; + height: 56px; + border: none; + color: rgba(0, 0, 0, 0.45); + } +} + +.tb-iot-hub-item-link-thumb-icon { + font-size: 28px; + width: 28px; + height: 28px; + line-height: 28px; + color: #fff; +} + +.tb-iot-hub-item-link-thumb-fallback { + font-size: 28px; + width: 28px; + height: 28px; + color: rgba(0, 0, 0, 0.45); +} + +.tb-iot-hub-item-link-text { + display: flex; + flex-direction: column; + min-width: 0; + gap: 2px; +} + +.tb-iot-hub-item-link-name { + font-size: 14px; + font-weight: 500; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tb-iot-hub-item-link-author { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: rgba(0, 0, 0, 0.6); + line-height: 1.3; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + // Verified-creator accent — matches the item card chip color. + .tb-iot-hub-item-link-verified-icon { + color: #00695c; + } +} + +.tb-iot-hub-item-link-skeleton-block, +.tb-iot-hub-item-link-skeleton-line { + background: linear-gradient( + 90deg, + rgba(0, 0, 0, 0.06) 0%, + rgba(0, 0, 0, 0.12) 50%, + rgba(0, 0, 0, 0.06) 100% + ); + background-size: 200% 100%; + animation: tb-iot-hub-item-link-shimmer 1.4s ease-in-out infinite; + border-radius: 4px; +} + +.tb-iot-hub-item-link-skeleton-line { + display: block; + height: 12px; + width: 180px; + margin: 4px 0; + + &--short { + width: 96px; + } +} + +@keyframes tb-iot-hub-item-link-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts new file mode 100644 index 0000000000..743f02bea4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts @@ -0,0 +1,88 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 } from '@angular/core'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { getItemTypeIcon, ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; + +type CardState = 'loading' | 'loaded' | 'unavailable'; + +const COMPACT_TYPES: ReadonlySet = new Set([ + ItemType.CALCULATED_FIELD, + ItemType.ALARM_RULE, + ItemType.RULE_CHAIN +]); + +@Component({ + selector: 'tb-iot-hub-item-link-card', + standalone: false, + templateUrl: './iot-hub-item-link-card.component.html', + styleUrls: ['./iot-hub-item-link-card.component.scss'] +}) +export class TbIotHubItemLinkCardComponent implements OnInit { + + @Input() itemId!: string; + + state: CardState = 'loading'; + item: MpItemVersionView | null = null; + + constructor(private iotHubApiService: IotHubApiService) {} + + ngOnInit(): void { + if (!this.itemId) { + this.state = 'unavailable'; + return; + } + this.iotHubApiService + .getPublishedVersion(this.itemId, { ignoreErrors: true, ignoreLoading: true }) + .subscribe({ + next: item => { + this.item = item; + this.state = item ? 'loaded' : 'unavailable'; + }, + error: () => { + this.state = 'unavailable'; + } + }); + } + + isCompact(): boolean { + return !!this.item && COMPACT_TYPES.has(this.item.type); + } + + getImageUrl(): string | null { + const item = this.item!; + return item.image ? this.iotHubApiService.resolveResourceUrl(item.image) : null; + } + + getCompactIcon(): string { + const item = this.item!; + return item.icon || getItemTypeIcon(item.type); + } + + getTypeIcon(): string { + return getItemTypeIcon(this.item!.type); + } + + getCompactColor(): string { + return this.item!.color || '#048ad3'; + } + + getHref(): string { + return `/iot-hub/${this.itemId}`; + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts new file mode 100644 index 0000000000..24571271ff --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts @@ -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. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { TbIotHubItemLinkCardComponent } from './iot-hub-item-link-card.component'; + +@NgModule({ + declarations: [TbIotHubItemLinkCardComponent], + imports: [CommonModule, SharedModule], + exports: [TbIotHubItemLinkCardComponent] +}) +export class IotHubItemLinkModule {} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.html similarity index 63% rename from ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.html rename to ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.html index 51eebbf7ed..a7c2d3bd04 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.html @@ -15,9 +15,11 @@ limitations under the License. --> -
- - {{ 'widget.' + type.type | translate }} - -
+ + diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss new file mode 100644 index 0000000000..be2ab69a76 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss @@ -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. + */ + +$text-color: rgba(0,0,0,0.76); +$link-color: #2a7dec; +$copy-button-color: #2a7dec; + +$code-block-border-color: rgba(42, 125, 236, .2); +$code-default-color: #212529; +$code-text-color: #eb5757; +$code-keyword-color: #2a7dec; + +$table-border-color: rgba(42, 125, 236, .2); +$table-header-background-color: #f9fbff; +$table-header-text-color: rgba(33, 37, 41, .6); +$table-text-color: rgba(15, 22, 29, 0.8); + +$gallery-border-hover-color: #2a7dec; + +:host { + display: block; + font-size: 14px; + line-height: 24px; + letter-spacing: 0.2px; + color: $text-color; + + --mat-button-outlined-label-text-color: #{$text-color}; + + ::ng-deep tb-markdown .tb-markdown-view { + padding: 16px 24px 24px; + &.table-wrapper { + padding: 0; + } + &:not(.table-wrapper) { + > :first-child { + margin-top: 0; + } + } + + // Headings: design 20px SemiBold, line-height 24, tracking 0.1. + h1, h2, h3, h4, h5, h6 { + font-size: 20px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.1px; + color: $text-color; + margin: 0; + padding: 0; + } + + > h1, > h2, > h3, > h4, > h5, > h6 { + padding: 0 0 6px; + margin-top: 20px; + } + + h1 { + font-size: 24px; + line-height: 32px; + padding-right: 0; + } + + // Paragraphs: design 14px Regular, tracking 0.2, line-height 24. + p { + font-size: 14px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.2px; + color: $text-color; + margin: 0; + } + + > p, > div { + padding-left: 0; + padding-right: 0; + } + + p + p { + margin-top: 8px; + } + + h1 + p, h2 + p, h3 + p, + h1 + ul, h2 + ul, h3 + ul, + h1 + ol, h2 + ol, h3 + ol { + margin-top: 8px; + } + + // Lists: design ms-21 per li, 14px, line-height 24. + ul, ol { + padding-left: 21px; + padding-right: 0; + margin: 0; + font-size: 14px; + line-height: 24px; + letter-spacing: 0.2px; + color: $text-color; + + + h1, + h2, + h3, + h4, + h5, + h6 { + padding-top: 0; + margin-top: 20px; + } + } + + ul { + margin-top: 8px; + margin-bottom: 8px; + } + + li { + padding-bottom: 4px; + margin-bottom: 0; + line-height: 24px; + } + + img { + max-width: 100%; + } + + a:not(.mdc-button):not(.tb-iot-hub-item-link-card) { + font-weight: 500; + color: $link-color; + text-decoration: none; + border: none; + + &:hover { + color: $link-color; + text-decoration: underline; + border: none; + } + } + + code:not([class*=language-]) { + color: $code-text-color; + font-family: monospace; + font-size: 14px; + } + + // Code-wrapper + clipboard button + div.code-wrapper { + position: relative; + + button.clipboard-btn { + pointer-events: none; + outline: none; + position: absolute; + width: 206px; + height: 32px; + top: 0; + right: 0; + background: 0 0; + border: none; + user-select: none; + + &.multiline { + right: 6px; + } + + p { + padding: 6px 8px 0; + top: 1px; + transition: .2s; + color: $copy-button-color; + background: rgba(255, 255, 255, .85); + backdrop-filter: blur(4px); + opacity: 0; + font-weight: 500; + right: 32px; + position: absolute; + } + + div { + background-color: #fff; + position: absolute; + width: 38px; + height: 28px; + top: 3px; + right: 3px; + padding: 8px 10px 0; + + img { + position: initial; + width: 18px; + height: 18px; + filter: invert(51%) sepia(6%) saturate(172%) hue-rotate(177deg) brightness(94%) contrast(92%); + } + } + } + + &:hover { + cursor: pointer; + + pre[class*="language-"] { + border: solid 1px $copy-button-color; + } + + button.clipboard-btn { + p { + opacity: 1; + } + + div img { + filter: invert(49%) sepia(97%) saturate(3730%) hue-rotate(200deg) brightness(95%) contrast(95%); + } + } + } + } + + th, td { + div.code-wrapper { + display: inline-block; + width: 100%; + + button.clipboard-btn { + top: -6px; + padding: 0 3px; + } + } + } + + // Code blocks (Prism) + pre[class*="language-"] { + font-size: 14px; + border: 1px solid $code-block-border-color; + border-radius: 4px; + background: 0 0; + padding: 8px 16px; + color: $code-default-color; + + .token.atrule, .token.attr-value, .token.keyword { + color: $code-keyword-color; + } + + .token.selector, .token.attr-name, .token.string, .token.char, .token.builtin, .token.inserted { + color: $code-text-color; + } + + .token.punctuation { + color: $code-default-color; + } + + &.line-numbers { + padding-left: 66px; + + > code span.line-numbers-rows { + top: -11px; + bottom: -11px; + left: -66px; + width: 50px; + border: none; + padding: 8px 12px 8px 18px; + text-align: right; + background: #f9fbff; + + > span:before { + color: rgba(33, 37, 41, .6); + padding-right: 0; + } + } + + &.no-line-numbers { + padding-left: 16px; + + > code span.line-numbers-rows { + display: none; + } + } + } + } + + // Tables + > table { + width: 100%; + border: 1px solid $table-border-color; + border-radius: 4px; + border-collapse: unset; + border-spacing: 0; + margin-top: 20px; + margin-bottom: 20px; + overflow: hidden; + table-layout: fixed; + + &.auto { + table-layout: auto; + } + + > thead { + background-color: $table-header-background-color; + color: $table-header-text-color; + + > tr > th { + border-bottom: 1px solid $table-border-color; + font-size: 14px; + padding: 8px 12px; + text-align: left; + margin: 0; + @media screen and (max-width: 400px) { + font-size: 12px; + padding: 8px 4px; + code:not([class*=language-]) { + font-size: 12px; + } + } + } + } + + > tbody { + > tr:not(:last-child) > td { + border-bottom: 1px solid $table-border-color; + } + + > tr > td { + font-size: 14px; + padding: 8px 12px; + text-align: left; + margin: 0; + color: $table-text-color; + @media screen and (max-width: 400px) { + font-size: 12px; + padding: 8px 4px; + code:not([class*=language-]) { + font-size: 12px; + } + } + } + } + + th, td { + font-size: 14px; + padding: 8px; + margin: 0; + text-align: left; + } + + td[align=center], th[align=center] { + text-align: center; + } + + td[align=right], th[align=right] { + text-align: right; + } + + tr td div { + padding-left: 0; + padding-right: 0; + } + } + + .tb-callout { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 16px; + border-radius: 4px; + border: 1px solid; + margin: 12px 0; + font-size: 14px; + line-height: 20px; + + .tb-callout-icon { + font-size: 20px; + flex-shrink: 0; + } + + &.tb-callout-note { + background: #e3f2fd; + border-color: #90caf9; + color: #1565c0; + } + + &.tb-callout-warn { + background: #fff8e1; + border-color: #ffe082; + color: #f57f17; + } + + &.tb-callout-error { + background: #fce4ec; + border-color: #ef9a9a; + color: #c62828; + } + } + + .tb-gallery-images { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + margin-top: 8px; + margin-bottom: 8px; + + .tb-gallery-image { + position: relative; + cursor: pointer; + border: 2px solid #c1c3c8; + border-radius: 8px; + overflow: hidden; + margin: 0; + padding: 0; + background: #fff; + transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease; + display: block; + &:hover { + border-color: $gallery-border-hover-color; + transform: translateY(-2px); + box-shadow: 0 6px 18px #0000002e; + outline: none; + .tb-image-tooltip { + opacity: 1; + transform: translateY(0); + } + } + .tb-image-container { + display: block; + height: 115px; + overflow: hidden; + background: #fff; + img { + width: 100%; + max-width: 100%; + height: 100%; + object-fit: contain; + object-position: top center; + display: block; + margin: 0; + padding: 0; + } + } + .tb-image-tooltip { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: .4rem .6rem; + background: #000000b8; + color: #fff; + font-size: .75rem; + line-height: 1.3; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0; + transform: translateY(4px); + transition: opacity .2s ease, transform .2s ease; + pointer-events: none; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts new file mode 100644 index 0000000000..a63d17c914 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts @@ -0,0 +1,239 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, + Type +} from '@angular/core'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { + escapeHtmlAttr, + replaceItemLinkPlaceholders, + resolveDocLinkPlaceholders, + sanitizeInlineHtml +} from '@home/components/iot-hub/iot-hub-markdown.utils'; +import { DevicePackageInfo } from '@shared/models/iot-hub/device-package.models'; +import { isNotEmptyStr } from '@core/utils'; + +@Component({ + selector: 'tb-iot-hub-markdown', + standalone: false, + templateUrl: './iot-hub-markdown.component.html', + styleUrls: ['./iot-hub-markdown.component.scss'] +}) +export class TbIotHubMarkdownComponent implements OnInit, OnChanges { + + @Input() data: string | undefined; + + @Input() item: MpItemVersionView | undefined; + + @Input() packageInfo: DevicePackageInfo | undefined; + + @Input() imageMap: Map | undefined; + + @Input() onResolveVariable: (key: string) => string | undefined = () => undefined; + + @Input() + @coerceBoolean() + lineNumbers = false; + + @Input() + @coerceBoolean() + fallbackToPlainMarkdown = false; + + @Input() + codeBlockMaxHeightPx: number; + + @Output() ready = new EventEmitter(); + + additionalStyles: string[] = []; + + parsedData: string; + + readonly itemLinkCompileModules: Type[] = [IotHubItemLinkModule]; + + constructor( + private iotHubApiService: IotHubApiService, + private elementRef: ElementRef + ) {} + + ngOnInit(): void { + if (this.codeBlockMaxHeightPx) { + const codeBlockMaxHeightStyle = `pre[class*="language-"] \n{ + max-height: ${this.codeBlockMaxHeightPx}px;\n + }`; + this.additionalStyles.push(codeBlockMaxHeightStyle); + } + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (propName === 'data' && change.currentValue !== change.previousValue) { + this.parsedData = this.parseData(this.data); + } + } + } + + onReady() { + const container = this.elementRef.nativeElement; + this.ready.emit(container); + } + + private parseData(content: string | undefined): string { + let parsed = this.prefixResourceUrls(content || ''); + parsed = this.resolveDocLinks(parsed); + parsed = replaceItemLinkPlaceholders(parsed); + parsed = this.resolveImages(parsed); + parsed = this.resolveVariables(parsed); + parsed = this.forceLinksOpenInNewTab(parsed); + return parsed; + } + + // Ensure every link rendered through tb-markdown opens in a new tab. + // 1) For markdown links `[text](url)` — append the + // `{:target="_blank"}` suffix to the link TEXT (not the URL) + // so MarkedOptionsService.renderer.link, which checks + // `token.text.endsWith(targetBlankBlock)`, emits + // `target="_blank"` on the rendered . Skip links whose + // text already ends with the suffix and skip image syntax + // (`![alt](url)`). + // 2) For raw HTML anchors authored in the markdown — add + // `target="_blank"` when no `target=` attribute is already + // present. + private forceLinksOpenInNewTab(content: string): string { + const TARGET_BLANK_BLOCK = '{:target="_blank"}'; + content = content.replace( + /(? + text.endsWith(TARGET_BLANK_BLOCK) ? match : `[${text}${TARGET_BLANK_BLOCK}](${url})` + ); + content = content.replace( + /]*)>/gi, + (match, attrs: string) => + /\btarget\s*=/i.test(attrs) ? match : `` + ); + return content; + } + + private prefixResourceUrls(markdown: string): string { + const baseUrl = this.iotHubApiService.baseUrl; + return markdown.replace(/([("])(\/api\/resources\/[^)"]*)/g, `$1${baseUrl}$2`); + } + + private resolveDocLinks(markdown: string): string { + if (this.item || this.packageInfo) { + const dd = this.item ? this.item.dataDescriptor : this.packageInfo; + return resolveDocLinkPlaceholders( + markdown, + this.item?.name || dd?.name || '', + { productURL: dd?.productURL, datasheetURL: dd?.datasheetURL }, + { productPage: 'Product page', datasheet: 'Datasheet' } + ); + } else { + return markdown; + } + } + + private resolveImages(content: string): string { + if (this.imageMap) { + return content.replace(/!\[([^\]]*)]\(([^)]+)\)/g, (match, alt, path) => { + if (path.startsWith('data:') || path.startsWith('http')) { + return match; + } + const dataUri = this.imageMap.get(path); + return dataUri ? `![${alt}](${dataUri})` : match; + }); + } else { + return content; + } + } + + private resolveImage(uri: string): string { + if (this.imageMap && uri) { + if (uri.startsWith('data:') || uri.startsWith('http')) { + return uri; + } + const dataUri = this.imageMap.get(uri); + return dataUri ? dataUri : uri; + } else { + return uri; + } + } + + private resolveVariables(content: string): string { + // Image gallery is handled first because its inner ${...} contents + // may include nested braces and span multiple lines, which the + // generic ${key} regex below cannot parse. + // + // Format: ${images.gallery({src: 'p1', alt: 'a1', caption: 'c1'}, {src: 'p2'}, ...)} + // Each image entry is a JS object literal with a required `src` + // and optional `alt` / `caption` string fields. + content = content.replace(/\$\{\s*images\.gallery\(([\s\S]*?)\)\s*}/g, (_match, inner: string) => { + const objects: string[] = inner.match(/\{[\s\S]*?}/g) || []; + const items = objects + .map((obj: string) => { + const src = (obj.match(/src\s*:\s*(['"])((?:(?!\1).)*)\1/) || [])[2] || ''; + const alt = (obj.match(/alt\s*:\s*(['"])((?:(?!\1).)*)\1/) || [])[2] || ''; + const caption = (obj.match(/caption\s*:\s*(['"])((?:(?!\1).)*)\1/) || [])[2] || ''; + return { src: this.resolveImage(src), alt, caption }; + }) + .filter((item: { src?: string }) => !!item.src); + const images = items + .map(item => { + let galleryImageHtml = ``; + return galleryImageHtml; + }) + .join(''); + return ``; + }); + + return content.replace(/\$\{([^}]+)}/g, (_match, key) => { + // Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} + const calloutMatch = key.match(/^(note|warn|error)\((.+)\)$/s); + if (calloutMatch) { + const type = calloutMatch[1]; + const text = calloutMatch[2]; + const icons: Record = { note: 'info_outline', warn: 'warning_amber', error: 'error_outline' }; + return `
${icons[type]}${text}
`; + } + + // Special variables + const res = this.onResolveVariable(key); + if (res) { + return res; + } + return '${' + key + '}'; + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts new file mode 100644 index 0000000000..6db27ce352 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts @@ -0,0 +1,124 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +export const ITEM_LINK_PLACEHOLDER_REGEX = + /\$\{item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})}/g; + +export function itemLinkCardTag(itemId: string): string { + return ``; +} + +export function replaceItemLinkPlaceholders(markdown: string): string { + if (!markdown) { + return markdown; + } + return markdown.replace(ITEM_LINK_PLACEHOLDER_REGEX, (_match, uuid) => itemLinkCardTag(uuid)); +} + +export interface DocLinks { + productURL?: string; + datasheetURL?: string; +} + +export interface DocLinkLabels { + productPage: string; + datasheet: string; +} + +export function resolveDocLinkPlaceholders( + markdown: string, + name: string, + links: DocLinks, + labels: DocLinkLabels +): string { + return markdown + .replace(/\$\{product\.button}/g, () => + links.productURL ? buildDocLinkButton(links.productURL, `${name} ${labels.productPage}`, 'open_in_new') : '') + .replace(/\$\{datasheet\.button}/g, () => + links.datasheetURL ? buildDocLinkButton(links.datasheetURL, `${name} ${labels.datasheet}`, 'description') : ''); +} + +function buildDocLinkButton(url: string, text: string, icon: string): string { + const safeUrl = escapeHtmlAttr(url); + const safeText = escapeHtml(text); + return `
` + + `${icon}${safeText}`; +} + +export function escapeHtml(value: string): string { + return value.replace(/[&<>]/g, ch => ch === '&' ? '&' : ch === '<' ? '<' : '>'); +} + +export function escapeHtmlAttr(value: string): string { + return value.replace(/[&<>"']/g, ch => { + switch (ch) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + case '"': return '"'; + default: return '''; + } + }); +} + +// Inline-only tags + a small attribute whitelist that are safe to keep +// in caption strings (or other small bits of user-authored HTML inside +// generated markdown). Anything else is dropped — disallowed tags are +// replaced by their text content and disallowed attributes are removed. +const SAFE_INLINE_TAGS: ReadonlySet = new Set([ + 'B', 'STRONG', 'I', 'EM', 'U', 'S', 'MARK', + 'SMALL', 'SUB', 'SUP', 'BR', 'CODE', 'SPAN' +]); + +const SAFE_INLINE_ATTRS: ReadonlySet = new Set(['class', 'style']); + +export function sanitizeInlineHtml(value: string): string { + if (!value) { + return ''; + } + const doc = new DOMParser().parseFromString(`
${value}
`, 'text/html'); + const root = doc.body.firstElementChild as HTMLElement | null; + if (!root) { + return ''; + } + const sanitize = (parent: Element): void => { + const children = Array.from(parent.childNodes); + for (const node of children) { + if (node.nodeType !== Node.ELEMENT_NODE) { + continue; + } + const el = node as Element; + if (!SAFE_INLINE_TAGS.has(el.tagName)) { + // Replace disallowed elements with their plain text content. + parent.replaceChild(doc.createTextNode(el.textContent || ''), el); + continue; + } + for (const attr of Array.from(el.attributes)) { + if (!SAFE_INLINE_ATTRS.has(attr.name)) { + el.removeAttribute(attr.name); + continue; + } + if (attr.name === 'style' && /(expression\s*\(|javascript:|url\s*\()/i.test(attr.value)) { + el.removeAttribute(attr.name); + } + } + sanitize(el); + } + }; + sanitize(root); + return root.innerHTML; +} + diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-pe-required-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-pe-required-dialog.component.html new file mode 100644 index 0000000000..57e98e4cbb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-pe-required-dialog.component.html @@ -0,0 +1,40 @@ + +
+ + + + +

+ {{ 'iot-hub.pe-required-title' | translate }} +

+ +

+ +
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-pe-required-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-pe-required-dialog.component.scss new file mode 100644 index 0000000000..8e5f0ad2e6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-pe-required-dialog.component.scss @@ -0,0 +1,77 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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: block; + width: 500px; + max-width: 100%; +} + +.tb-iot-hub-pe-required { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 20px; + padding: 40px; + background: white; + border-radius: 8px; +} + +.tb-iot-hub-pe-required-close { + position: absolute; + top: 8px; + right: 8px; +} + +// SVG illustration applied as a mask so the silhouette is filled +// with $tb-primary-color. PE builds inherit their own primary value +// without re-exporting the asset. +.tb-iot-hub-pe-required-icon { + display: block; + width: 100px; + height: 100px; + background-color: $tb-primary-color; + -webkit-mask: url('/assets/iot-hub/upgrade-required.svg') no-repeat center / contain; + mask: url('/assets/iot-hub/upgrade-required.svg') no-repeat center / contain; +} + +.tb-iot-hub-pe-required-title { + font-size: 24px; + font-weight: 500; + line-height: 32px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.87); + margin: 0; +} + +.tb-iot-hub-pe-required-message { + font-size: 14px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.76); + margin: 0; +} + +.tb-iot-hub-pe-required-actions { + display: flex; + align-items: center; + gap: 8px; +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-pe-required-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-pe-required-dialog.component.ts new file mode 100644 index 0000000000..640c8fad20 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-pe-required-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 } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; + +@Component({ + selector: 'tb-iot-hub-pe-required-dialog', + standalone: false, + templateUrl: './iot-hub-pe-required-dialog.component.html', + styleUrls: ['./iot-hub-pe-required-dialog.component.scss'] +}) +export class TbIotHubPeRequiredDialogComponent extends DialogComponent { + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef + ) { + super(store, router, dialogRef); + } + + close(): void { + this.dialogRef.close(); + } + + upgradeInstance(): void { + window.open('https://thingsboard.io/docs/pe/installation/upgrade-from-ce/', '_blank'); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html new file mode 100644 index 0000000000..5a74f045fe --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html @@ -0,0 +1,158 @@ + +
+ + + {{ 'iot-hub.results-count' | translate:{ count: totalElements } }} + +
+ +
+
+ + @if (searchText) { + + } @else { + + } +
+ + + + @for (option of sortOptions; track option.value; let i = $index) { + + } + +
+ + +
+ + {{ 'iot-hub.results-count' | translate:{ count: totalElements } }} + + @if (totalElements > 0) { +
+
+ + @for (p of getPageNumbers(); track p) { + + } + +
+
+ {{ 'iot-hub.items-per-page' | translate }}: + + @for (size of pageSizeOptions; track size) { + {{ size }} + } + +
+
+ } +
+ +
+ +
+ +
+ + @if (hasError) { +
+
+

{{ 'iot-hub.network-server-unavailable' | translate }}

+

{{ 'iot-hub.network-server-unavailable-text' | translate }}

+ +
+ } + + + @if (!hasError && totalElements === 0 && searchText?.trim()) { +
+
+

{{ 'iot-hub.no-items-found' | translate }}

+

{{ 'iot-hub.try-adjusting-search' | translate }}

+
+ } + + + @if (resultGroups.length > 0) { +
+ @for (group of resultGroups; track group.type) { +
+ + + +
+ @for (item of group.items; track item.id) { + + + } +
+
+ } +
+ } +
+ + + @if (isLoading) { +
+
+ +
+
+ } + +
+ diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.scss new file mode 100644 index 0000000000..de87e32c0f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.scss @@ -0,0 +1,284 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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"; +@import "./iot-hub-common"; + +:host { + display: flex; + flex-direction: column; +} + +.tb-search-results-count-mobile { + display: none; + margin-left: auto; +} + +.tb-search-input-field { + @include tb-iot-hub-page-search-container; +} + +.tb-search-input { + @include tb-iot-hub-page-search-input; +} + +.tb-search-input-action { + @include tb-iot-hub-page-search-action; +} + +.tb-search-sort-btn { + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + margin-left: auto; +} + +.tb-search-sort-btn-icon { + display: none; +} + +// Results count + Pagination row +.tb-search-pagination-row { + flex: 0 0 48px; + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 12px; +} + +.tb-search-results-count { + font-size: 14px; + font-weight: 500; + line-height: 20px; + color: rgba(0, 0, 0, 0.76); + white-space: nowrap; +} + +.tb-search-pagination { + display: flex; + align-items: center; + gap: 24px; +} + +.tb-search-page-btn { + min-width: 40px; + height: 40px; + border: none; + background: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + cursor: pointer; + padding: 8px 16px; + font-family: inherit; + + &:hover { + background: rgba(0, 0, 0, 0.04); + } + + &.active { + background: $tb-primary-color; + color: white; + } +} + +.tb-search-page-size { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + margin-left: auto; +} + +.tb-search-page-size-select { + width: 77px; + height: 40px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 6px 12px; + + ::ng-deep .mat-mdc-select-trigger { + font-size: 14px; + } +} + +// Empty state no-data-bg + text +.tb-search-empty { + .tb-no-data-bg, .tb-no-service-bg { + flex: auto; + height: 100px; + } + + h3 { + font-size: 18px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + margin: 16px 0 8px; + } + + p { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); + margin: 0 0 24px; + } +} + +// Divider before categories +.tb-search-divider { + background: rgba(0, 0, 0, 0.12); + height: 1px; + min-height: 1px; + margin-top: 20px; +} + +.tb-results-container { + position: relative; + height: 100%; + padding-top: 20px; + overflow: auto; +} + +// Categories / sections +.tb-search-categories { + display: flex; + flex-direction: column; + gap: 48px; +} + +// Section header +.tb-search-section-header { + font-size: 18px; + font-weight: 500; + line-height: 24px; + color: rgba(0, 0, 0, 0.87); + padding: 0; + margin-bottom: 12px; + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + } +} + +// Card grid +.tb-search-card-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 20px; + + &.tb-search-card-grid-compact { + grid-template-columns: repeat(3, 1fr); + } +} + +// Responsive + +@media #{$mat-gt-xxl} { + .tb-search-card-grid { + grid-template-columns: repeat(6, 1fr); + + &.tb-search-card-grid-compact { + grid-template-columns: repeat(4, 1fr); + } + } +} + +@media #{$mat-lt-xl} { + .tb-search-card-grid { + grid-template-columns: repeat(4, 1fr); + + &.tb-search-card-grid-compact { + grid-template-columns: repeat(3, 1fr); + } + } +} + +@media #{$mat-lt-xmd} { + .tb-search-card-grid { + grid-template-columns: repeat(3, 1fr); + + &.tb-search-card-grid-compact { + grid-template-columns: repeat(2, 1fr); + } + } +} + +@media #{$mat-lt-lg} { + .tb-search-pagination-row { + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 12px; + } + + .tb-search-results-count { + order: 1; + } + + .tb-search-pagination { + order: 0; + width: 100%; + } + + .tb-search-card-grid { + grid-template-columns: repeat(2, 1fr); + + &.tb-search-card-grid-compact { + grid-template-columns: repeat(1, 1fr); + } + } +} + +@media #{$mat-lt-md} { + .tb-search-results-count-mobile { + display: inline; + } + + .tb-search-pagination-row .tb-search-results-count { + display: none; + } +} + +@media #{$mat-lt-sm} { + .tb-search-sort-btn-full { + display: none; + } + + .tb-search-sort-btn-icon { + display: inline-flex; + } + + .tb-search-card-grid { + grid-template-columns: repeat(1, 1fr); + } + + .tb-search-pagination { + flex-direction: column; + align-items: center; + gap: 16px; + } + + .tb-search-page-size { + margin-left: 0; + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts new file mode 100644 index 0000000000..64ff558723 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts @@ -0,0 +1,356 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, OnInit, OnDestroy, Input, Output, EventEmitter } from '@angular/core'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { forkJoin, Subject, Subscription } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { IotHubActionsService } from './iot-hub-actions.service'; + +interface SearchResultGroup { + type: ItemType; + items: MpItemVersionView[]; +} + +interface SortOption { + value: string; + label: string; + direction: Direction; +} + +const TYPE_ORDER: ItemType[] = [ + ItemType.DEVICE, ItemType.SOLUTION_TEMPLATE, ItemType.WIDGET, + ItemType.CALCULATED_FIELD, ItemType.ALARM_RULE, ItemType.RULE_CHAIN +]; + +@Component({ + selector: 'tb-iot-hub-search', + standalone: false, + templateUrl: './iot-hub-search.component.html', + styleUrls: ['./iot-hub-search.component.scss'] +}) +export class TbIotHubSearchComponent implements OnInit, OnDestroy { + + readonly ItemType = ItemType; + + @Input() searchText = ''; + @Input() creatorId: string; + @Input() showCreator = true; + @Output() searchTextChange = new EventEmitter(); + + get searchPlaceholderKey(): string { + return this.creatorId ? 'iot-hub.search-published-items' : 'iot-hub.search'; + } + + resultGroups: SearchResultGroup[] = []; + totalElements = 0; + isLoading = false; + hasError = false; + private retryTimer: any = null; + + pageSize = 15; + pageIndex = 0; + pageSizeOptions = [15, 30, 60]; + + sortOptions: SortOption[] = [ + { value: 'totalInstallCount', label: 'iot-hub.sort-most-installed', direction: Direction.DESC }, + { value: 'publishedTime', label: 'iot-hub.sort-newest', direction: Direction.DESC }, + { value: 'name', label: 'iot-hub.sort-name', direction: Direction.ASC } + ]; + selectedSortIndex = 0; + + installedWidgets: IotHubInstalledItem[] = []; + installedSolutionTemplates: IotHubInstalledItem[] = []; + installedDeviceCounts: Record = {}; + installedCalcFieldCounts: Record = {}; + installedAlarmRuleCounts: Record = {}; + installedRuleChainCounts: Record = {}; + + private searchSubject = new Subject(); + private searchSubscription: Subscription; + + constructor( + private router: Router, + private translate: TranslateService, + private iotHubApiService: IotHubApiService, + private iotHubActions: IotHubActionsService + ) {} + + ngOnInit(): void { + this.loadInstalledItems(); + this.searchSubscription = this.searchSubject.pipe( + debounceTime(300), + distinctUntilChanged() + ).subscribe(() => { + this.pageIndex = 0; + this.loadResults(); + }); + this.loadResults(); + } + + ngOnDestroy(): void { + this.searchSubscription?.unsubscribe(); + } + + onSearchInput(): void { + this.searchTextChange.emit(this.searchText); + this.searchSubject.next(this.searchText || ''); + } + + clearSearch(): void { + this.searchText = ''; + this.searchTextChange.emit(this.searchText); + this.loadResults(); + } + + onSearchEnter(): void { + this.loadResults(); + } + + onSortChange(index: number): void { + this.selectedSortIndex = index; + this.pageIndex = 0; + this.loadResults(); + } + + // Pagination + get totalPages(): number { + return Math.ceil(this.totalElements / this.pageSize) || 0; + } + + getPageNumbers(): number[] { + const total = this.totalPages; + if (total <= 5) { + return Array.from({length: total}, (_, i) => i); + } + const pages: number[] = []; + const start = Math.max(0, this.pageIndex - 2); + const end = Math.min(total - 1, start + 4); + if (end - start < 4) { + const adjustedStart = Math.max(0, end - 4); + for (let i = adjustedStart; i <= end; i++) { + pages.push(i); + } + } else { + for (let i = start; i <= end; i++) { + pages.push(i); + } + } + return pages; + } + + goToPage(page: number): void { + if (page >= 0 && page < this.totalPages) { + this.pageIndex = page; + this.loadResults(); + } + } + + onPageSizeChange(size: number): void { + this.pageSize = size; + this.pageIndex = 0; + this.loadResults(); + } + + // Type helpers + isCompactType(type: ItemType): boolean { + return type === ItemType.CALCULATED_FIELD + || type === ItemType.ALARM_RULE + || type === ItemType.RULE_CHAIN; + } + + getTypeLabel(type: ItemType): string { + const key = itemTypeTranslations.get(type); + return key ? this.translate.instant(key + '-plural') : type; + } + + getTypeRoute(type: ItemType): string { + switch (type) { + case ItemType.WIDGET: return 'widgets'; + case ItemType.SOLUTION_TEMPLATE: return 'solution-templates'; + case ItemType.CALCULATED_FIELD: return 'calculated-fields'; + case ItemType.ALARM_RULE: return 'alarm-rules'; + case ItemType.RULE_CHAIN: return 'rule-chains'; + case ItemType.DEVICE: return 'devices'; + default: return 'widgets'; + } + } + + navigateToType(type: ItemType): void { + const search = this.searchText?.trim() || undefined; + void this.router.navigate(['/iot-hub', this.getTypeRoute(type)], { queryParams: { search } }); + } + + // Installed items + getInstalledItem(item: MpItemVersionView): IotHubInstalledItem | undefined { + switch (item.type) { + case ItemType.WIDGET: + return this.installedWidgets.find(i => i.itemId === item.itemId); + case ItemType.SOLUTION_TEMPLATE: + return this.installedSolutionTemplates.find(i => i.itemId === item.itemId); + default: + return undefined; + } + } + + getInstalledItemsCount(item: MpItemVersionView): number { + switch (item.type) { + case ItemType.DEVICE: + return this.installedDeviceCounts[item.itemId] || 0; + case ItemType.CALCULATED_FIELD: + return this.installedCalcFieldCounts[item.itemId] || 0; + case ItemType.ALARM_RULE: + return this.installedAlarmRuleCounts[item.itemId] || 0; + case ItemType.RULE_CHAIN: + return this.installedRuleChainCounts[item.itemId] || 0; + default: + return 0; + } + } + + // Dialogs + openItemDetail(item: MpItemVersionView): void { + this.iotHubActions.openItemDetail(item, this.getInstalledItem(item), this.getInstalledItemsCount(item), undefined, this.showCreator).subscribe(result => { + if (result === 'installed' || result === 'deleted' || result === 'updated') { + this.reloadInstalledItems(); + } + }); + } + + installItem(item: MpItemVersionView): void { + this.iotHubActions.installItem(item).subscribe(result => { + if (result === 'installed') { + this.reloadInstalledItems(); + } + }); + } + + updateItem(item: MpItemVersionView): void { + const installedItem = this.getInstalledItem(item); + if (!installedItem) { return; } + this.iotHubActions.updateItem(installedItem, item.version, item.id as string).subscribe(result => { + if (result === 'updated') { + this.reloadInstalledItems(); + } + }); + } + + deleteInstalledItem(item: MpItemVersionView): void { + const installedItem = this.getInstalledItem(item); + if (!installedItem) { return; } + this.iotHubActions.deleteItem(installedItem).subscribe(() => { + this.reloadInstalledItems(); + }); + } + + navigateToCreator(creatorId: string): void { + void this.router.navigate(['/iot-hub/creator', creatorId]); + } + + retryLoadResults(): void { + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + } + this.isLoading = true; + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.loadResults(); + }, 350); + } + + // Data loading + private loadResults(): void { + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + this.isLoading = true; + // hasError stays as-is until the request actually succeeds + // (cleared in the `next` callback below). + this.fetchResults(this.searchText || '').subscribe({ + next: result => { + this.applyResults(result.data, result.totalElements); + this.hasError = false; + }, + error: () => { + this.isLoading = false; + this.hasError = true; + this.resultGroups = []; + this.totalElements = 0; + } + }); + } + + private fetchResults(text: string) { + const sort = this.sortOptions[this.selectedSortIndex]; + const sortOrder: SortOrder = { property: sort.value, direction: sort.direction }; + const pageLink = new PageLink(this.pageSize, this.pageIndex, text.trim() || null, sortOrder); + const query = new MpItemVersionQuery(pageLink, { creatorId: this.creatorId || undefined }); + return this.iotHubApiService.getPublishedVersions(query, { ignoreLoading: true, ignoreErrors: true }); + } + + private applyResults(data: MpItemVersionView[], totalElements: number): void { + this.totalElements = totalElements; + this.resultGroups = this.groupResults(data); + this.isLoading = false; + } + + private groupResults(items: MpItemVersionView[]): SearchResultGroup[] { + const groupMap = new Map(); + for (const item of items) { + let list = groupMap.get(item.type); + if (!list) { + list = []; + groupMap.set(item.type, list); + } + list.push(item); + } + return TYPE_ORDER + .filter(type => groupMap.has(type)) + .map(type => ({ type, items: groupMap.get(type) })); + } + + private loadInstalledItems(): void { + const config = { ignoreLoading: true }; + const pageLink = new PageLink(10000, 0); + forkJoin({ + widgets: this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, undefined, config), + solutionTemplates: this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, undefined, config), + deviceCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DEVICE, config), + calcFieldCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.CALCULATED_FIELD, config), + alarmRuleCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.ALARM_RULE, config), + ruleChainCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.RULE_CHAIN, config) + }).subscribe(results => { + this.installedWidgets = results.widgets.data; + this.installedSolutionTemplates = results.solutionTemplates.data; + this.installedDeviceCounts = results.deviceCounts; + this.installedCalcFieldCounts = results.calcFieldCounts; + this.installedAlarmRuleCounts = results.alarmRuleCounts; + this.installedRuleChainCounts = results.ruleChainCounts; + }); + } + + private reloadInstalledItems(): void { + this.loadInstalledItems(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html new file mode 100644 index 0000000000..2f14b5f99a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html @@ -0,0 +1,32 @@ + +
+
+ warning +

{{ 'iot-hub.unpublished-warning-title' | translate }}

+
+

{{ 'iot-hub.unpublished-warning-text' | translate }}

+
+ {{ data.item.name }} + v {{ data.item.version }} +
+
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss new file mode 100644 index 0000000000..184924cbcc --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss @@ -0,0 +1,82 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-iot-hub-warning-content { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; + max-width: 480px; +} + +.tb-iot-hub-warning-title-row { + display: flex; + align-items: center; + gap: 12px; +} + +.tb-iot-hub-warning-icon { + color: #d32f2f; + font-size: 28px; + width: 28px; + height: 28px; +} + +.tb-iot-hub-warning-title { + font-size: 20px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.87); + margin: 0; +} + +.tb-iot-hub-warning-text { + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.75); + margin: 0; +} + +.tb-iot-hub-warning-item { + display: flex; + align-items: baseline; + gap: 8px; + padding: 12px 16px; + background: rgba(211, 47, 47, 0.08); + border-left: 3px solid #d32f2f; + border-radius: 2px; + + .tb-iot-hub-warning-item-name { + font-weight: 600; + color: rgba(0, 0, 0, 0.87); + } + + .tb-iot-hub-warning-item-version { + font-size: 13px; + color: rgba(0, 0, 0, 0.54); + } +} + +.tb-iot-hub-warning-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 8px; +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts new file mode 100644 index 0000000000..3d55dc598a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.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, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; + +export interface IotHubUnpublishedWarningDialogData { + item: MpItemVersionView; +} + +@Component({ + selector: 'tb-iot-hub-unpublished-warning-dialog', + standalone: false, + templateUrl: './iot-hub-unpublished-warning-dialog.component.html', + styleUrls: ['./iot-hub-unpublished-warning-dialog.component.scss'] +}) +export class TbIotHubUnpublishedWarningDialogComponent extends DialogComponent { + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: IotHubUnpublishedWarningDialogData + ) { + super(store, router, dialogRef); + } + + confirm(): void { + this.dialogRef.close(true); + } + + cancel(): void { + this.dialogRef.close(false); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.html new file mode 100644 index 0000000000..78e8fc3b66 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.html @@ -0,0 +1,70 @@ + +
+ @switch (state) { + @case ('confirm') { +

{{ 'iot-hub.update-confirm-title' | translate:{ name: data.itemName } }}

+

{{ 'iot-hub.update-desc' | translate }}

+ } + @case ('updating') { +

{{ 'iot-hub.update-confirm-title' | translate:{ name: data.itemName } }}

+

{{ 'iot-hub.update-desc' | translate }}

+ } + @case ('success') { +

{{ 'iot-hub.update-success-title' | translate }}

+

{{ 'iot-hub.update-success-desc' | translate:{ name: data.itemName } }}

+ } + @case ('error') { +

{{ 'iot-hub.update-error-title' | translate }}

+

{{ 'iot-hub.update-error-message' | translate:{ name: data.itemName } }}

+ @if (data?.itemType === ItemType.SOLUTION_TEMPLATE) { + + } @else { +
+ {{ errorMessage }} +
+ } + } + } +
+ + @switch (state) { + @case ('confirm') { + + + } + @case ('updating') { + + + } + @case ('success') { + + @if (entityDetailsUrl) { + + } + } + @case ('error') { + + } + } + diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.ts new file mode 100644 index 0000000000..6962073104 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.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 { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { + getInstalledItemUrl, + SolutionTemplateInstalledItemDescriptor +} from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { DialogService } from '@core/services/dialog.service'; +import { TranslateService } from '@ngx-translate/core'; +import { SolutionInstallDialogComponent } from '@home/components/iot-hub/solution-install-dialog.component'; + +export interface IotHubUpdateDialogData { + installedItemId: string; + itemName: string; + itemType: ItemType; + version: string; + versionId: string; +} + +export type UpdateState = 'confirm' | 'updating' | 'success' | 'error'; + +@Component({ + selector: 'tb-iot-hub-update-dialog', + standalone: false, + templateUrl: './iot-hub-update-dialog.component.html', + styleUrls: ['./iot-hub-install-dialog.component.scss'] +}) +export class TbIotHubUpdateDialogComponent extends DialogComponent { + + ItemType = ItemType; + + typeTranslations = itemTypeTranslations; + state: UpdateState = 'confirm'; + errorMessage = ''; + entityDetailsUrl: string | null = null; + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: IotHubUpdateDialogData, + private dialog: MatDialog, + private dialogService: DialogService, + private translate: TranslateService, + private iotHubApiService: IotHubApiService + ) { + super(store, router, dialogRef); + } + + getTypeLabel(): string { + const key = this.typeTranslations.get(this.data.itemType); + return key ? this.translate.instant(key) : ''; + } + + update(force = false): void { + this.state = 'updating'; + this.iotHubApiService.updateItemVersion(this.data.installedItemId, this.data.versionId, { ignoreLoading: true }, force).subscribe({ + next: (result) => { + if (result.success) { + if (result.descriptor?.type === 'SOLUTION_TEMPLATE') { + this.dialogRef.close('updated'); + this.dialog.open(SolutionInstallDialogComponent, { + disableClose: true, + autoFocus: false, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { descriptor: result.descriptor as SolutionTemplateInstalledItemDescriptor } + }); + } else { + this.state = 'success'; + this.entityDetailsUrl = getInstalledItemUrl(result.descriptor); + } + } else if (result.entityModified) { + this.state = 'confirm'; + this.dialogService.confirm( + this.translate.instant('iot-hub.entity-modified-title'), + this.translate.instant('iot-hub.entity-modified-text', { type: this.getTypeLabel() }), + this.translate.instant('action.no'), + this.translate.instant('action.yes') + ).subscribe(confirmed => { + if (confirmed) { + this.update(true); + } + }); + } else { + this.state = 'error'; + this.errorMessage = result.errorMessage || this.translate.instant('iot-hub.update-error', { name: this.data.itemName }); + } + }, + error: (err) => { + this.state = 'error'; + this.errorMessage = err?.error?.message || err?.message || this.translate.instant('iot-hub.update-error', { name: this.data.itemName }); + } + }); + } + + openEntityDetails(): void { + if (this.entityDetailsUrl) { + this.dialogRef.close('updated'); + void this.router.navigateByUrl(this.entityDetailsUrl); + } + } + + close(): void { + this.dialogRef.close(this.state === 'success' ? 'updated' : false); + } + + cancel(): void { + this.dialogRef.close(false); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-upgrade-required-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-upgrade-required-dialog.component.html new file mode 100644 index 0000000000..e1fce18133 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-upgrade-required-dialog.component.html @@ -0,0 +1,43 @@ + +
+ + + + +

+ {{ 'iot-hub.upgrade-required-title' | translate }} +

+ +

+ +
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-upgrade-required-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-upgrade-required-dialog.component.scss new file mode 100644 index 0000000000..3a4d7f8c08 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-upgrade-required-dialog.component.scss @@ -0,0 +1,76 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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: block; + width: 500px; + max-width: 100%; +} + +.tb-iot-hub-upgrade-required { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 20px; + padding: 40px; + background: white; + border-radius: 8px; +} + +.tb-iot-hub-upgrade-required-close { + position: absolute; + top: 8px; + right: 8px; +} + +// Re-use the upgrade-required.svg silhouette as a CSS mask so the icon +// tints with $tb-primary-color (PE overrides apply automatically). +.tb-iot-hub-upgrade-required-icon { + display: block; + width: 100px; + height: 100px; + background-color: $tb-primary-color; + -webkit-mask: url('/assets/iot-hub/upgrade-required.svg') no-repeat center / contain; + mask: url('/assets/iot-hub/upgrade-required.svg') no-repeat center / contain; +} + +.tb-iot-hub-upgrade-required-title { + font-size: 24px; + font-weight: 500; + line-height: 32px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.87); + margin: 0; +} + +.tb-iot-hub-upgrade-required-message { + font-size: 14px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.76); + margin: 0; +} + +.tb-iot-hub-upgrade-required-actions { + display: flex; + align-items: center; + gap: 8px; +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-upgrade-required-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-upgrade-required-dialog.component.ts new file mode 100644 index 0000000000..480d3109ba --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-upgrade-required-dialog.component.ts @@ -0,0 +1,59 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { tbVersionIntToString } from '@core/http/iot-hub-api.service'; +import { environment as env } from '@env/environment'; + +export interface IotHubUpgradeRequiredDialogData { + minTbVersion: number; +} + +@Component({ + selector: 'tb-iot-hub-upgrade-required-dialog', + standalone: false, + templateUrl: './iot-hub-upgrade-required-dialog.component.html', + styleUrls: ['./iot-hub-upgrade-required-dialog.component.scss'] +}) +export class TbIotHubUpgradeRequiredDialogComponent extends DialogComponent { + + readonly minVersion: string; + readonly currentVersion: string; + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: IotHubUpgradeRequiredDialogData + ) { + super(store, router, dialogRef); + this.minVersion = `v${tbVersionIntToString(data.minTbVersion)}`; + this.currentVersion = `v${env.tbVersion}`; + } + + close(): void { + this.dialogRef.close(); + } + + upgradeInstance(): void { + window.open('https://thingsboard.io/docs/installation/upgrade-instructions/', '_blank'); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-utils.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-utils.ts new file mode 100644 index 0000000000..c5ea02e2d0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-utils.ts @@ -0,0 +1,69 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { FilterParamInfo } from '@shared/models/iot-hub/iot-hub-item.models'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; + +export const IOT_HUB_FILTER_GROUPING_THRESHOLD = 11; +export const IOT_HUB_FILTER_POPULAR_LIMIT = 10; + +export interface IotHubFilterGroup { + label: string; + items: FilterParamInfo[]; +} + +export function filterIotHubItemsBySearch(items: FilterParamInfo[], search: string): FilterParamInfo[] { + const normalized = (search || '').toLowerCase(); + if (!normalized) { + return items; + } + return items.filter(item => item.key.toLowerCase().includes(normalized)); +} + +export function groupIotHubFilterItems(items: FilterParamInfo[], search: string): IotHubFilterGroup[] { + const filtered = filterIotHubItemsBySearch(items, search); + if (items.length < IOT_HUB_FILTER_GROUPING_THRESHOLD) { + return [{ label: null, items: filtered }]; + } + const topKeys = new Set( + [...items] + .sort((a, b) => b.totalInstallCount - a.totalInstallCount) + .slice(0, IOT_HUB_FILTER_POPULAR_LIMIT) + .map(i => i.key) + ); + const popular = filtered.filter(i => topKeys.has(i.key)); + const rest = filtered.filter(i => !topKeys.has(i.key)); + const groups: IotHubFilterGroup[] = []; + if (popular.length) { + groups.push({ label: 'iot-hub.most-popular', items: popular }); + } + if (rest.length) { + groups.push({ label: 'iot-hub.all', items: rest }); + } + return groups; +} + +export function resolveIotHubItemImageUrl(item: MpItemVersionView, api: IotHubApiService): string | null { + if (item.image) { + return api.resolveResourceUrl(item.image); + } + const resource = item.resources?.find(r => r.type === 'SCREENSHOT') || item.resources?.find(r => r.type === 'ICON'); + if (resource) { + return api.resolveResourceUrl(`/api/resources/${resource.id}`); + } + return null; +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.html new file mode 100644 index 0000000000..1c4b8e25b1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.html @@ -0,0 +1,38 @@ + +
+ + +

+ {{ data.connectorName }} + {{ 'iot-hub.pe-connectivity-prompt-title-suffix' | translate }} +

+
+ + {{ 'iot-hub.pe-connectivity-prompt-try-pe' | translate }} + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.scss new file mode 100644 index 0000000000..a66a867374 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.scss @@ -0,0 +1,65 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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: block; + width: 500px; + max-width: 100%; +} + +.tb-pe-connectivity-prompt { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 20px; + padding: 40px; + background: white; + border-radius: 8px; +} + +.tb-pe-connectivity-prompt-close { + position: absolute; + top: 8px; + right: 8px; +} + +.tb-pe-connectivity-prompt-illustration { + width: 140px; + height: 140px; +} + +.tb-pe-connectivity-prompt-title { + font-size: 24px; + font-weight: 500; + line-height: 32px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.87); + margin: 0; +} + +.tb-pe-connectivity-prompt-connector { + color: $tb-primary-color; +} + +.tb-pe-connectivity-prompt-actions { + display: flex; + align-items: center; + gap: 8px; +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.ts new file mode 100644 index 0000000000..11173a21cb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.ts @@ -0,0 +1,48 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; + +export interface PeConnectivityMethodPromptData { + connectorName: string; +} + +@Component({ + selector: 'tb-pe-connectivity-method-prompt', + standalone: false, + templateUrl: './pe-connectivity-method-prompt.component.html', + styleUrls: ['./pe-connectivity-method-prompt.component.scss'] +}) +export class TbPeConnectivityMethodPromptComponent extends DialogComponent { + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: PeConnectivityMethodPromptData + ) { + super(store, router, dialogRef); + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.html new file mode 100644 index 0000000000..243ae759d3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.html @@ -0,0 +1,47 @@ + +
+
+ @if (instructions) {info_outline} +
{{ (instructions ? 'iot-hub.solution-instructions' : 'iot-hub.solution-install-title') | translate }}
+ + +
+
+ +
+
+ + @if (dashboardId) { + + } +
+
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.scss new file mode 100644 index 0000000000..ae8f543c10 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.scss @@ -0,0 +1,65 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + .dlg-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 12px 8px 24px; + flex-shrink: 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + } + .dlg-title { + font-size: 24px; + font-weight: 500; + line-height: 32px; + color: rgba(0, 0, 0, 0.87); + } + .mat-mdc-dialog-actions { + border-top: 1px solid rgba(0, 0, 0, 0.12); + } +} + +.tb-dialog-content { + max-width: 100%; + width: 1200px; + display: flex; + flex-direction: column; + + @media #{$mat-lt-xxl} { + width: 900px; + } + + @media #{$mat-lt-lg} { + width: 768px; + } + + @media #{$mat-lt-md} { + width: 100%; + } + + @media #{$mat-xs} { + min-width: 100% !important; + height: 100%; + } +} + +:host .tb-dialog-content ::ng-deep tb-iot-hub-markdown .tb-markdown-view { + padding: 0; +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.ts new file mode 100644 index 0000000000..12539d039e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.ts @@ -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 { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { + replaceItemLinkPlaceholders +} from '@home/components/iot-hub/iot-hub-markdown.utils'; + +export interface SolutionInstallDialogData { + descriptor: SolutionTemplateInstalledItemDescriptor; + instructions?: boolean; +} + +@Component({ + selector: 'tb-solution-install-dialog', + templateUrl: './solution-install-dialog.component.html', + styleUrls: ['./solution-install-dialog.component.scss'], + standalone: false +}) +export class SolutionInstallDialogComponent { + + details: string; + dashboardId: string | null; + instructions: boolean; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: SolutionInstallDialogData, + private dialogRef: MatDialogRef, + private router: Router + ) { + this.details = replaceItemLinkPlaceholders(data.descriptor.details || ''); + this.dashboardId = data.descriptor.dashboardId?.id || null; + this.instructions = !!data.instructions; + } + + gotoMainDashboard(): void { + if (this.dashboardId) { + this.dialogRef.close(); + this.router.navigateByUrl(`/dashboards/${this.dashboardId}`); + } + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html index 3cf88a2463..4a12d06097 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html @@ -91,9 +91,15 @@ {{ 'rule-node-config.ai.response-format' | translate }}
- {{ 'rule-node-config.ai.response-text' | translate }} - {{ 'rule-node-config.ai.response-json' | translate }} - {{ 'rule-node-config.ai.response-json-schema' | translate }} + @if (allowedResponseFormats.includes(responseFormat.TEXT)) { + {{ 'rule-node-config.ai.response-text' | translate }} + } + @if (allowedResponseFormats.includes(responseFormat.JSON)) { + {{ 'rule-node-config.ai.response-json' | translate }} + } + @if (allowedResponseFormats.includes(responseFormat.JSON_SCHEMA)) { + {{ 'rule-node-config.ai.response-json-schema' | translate }} + }
-
+
widget-config.actions
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts index 0320dc7fd1..389c780e4c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts @@ -26,6 +26,7 @@ import { import { deepClone } from '@core/utils'; import { MatDialog } from '@angular/material/dialog'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ selector: 'tb-widget-actions-panel', @@ -45,6 +46,10 @@ export class WidgetActionsPanelComponent implements ControlValueAccessor, OnInit @Input() disabled: boolean; + @Input() + @coerceBoolean() + strokedPanel = false; + actionsFormGroup: UntypedFormGroup; private propagateChange = (_val: any) => {}; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html index 298bdb6e61..907da3e14b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html @@ -17,4 +17,8 @@ --> + + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts index d057acf057..3872e07853 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts @@ -51,12 +51,14 @@ export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponen protected onConfigSet(configData: WidgetConfigComponentData) { const settings: HtmlContainerWidgetSettings = {...htmlContainerDefaultSettings, ...(configData.config.settings || {})}; this.htmlContainerWidgetConfigForm = this.fb.group({ - settings: [settings, []] + settings: [settings, []], + actions: [configData.config.actions || {}, []] }); } protected prepareOutputConfig(config: any): WidgetConfigComponentData { this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings}; + this.widgetConfig.config.actions = config.actions; return this.widgetConfig; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/home-page-widgets.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/home-page-widgets.module.ts index af27184780..067cc0daab 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/home-page-widgets.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/home-page-widgets.module.ts @@ -35,6 +35,8 @@ import { AddQuickLinkDialogComponent } from '@home/components/widget/lib/home-pa import { RecentDashboardsWidgetComponent } from '@home/components/widget/lib/home-page/recent-dashboards-widget.component'; +import { IotHubWidgetComponent } from '@home/components/widget/lib/home-page/iot-hub-widget.component'; +import { IotHubComponentsModule } from '@home/components/iot-hub/iot-hub-components.module'; @NgModule({ declarations: @@ -52,11 +54,13 @@ import { QuickLinksWidgetComponent, QuickLinkComponent, AddQuickLinkDialogComponent, - RecentDashboardsWidgetComponent + RecentDashboardsWidgetComponent, + IotHubWidgetComponent ], imports: [ CommonModule, - SharedModule + SharedModule, + IotHubComponentsModule ], exports: [ ClusterInfoTableComponent, @@ -72,7 +76,8 @@ import { QuickLinksWidgetComponent, QuickLinkComponent, AddQuickLinkDialogComponent, - RecentDashboardsWidgetComponent + RecentDashboardsWidgetComponent, + IotHubWidgetComponent ] }) export class HomePageWidgetsModule { } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.html new file mode 100644 index 0000000000..7c1a11f3ab --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.html @@ -0,0 +1,82 @@ + +
+ @if (hasIotHubAccess) { +
+
{{ 'widgets.iot-hub.title-text' | translate }} IoT Hub
+ +
+
+
+ {{ 'item.type-solution-template-plural' | translate }} +
+ @for (item of solutionTemplates; track item.id) { +
+
+ @if (getItemImage(item); as imgUrl) { + + } + @if (isInstalled(item)) { + check_circle + } +
+
+ {{ item.name }} + + file_download + {{ item.totalInstallCount | shortNumber }} + +
+
+ } +
+
+ +
+ {{ 'iot-hub.device-library' | translate }} +
+ @for (item of devices; track item.id) { +
+
+ @if (getItemImage(item); as imgUrl) { + + } +
+
+ {{ item.name }} + + file_download + {{ item.totalInstallCount | shortNumber }} + +
+
+ } +
+
+
+ } @else { +
+
+
widgets.home.no-data-available
+
+ } +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.scss new file mode 100644 index 0000000000..345c68adaf --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.scss @@ -0,0 +1,203 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-card-header { + gap: 10px; + @media #{$mat-md-lg} { + gap: 8px; + @media #{$mat-lt-lg} { + gap: 4px; + } + } + .tb-card-title-text { + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 20px; + color: rgba(0, 0, 0, 0.76); + @media #{$mat-md-lg} { + font-size: 12px; + line-height: 16px; + } + a { + display: inline-block; + font-weight: 500; + border: none; + color: $tb-primary-color; + } + } + .mat-divider { + margin-left: -16px; + margin-right: -16px; + @media #{$mat-md-lg} { + margin-left: -8px; + margin-right: -8px; + @media #{$mat-lt-lg} { + margin-left: -2px; + margin-right: -2px; + } + } + } + } + + .tb-iot-hub-widget-body { + display: flex; + flex: 1 1 0; + min-height: 0; + gap: 0; + padding: 8px 0 0; + + @media #{$mat-lt-md} { + flex-direction: column; + gap: 12px; + } + + .mat-divider { + margin: 24px 16px; + @media #{$mat-lt-md} { + display: none; + } + @media #{$mat-md-lg} { + margin-left: 8px; + margin-right: 8px; + } + } + } + + .tb-iot-hub-widget-column { + display: flex; + flex: 1 1 0; + min-width: 0; + flex-direction: column; + gap: 8px; + overflow: hidden; + } + + .tb-iot-hub-widget-cards { + display: flex; + flex: 1 1 0; + min-height: 0; + gap: 12px; + @media #{$mat-md} { + gap: 8px; + } + } + + .tb-iot-hub-widget-card { + display: flex; + flex: 1 1 0; + min-width: 0; + flex-direction: column; + gap: 4px; + cursor: pointer; + border: none; + background: none; + padding: 0; + text-align: left; + + &:hover .tb-iot-hub-widget-card-image, + &:focus-visible .tb-iot-hub-widget-card-image { + border-color: rgba(0, 0, 0, 0.12); + box-shadow: 0 4px 10px 0 rgba(23, 33, 90, 0.08); + } + + &:focus-visible { + outline: none; + } + } + + .tb-iot-hub-widget-card-image { + position: relative; + display: flex; + flex: 1 1 0; + min-height: 0; + align-items: center; + justify-content: center; + padding: 8px; + border: 1px solid rgba(0, 0, 0, 0.05); + border-radius: 10px; + background: radial-gradient(circle, rgba(0, 0, 0, 0.06) 1px, transparent 1px) 0 0 / 16px 16px rgba(0, 0, 0, 0.04); + overflow: hidden; + transition: border-color 0.15s, box-shadow 0.15s; + @media #{$mat-md-lg} { + padding: 4px; + } + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + } + + // Installed badge — green circle + check icon, top-right corner + .tb-iot-hub-widget-card-installed { + position: absolute; + top: 5px; + right: 5px; + width: 20px; + height: 20px; + font-size: 20px; + line-height: 20px; + color: #2e7d32; + background: white; + border-radius: 50%; + } + + .tb-iot-hub-widget-card-label { + display: flex; + align-items: center; + gap: 8px; + padding: 0 4px; + } + + .tb-iot-hub-widget-card-name { + flex: 1 1 0; + min-width: 0; + font-family: inherit; + font-size: 12px; + font-weight: 400; + line-height: 16px; + color: rgba(0, 0, 0, 0.76); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + @media #{$mat-md-lg} { + font-size: 11px; + } + } + + .tb-iot-hub-widget-card-installs { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 2px; + font-size: 12px; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.4px; + color: rgba(0, 0, 0, 0.54); + + mat-icon { + width: 12px; + height: 12px; + font-size: 12px; + line-height: 12px; + color: rgba(0, 0, 0, 0.54); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.ts new file mode 100644 index 0000000000..8195aa4117 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.ts @@ -0,0 +1,131 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 } from '@angular/core'; +import { forkJoin } from 'rxjs'; +import { PageComponent } from '@shared/components/page.component'; +import { Authority } from '@shared/models/authority.enum'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; +import { resolveIotHubItemImageUrl } from '@home/components/iot-hub/iot-hub-utils'; +import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; + +const WIDGET_CARD_COUNT = 3; + +@Component({ + selector: 'tb-iot-hub-widget', + templateUrl: './iot-hub-widget.component.html', + styleUrls: ['./home-page-widget.scss', './iot-hub-widget.component.scss'], + standalone: false +}) +export class IotHubWidgetComponent extends PageComponent implements OnInit { + + @Input() + ctx: WidgetContext; + + authority = Authority; + + authUser = getCurrentAuthUser(this.store); + + hasIotHubAccess = true; + + solutionTemplates: MpItemVersionView[] = []; + devices: MpItemVersionView[] = []; + installedSolutionTemplates: IotHubInstalledItem[] = []; + installedDeviceCounts: Record = {}; + + constructor(private iotHubApiService: IotHubApiService, + private iotHubActions: IotHubActionsService) { + super(); + } + + ngOnInit() { + this.ctx.overflowVisible = true; + this.hasIotHubAccess = [Authority.TENANT_ADMIN].includes(this.authUser.authority); + if (this.hasIotHubAccess) { + this.load(); + } + } + + openItemDetail(item: MpItemVersionView): void { + this.iotHubActions.openItemDetail(item, this.findInstalledSolutionTemplate(item), this.findInstalledDeviceCount(item)) + .subscribe(result => { + if (result === 'installed' || result === 'deleted' || result === 'updated') { + this.reloadInstalled(item.type); + } + }); + } + + getItemImage(item: MpItemVersionView): string | null { + return resolveIotHubItemImageUrl(item, this.iotHubApiService); + } + + isInstalled(item: MpItemVersionView): boolean { + return item.type === ItemType.SOLUTION_TEMPLATE && !!this.findInstalledSolutionTemplate(item); + } + + private findInstalledSolutionTemplate(item: MpItemVersionView): IotHubInstalledItem | undefined { + return this.installedSolutionTemplates.find(i => i.itemId === item.itemId); + } + + private findInstalledDeviceCount(item: MpItemVersionView): number { + return this.installedDeviceCounts[item.itemId] || 0; + } + + private load(): void { + const config = { ignoreLoading: true }; + const sortOrder: SortOrder = { property: 'totalInstallCount', direction: Direction.DESC }; + const buildQuery = (type: ItemType): MpItemVersionQuery => + new MpItemVersionQuery(new PageLink(WIDGET_CARD_COUNT, 0, null, sortOrder), { type }); + const installedPageLink = new PageLink(10000, 0); + + forkJoin({ + solutionTemplates: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.SOLUTION_TEMPLATE), config), + devices: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.DEVICE), config), + installedSolutionTemplates: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.SOLUTION_TEMPLATE, undefined, config), + installedDeviceCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DEVICE, config) + }).subscribe(result => { + this.solutionTemplates = result.solutionTemplates.data; + this.devices = result.devices.data; + this.installedSolutionTemplates = result.installedSolutionTemplates.data; + this.installedDeviceCounts = result.installedDeviceCounts; + this.ctx.detectChanges(); + }); + } + + private reloadInstalled(type: ItemType): void { + const config = { ignoreLoading: true }; + if (type === ItemType.SOLUTION_TEMPLATE) { + const installedPageLink = new PageLink(10000, 0); + this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.SOLUTION_TEMPLATE, undefined, config).subscribe(data => { + this.installedSolutionTemplates = data.data; + this.ctx.detectChanges(); + }); + } else if (type === ItemType.DEVICE) { + this.iotHubApiService.getInstalledItemCounts(ItemType.DEVICE, config).subscribe(counts => { + this.installedDeviceCounts = counts; + this.ctx.detectChanges(); + }); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts index f7bae926bc..51ee9871f8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts @@ -15,8 +15,10 @@ /// import { - Component, + ChangeDetectorRef, + Component, ComponentRef, ElementRef, + inject, Inject, Injector, Input, @@ -96,6 +98,7 @@ export class HtmlContainerWidgetComponent implements OnInit { @Inject(HOME_COMPONENTS_MODULE_TOKEN) private homeComponentsModule: Type, private dynamicComponentFactoryService: DynamicComponentFactoryService, private utils: UtilsService, + private cdr: ChangeDetectorRef, private resources: ResourcesService) {} ngOnInit(): void { @@ -160,11 +163,13 @@ export class HtmlContainerWidgetComponent implements OnInit { this.compileAngularFunction().subscribe( { next: (containerFunction) => { - try { - this.initAngularComponent(imports, containerFunction); - } catch (e) { - this.handleWidgetException(e); - } + setTimeout(() => { + try { + this.initAngularComponent(imports, containerFunction); + } catch (e) { + this.handleWidgetException(e); + } + }); }, error: (e) => { this.handleWidgetException(e); @@ -200,9 +205,16 @@ export class HtmlContainerWidgetComponent implements OnInit { compileModules = compileModules.concat(imports); } const self = () => this; + + let containerRef: ComponentRef; + this.dynamicComponentFactoryService.createDynamicComponent( class TbContainerInstance { + + private cdr = inject(ChangeDetectorRef); + ngOnInit(): void { + this.cdr.detach(); if (containerFunction) { const instance = self(); try { @@ -212,6 +224,15 @@ export class HtmlContainerWidgetComponent implements OnInit { } } } + ngDoCheck(): void { + const instance = self(); + try { + this.cdr.detectChanges() + } catch (error) { + containerRef.destroy(); + instance.handleWidgetException(error) + } + } ngOnDestroy(): void { destroyContainerInstanceResources(); } @@ -224,7 +245,7 @@ export class HtmlContainerWidgetComponent implements OnInit { this.containerInstanceComponentType = componentType; const injector: Injector = Injector.create({providers: [], parent: this.angularContainer.viewContainerRef.injector}); try { - this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType, + containerRef = this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType, {index: 0, injector}); } catch (error) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts index bc1e5f8f89..405be8729e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts @@ -18,13 +18,13 @@ import { DEFAULT_ZOOM_LEVEL, defaultGeoMapSettings, GeoMapSettings, - latLngPointToBounds, + latLngPointToBounds, MapControlsPosition, MapZoomAction, TbCircleData, TbPolygonCoordinate, TbPolygonCoordinates, TbPolygonRawCoordinate, - TbPolygonRawCoordinates + TbPolygonRawCoordinates, WEBGL_ERROR_EVENT } from '@shared/models/widget/maps/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { DeepPartial } from '@shared/models/common'; @@ -92,9 +92,19 @@ export class TbGeoMap extends TbMap { return this.loadLayers().pipe( tap((layers: L.TB.LayerData[]) => { if (layers.length) { - const layer = layers[0]; - layer.layer.addTo(this.map); - this.map.attributionControl.setPrefix(layer.attributionPrefix); + const defaultLayer = layers[0]; + layers.forEach(layer => { + layer.layer.once(WEBGL_ERROR_EVENT, () => { + const toastPosition = this.settings.controlsPosition === MapControlsPosition.bottomleft + || this.settings.controlsPosition === MapControlsPosition.bottomright ? 'top' : 'bottom'; + this.ctx.showErrorToast( + this.ctx.translate.instant('widgets.maps.layer.webgl-not-available'), + toastPosition, 'left', this.ctx.toastTargetId, true + ); + }); + }); + defaultLayer.layer.addTo(this.map); + this.map.attributionControl.setPrefix(defaultLayer.attributionPrefix); if (layers.length > 1) { const sidebar = this.getSidebar(); L.TB.layers({ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts index c16d5cc67c..72e7e5b324 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts @@ -24,6 +24,7 @@ import { MatIconRegistry } from '@angular/material/icon'; import { isSvgIcon, splitIconName } from '@shared/models/icon.models'; import { catchError, take } from 'rxjs/operators'; import { of } from 'rxjs'; +import { WEBGL_ERROR_EVENT } from '@shared/models/widget/maps/map.models'; L.MarkerCluster = L.MarkerCluster.mergeOptions({ pmIgnore: true }); @@ -1093,6 +1094,7 @@ class MapLibreGLLayer extends L.Layer implements TB.MapLibreGL.MapLibreGLLayer { private _actualCanvas: HTMLCanvasElement; private _offset: L.Point; private _zooming: boolean; + private _glError = false; constructor(options: TB.MapLibreGL.LeafletMapLibreGLMapOptions) { super(); @@ -1136,7 +1138,9 @@ class MapLibreGLLayer extends L.Layer implements TB.MapLibreGL.MapLibreGLLayer { const paneName = this.getPaneName(); map.getPane(paneName).removeChild(this._container); - this._glMap.remove(); + if (this._glMap) { + this._glMap.remove(); + } this._glMap = null; return this; @@ -1204,7 +1208,18 @@ class MapLibreGLLayer extends L.Layer implements TB.MapLibreGL.MapLibreGLLayer { zoom: this._map.getZoom() - 1, attributionControl: false }); - this._glMap = new MapLibreGLMap(options); + this._glError = false; + try { + this._glMap = new MapLibreGLMap(options); + } catch (e) { + this._glError = true; + this.fire(WEBGL_ERROR_EVENT, { error: e }); + return; + } + this._glMap.once('webglcontextlost', (e) => { + this._glError = true; + this.fire(WEBGL_ERROR_EVENT, {error: e}); + }); this._glMap.once('load', () => { this.fire('load'); }); @@ -1222,8 +1237,12 @@ class MapLibreGLLayer extends L.Layer implements TB.MapLibreGL.MapLibreGLLayer { } } + private _glReady(): boolean { + return !this._glError && !!this._glMap; + } + private _update() { - if (!this._map) { + if (!this._map || !this._glReady()) { return; } this._offset = this._map.containerPointToLayerPoint([0, 0]); @@ -1253,6 +1272,7 @@ class MapLibreGLLayer extends L.Layer implements TB.MapLibreGL.MapLibreGLLayer { private _transformGL(gl: MapLibreGLMap) { const center = this._map.getCenter(); const tr = gl._getTransformForUpdate(); + if (!tr) { return; } tr.setCenter(MapLibreGLLngLat.convert([center.lng, center.lat])); tr.setZoom(this._map.getZoom() - 1); gl.transform.apply(tr); @@ -1260,6 +1280,7 @@ class MapLibreGLLayer extends L.Layer implements TB.MapLibreGL.MapLibreGLLayer { } private _pinchZoom() { + if (!this._glReady()) { return; } this._glMap.jumpTo({ zoom: this._map.getZoom() - 1, center: this._map.getCenter() @@ -1267,6 +1288,7 @@ class MapLibreGLLayer extends L.Layer implements TB.MapLibreGL.MapLibreGLLayer { } private _animateZoom(e: L.ZoomAnimEvent) { + if (!this._glReady() || !this._actualCanvas) { return; } const scale = this._map.getZoomScale(e.zoom); const padding = this._map.getSize().multiplyBy(this.options.padding * scale); const viewHalf = this.getSize().divideBy(2); @@ -1291,6 +1313,7 @@ class MapLibreGLLayer extends L.Layer implements TB.MapLibreGL.MapLibreGLLayer { } private _zoomEnd() { + if (!this._glReady() || !this._actualCanvas) { return; } const scale = this._map.getZoomScale(this._map.getZoom()); L.DomUtil.setTransform( this._actualCanvas, @@ -1302,7 +1325,9 @@ class MapLibreGLLayer extends L.Layer implements TB.MapLibreGL.MapLibreGLLayer { } private _transitionEnd() { + if (!this._glReady()) { return; } L.Util.requestAnimFrame(() => { + if (!this._glReady()) { return; } const zoom = this._map.getZoom(); const center = this._map.getCenter(); const offset = this._map.latLngToContainerPoint( diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts index 2953c609ce..4bc2dbf231 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts @@ -28,14 +28,14 @@ import { MapProvider, OpenStreetMapLayerSettings, ReferenceLayerType, - TencentMapLayerSettings + TencentMapLayerSettings, WEBGL_ERROR_EVENT } from '@shared/models/widget/maps/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { DeepPartial } from '@shared/models/common'; import { mergeDeep } from '@core/utils'; import { Observable, of, shareReplay, switchMap } from 'rxjs'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; -import L from 'leaflet'; +import L, { LeafletEvent } from 'leaflet'; import { catchError, map } from 'rxjs/operators'; import { ResourcesService } from '@core/services/resources.service'; import { StyleSpecification } from '@maplibre/maplibre-gl-style-spec'; @@ -120,6 +120,13 @@ export abstract class TbMapLayer { let referenceLayerLoaded = false; baseLayer.addTo(layer); referenceLayer.addTo(layer); + // Forwarding is needed only here: when a reference layer is present the base layer is + // wrapped in this featureGroup, and geo-map listens for WEBGL_ERROR_EVENT on the + // returned layer (the group). In the other branches the MapLibreGLLayer is returned + // directly and fires WEBGL_ERROR_EVENT itself, so no forwarding is required. + baseLayer.once(WEBGL_ERROR_EVENT, (e: LeafletEvent) => { + layer.fire(WEBGL_ERROR_EVENT, e); + }); baseLayer.once('load', () => { baseLayerLoaded = true; if (referenceLayerLoaded) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html index a72f28e70e..4f891f1617 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html @@ -122,7 +122,8 @@ [editorCompleter]="containerFunctionEditorCompleter" [functionArgs]="htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? ['ctx'] : ['ctx', 'container']" withModules - functionTitle="{{ 'widgets.html-container.js-function' | translate }}"> + functionTitle="{{ 'widgets.html-container.js-function' | translate }}" + [helpId]="htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? 'widget/lib/html_container/container_angular_fn' : 'widget/lib/html_container/container_plain_fn'"> diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index f35a1f9ae1..b09929d917 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -29,7 +29,7 @@
-
+
@@ -213,7 +213,7 @@
-
+
{{basicModeDirectiveError}}
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss index af7e27f481..8df18b25bc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss @@ -43,7 +43,9 @@ } .tb-widget-config-content { & > .mat-content { - height: 100%; + &.tb-widget-config-fill { + height: 100%; + } padding-top: 8px; @media #{$mat-xs} { padding-left: 8px; diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index cb96a493a1..fc7ad9f64b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -276,6 +276,7 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, elementClick: this.elementClick.bind(this), cardClick: this.cardClick.bind(this), click: this.click.bind(this), + invokeAction: this.invokeAction.bind(this), getActiveEntityInfo: this.getActiveEntityInfo.bind(this), openDashboardStateInSeparateDialog: this.openDashboardStateInSeparateDialog.bind(this), openDashboardStateInPopover: this.openDashboardStateInPopover.bind(this), @@ -1590,6 +1591,16 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, } } + private invokeAction($event: Event, actionName: string, additionalParams?: any) { + const descriptors = this.getActionDescriptors('javaScript'); + if (descriptors?.length) { + const found = descriptors.find(d => d.name === actionName); + if (found) { + this.handleWidgetAction($event, found, null, null, additionalParams); + } + } + } + private onWidgetAction($event: Event, action: WidgetAction) { if ($event) { $event.stopPropagation(); diff --git a/ui-ngx/src/app/modules/home/menu/menu-link.component.html b/ui-ngx/src/app/modules/home/menu/menu-link.component.html index 3d3823d12a..1be09e0ddd 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-link.component.html +++ b/ui-ngx/src/app/modules/home/menu/menu-link.component.html @@ -15,7 +15,8 @@ limitations under the License. --> - + {{section.icon}} {{section.customTranslate ? (section.name | customTranslate) : (section.name | translate)}} diff --git a/ui-ngx/src/app/modules/home/menu/menu-link.component.scss b/ui-ngx/src/app/modules/home/menu/menu-link.component.scss index 16bc26c7e2..7720091e79 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-link.component.scss +++ b/ui-ngx/src/app/modules/home/menu/menu-link.component.scss @@ -13,6 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -:host { +@import '../../../../scss/constants'; + +:host ::ng-deep { + a.is-new::after { + content: "NEW"; + font-size: 12px; + letter-spacing: .03125em; + transform: translate(6px, -6px); + display: inline-block; + border-radius: 10px; + line-height: 22px; + height: 20px; + background: #fff; + color: $tb-primary-color; + padding-left: 6px; + padding-right: 6px; + margin-left: -6px; + } } diff --git a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html index 31753751af..ca379c6e73 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html +++ b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html @@ -15,7 +15,8 @@ limitations under the License. --> - + {{section.icon}} {{section.customTranslate ? (section.name | customTranslate) : (section.name | translate)}} void; + export class WidgetContext { constructor(public dashboard: IDashboardComponent, @@ -343,6 +345,8 @@ export class WidgetContext { ...RxJSOperators }; + private destroyCallbacks: WidgetDestroyCallback[] = []; + registerPopoverComponent(popoverComponent: TbPopoverComponent) { this.popoverComponents.push(popoverComponent); popoverComponent.tbDestroy.subscribe(() => { @@ -382,6 +386,10 @@ export class WidgetContext { } } + registerDestroyCallback(destroyCallback: WidgetDestroyCallback) { + this.destroyCallbacks.push(destroyCallback); + } + showSuccessToast(message: string, duration: number = 1000, verticalPosition: NotificationVerticalPosition = 'bottom', horizontalPosition: NotificationHorizontalPosition = 'left', @@ -494,6 +502,13 @@ export class WidgetContext { labelPattern.destroy(); } this.labelPatterns.clear(); + this.destroyCallbacks.forEach((destroyCallback) => { + try { + destroyCallback() + } catch (_ignoredError) { /* empty */ } + } + ); + this.destroyCallbacks.length = 0; this.width = undefined; this.height = undefined; this.destroyed = true; diff --git a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts index a7ce0082cd..41dd189b63 100644 --- a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts @@ -36,4 +36,12 @@ export class AssetProfileTabsComponent extends EntityTabsComponent super.ngOnInit(); } + resolveTabIndex(tab: string): number { + if (tab === 'cf') { + return 1; + } else { + return super.resolveTabIndex(tab); + } + } + } diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts index 10ef434755..03aa7a1fa1 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts @@ -36,4 +36,12 @@ export class AssetTabsComponent extends EntityTabsComponent { super.ngOnInit(); } + resolveTabIndex(tab: string): number { + if (tab === 'cf') { + return 3; + } else { + return super.resolveTabIndex(tab); + } + } + } diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts index 4d8637d5d7..e83f1dd16a 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts @@ -77,7 +77,6 @@ import { EntityAliasesDialogComponent, EntityAliasesDialogData } from '@home/components/alias/entity-aliases-dialog.component'; - @Injectable() export class DashboardsTableConfigResolver { 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 1a762ac881..bfefdf5d4c 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 @@ -56,6 +56,14 @@ export class DeviceProfileTabsComponent extends EntityTabsComponent { super.ngOnInit(); } + resolveTabIndex(tab: string): number { + if (tab === 'cf') { + return 3; + } else { + return super.resolveTabIndex(tab); + } + } + } diff --git a/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts index 3363532b5f..72d0a08dd6 100644 --- a/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts @@ -84,6 +84,8 @@ import { DeviceCheckConnectivityDialogData } from '@home/pages/device/device-check-connectivity-dialog.component'; import { EntityId } from '@shared/models/id/entity-id'; +import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; interface DevicePageQueryParams extends PageQueryParam { deviceProfileId?: string; @@ -107,7 +109,8 @@ export class DevicesTableConfigResolver { private translate: TranslateService, private datePipe: DatePipe, private router: Router, - private dialog: MatDialog) { + private dialog: MatDialog, + private iotHubActions: IotHubActionsService) { this.config.entityType = EntityType.DEVICE; this.config.entityComponent = DeviceComponent; @@ -416,6 +419,12 @@ export class DevicesTableConfigResolver { isEnabled: () => true, onAction: ($event) => this.importDevices($event) }, + { + name: this.translate.instant('iot-hub.add-from-iot-hub'), + icon: 'hub', + isEnabled: () => true, + onAction: (_$event) => this.addDeviceFromIotHub() + }, ); this.config.addEntity = () => {this.deviceWizard(null); return of(null); }; } @@ -450,6 +459,14 @@ export class DevicesTableConfigResolver { this.router.navigateByUrl(url); } + addDeviceFromIotHub() { + this.iotHubActions.addItem(ItemType.DEVICE).subscribe(result => { + if (result?.descriptor) { + this.config.updateData(); + } + }); + } + importDevices($event: Event) { this.homeDialogs.importEntities(EntityType.DEVICE).subscribe((res) => { if (res) { diff --git a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts index 84412fabf4..fcc8298904 100644 --- a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts +++ b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts @@ -47,6 +47,7 @@ import { ScadaSymbolModule } from '@home/pages/scada-symbol/scada-symbol.module' import { GatewaysModule } from '@home/pages/gateways/gateways.module'; import { MobileModule } from '@home/pages/mobile/mobile.module'; import { AiModelModule } from '@home/pages/ai-model/ai-model.module'; +import { IotHubModule } from '@home/pages/iot-hub/iot-hub.module'; @NgModule({ exports: [ @@ -81,6 +82,7 @@ import { AiModelModule } from '@home/pages/ai-model/ai-model.module'; AccountModule, ScadaSymbolModule, AiModelModule, + IotHubModule, ] }) export class HomePagesModule { } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-alarm-rules-unavailable-page.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-alarm-rules-unavailable-page.component.html new file mode 100644 index 0000000000..18a47441ff --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-alarm-rules-unavailable-page.component.html @@ -0,0 +1,52 @@ + +
+ +
+
+ +
+
+ + +

+ {{ 'iot-hub.alarm-rules-unavailable-title-prefix' | translate }} + {{ 'iot-hub.alarm-rules-unavailable-tb-version' | translate }} +

+ +

+ {{ 'iot-hub.alarm-rules-unavailable-desc' | translate }} +

+ +
+ + +
+ +
+ {{ 'iot-hub.alarm-rules-unavailable-current' | translate:{ version: currentTbVersion } }} +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-alarm-rules-unavailable-page.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-alarm-rules-unavailable-page.component.scss new file mode 100644 index 0000000000..d958011810 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-alarm-rules-unavailable-page.component.scss @@ -0,0 +1,143 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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"; + +// Outer page — 16px padding around the white container, matches +// iot-hub-home. +:host { + display: block; + padding: 16px; + height: 100%; +} + +// Page container — same white card / dot pattern / blob layering +// rules as the iot-hub home page. +.tb-iot-hub-alarm-rules-unavailable { + position: relative; + background: white; + border-radius: 8px; + overflow: hidden; + height: 100%; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 800px; + background-image: radial-gradient(circle, rgba(0, 0, 0, 0.1) 1px, transparent 1px); + background-size: 20px 20px; + mask-image: linear-gradient(to bottom, black 0%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 0%, transparent 100%); + pointer-events: none; + z-index: 1; + } + + > *:not(.tb-iot-hub-blob) { + position: relative; + z-index: 2; + } +} + +// Soft colored glow blobs — copied verbatim from iot-hub-home so the +// page background matches. `color` is bound from the ALARM_RULE +// HeroTypeConfig entry. +.tb-iot-hub-blob { + position: absolute; + width: 568px; + height: 568px; + border-radius: 50%; + background: currentColor; + opacity: 0.16; + filter: blur(120px); + pointer-events: none; + z-index: 0; +} + +.tb-iot-hub-blob-left { + top: 56px; + left: -221px; +} + +.tb-iot-hub-blob-right { + top: 259px; + right: -196px; +} + +.tb-alarm-rules-banner-container { + height: 100%; + display: flex; + overflow-y: auto; + flex-direction: column; +} + +// Banner content stack — vertically centred in the card. +.tb-alarm-rules-banner { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 40px; + padding: 80px 40px; + margin: auto; +} + +.tb-alarm-rules-banner-image { + width: 446px; + max-width: 100%; + height: auto; +} + +.tb-alarm-rules-banner-title { + font-size: 28px; + font-weight: 500; + line-height: 36px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.87); + margin: 0; + max-width: 900px; +} + +.tb-alarm-rules-banner-version { + color: $tb-primary-color; +} + +.tb-alarm-rules-banner-desc { + font-size: 16px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.76); + margin: -24px 0 0; + max-width: 900px; +} + +.tb-alarm-rules-banner-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.tb-alarm-rules-banner-current { + font-size: 12px; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.4px; + color: rgba(0, 0, 0, 0.54); + margin-top: -28px; +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-alarm-rules-unavailable-page.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-alarm-rules-unavailable-page.component.ts new file mode 100644 index 0000000000..c44a46ce0f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-alarm-rules-unavailable-page.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 { Router } from '@angular/router'; +import { environment as env } from '@env/environment'; + +@Component({ + selector: 'tb-iot-hub-alarm-rules-unavailable-page', + standalone: false, + templateUrl: './iot-hub-alarm-rules-unavailable-page.component.html', + styleUrls: ['./iot-hub-alarm-rules-unavailable-page.component.scss'] +}) +export class TbIotHubAlarmRulesUnavailablePageComponent { + + // Same colour as the ALARM_RULE HeroTypeConfig entry on the home page. + readonly alarmRulesColor = '#d66f2e'; + + readonly currentTbVersion: string = env.tbVersion; + + constructor(private router: Router) {} + + goBack(): void { + void this.router.navigate(['/iot-hub']); + } + + upgradeInstance(): void { + window.open('https://thingsboard.io/docs/installation/upgrade-instructions/', '_blank'); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html new file mode 100644 index 0000000000..9e181dff63 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html @@ -0,0 +1,116 @@ + +
diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss new file mode 100644 index 0000000000..3163c89674 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss @@ -0,0 +1,360 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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: block; + padding: 16px; + height: 100%; +} + +.tb-creator-page { + background: white; + border-radius: 8px; + padding-left: 123px; + overflow: hidden; + display: flex; + height: 100%; +} + +.tb-iot-hub-empty-state { + position: relative; + .tb-no-data-bg, + .tb-no-service-bg { + flex: auto; + height: 100px; + } + + h3 { + font-size: 18px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + margin: 16px 0 8px; + } + + p { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); + margin: 0 0 24px; + } +} + +// Left column: creator info +.tb-creator-info { + width: 360px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 48px; + padding: 40px 24px 0 0; +} + +// Avatar +.tb-creator-avatar { + width: 140px; + height: 140px; + min-width: 140px; + min-height: 140px; + border-radius: 50%; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.06); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + color: rgba(0, 0, 0, 0.3); + } +} + +.tb-creator-name { + font-size: 28px; + font-weight: 500; + line-height: 36px; + letter-spacing: 0.07px; + color: rgba(0, 0, 0, 0.87); + margin: 0; +} + +.tb-creator-verified-badge { + font-size: 32px; + width: 32px; + height: 32px; + color: #00695c; + flex-shrink: 0; + cursor: default; +} + +.tb-creator-desc-container { + display: flex; + flex-direction: column; + gap: 1rem; + flex: 1; + min-height: 0; + overflow-y: auto; + @media #{$mat-gt-md} { + padding-bottom: 1rem; + } +} + +// Description +.tb-creator-desc { + font-size: 16px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.76); + margin: 0; + max-width: 800px; +} + +.tb-creator-link { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + height: 32px; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + text-decoration: none; + border-bottom: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + border-bottom: none; + } + + mat-icon, tb-icon { + font-size: 24px; + width: 24px; + height: 24px; + flex-shrink: 0; + color: rgba(0, 0, 0, 0.54); + } + + &.tb-creator-link-primary { + color: $tb-primary-color; + + mat-icon { + color: $tb-primary-color; + } + } +} + +a.tb-creator-link:hover span { + color: $tb-primary-color; + text-decoration: underline; +} + +// Vertical divider (desktop) +.tb-creator-divider { + width: 1px; + background: rgba(0, 0, 0, 0.12); + flex-shrink: 0; + align-self: stretch; +} + +// Right column: published items +.tb-creator-items { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + padding-top: 40px; + padding-left: 24px; + padding-right: 123px; + + ::ng-deep { + .tb-search-divider { + margin-left: -24px; + margin-right: -123px; + } + + .tb-results-container { + padding-right: 123px; + padding-bottom: 40px; + margin-right: -123px; + } + + .tb-search-card-grid { + grid-template-columns: repeat(5, 1fr); + + &.tb-search-card-grid-compact { + grid-template-columns: repeat(3, 1fr); + } + } + } +} + +.tb-creator-items-heading { + font-size: 20px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.87); + margin: 0 0 24px; +} + +// Responsive + +@media #{$mat-lt-xxl} { + .tb-creator-page { + padding-left: 40px; + } + + .tb-creator-items { + padding-right: 40px; + + ::ng-deep { + .tb-search-divider { + margin-right: -40px; + } + + .tb-results-container { + padding-right: 40px; + margin-right: -40px; + } + + .tb-search-card-grid { + grid-template-columns: repeat(4, 1fr); + } + } + } +} + +@media #{$mat-lt-xl} { + .tb-creator-info { + width: 300px; + } + + .tb-creator-items { + ::ng-deep { + .tb-search-card-grid { + grid-template-columns: repeat(3, 1fr); + + &.tb-search-card-grid-compact { + grid-template-columns: repeat(2, 1fr); + } + } + } + } +} + +@media #{$mat-lt-xmd} { + + .tb-creator-items { + ::ng-deep { + .tb-search-card-grid { + grid-template-columns: repeat(2, 1fr); + + &.tb-search-card-grid-compact { + grid-template-columns: repeat(1, 1fr); + } + } + } + } +} + +@media #{$mat-lt-lg} { + :host { + height: auto; + flex: 1; + } + + .tb-creator-page { + flex-direction: column; + padding: 0; + gap: 40px; + } + + .tb-creator-info { + width: 100%; + padding: 40px 24px 0 24px; + } + + .tb-creator-items { + padding-top: 0; + padding-right: 24px; + + ::ng-deep { + .tb-search-divider { + margin-right: -24px; + } + + .tb-results-container { + padding-right: 24px; + margin-right: -24px; + } + } + } + + .tb-creator-items-heading { + margin-bottom: 0; + } + + .tb-creator-divider { + width: 100%; + height: 1px; + align-self: auto; + } +} + +@media #{$mat-lt-sm} { + .tb-creator-page { + gap: 24px; + } + + .tb-creator-info { + padding: 16px 16px 0 16px; + gap: 24px; + } + + .tb-creator-items { + padding-left: 16px; + padding-right: 16px; + + ::ng-deep { + .tb-search-divider { + margin-left: -16px; + margin-right: -16px; + } + + .tb-results-container { + padding-right: 16px; + margin-right: -16px; + } + + .tb-search-card-grid { + grid-template-columns: repeat(1, 1fr); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts new file mode 100644 index 0000000000..d4a992f50c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts @@ -0,0 +1,107 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, OnInit, OnDestroy } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { CreatorView } from '@shared/models/iot-hub/iot-hub-creator.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; + +@Component({ + selector: 'tb-iot-hub-creator-profile', + standalone: false, + templateUrl: './iot-hub-creator-profile.component.html', + styleUrls: ['./iot-hub-creator-profile.component.scss'] +}) +export class TbIotHubCreatorProfileComponent implements OnInit, OnDestroy { + + creator: CreatorView; + creatorId: string; + isLoading = false; + hasError = false; + + private retryTimer: any = null; + private destroy$ = new Subject(); + + constructor( + private route: ActivatedRoute, + private router: Router, + private iotHubApiService: IotHubApiService + ) {} + + ngOnInit(): void { + this.route.params.pipe(takeUntil(this.destroy$)).subscribe(params => { + this.creatorId = params['creatorId']; + this.loadCreator(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + getAvatarUrl(): string | null { + return this.creator?.avatarUrl ? this.iotHubApiService.resolveResourceUrl(this.creator.avatarUrl) : null; + } + + getWebsiteLabel(): string { + return this.creator?.website?.replace(/^https?:\/\//, '') || ''; + } + + goBack(): void { + void this.router.navigate(['/iot-hub']); + } + + retryLoadCreator(): void { + // Debounce frequent retry clicks: show the loading spinner + // immediately and defer the actual loadCreator() call by 350ms + // so rapid-fire clicks coalesce into a single network round-trip. + // hasError is left as-is until the next request actually succeeds. + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + } + this.isLoading = true; + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.loadCreator(); + }, 350); + } + + private loadCreator(): void { + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + this.isLoading = true; + // hasError stays as-is until the request actually succeeds — + // cleared in the `next` callback below. + this.iotHubApiService.getCreatorProfile(this.creatorId, { ignoreLoading: true, ignoreErrors: true }).subscribe({ + next: creator => { + this.creator = creator; + this.isLoading = false; + this.hasError = false; + }, + error: () => { + this.creator = null; + this.isLoading = false; + this.hasError = true; + } + }); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts new file mode 100644 index 0000000000..e1171095a2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts @@ -0,0 +1,46 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isUUID(s: string | null | undefined): s is string { + return !!s && UUID_RE.test(s); +} + +export function typeSegment(t: ItemType): string | undefined { + switch (t) { + case ItemType.WIDGET: return 'widgets'; + case ItemType.DASHBOARD: return 'dashboards'; + case ItemType.SOLUTION_TEMPLATE: return 'solution-templates'; + case ItemType.CALCULATED_FIELD: return 'calculated-fields'; + case ItemType.ALARM_RULE: return 'alarm-rules'; + case ItemType.RULE_CHAIN: return 'rule-chains'; + case ItemType.DEVICE: return 'devices'; + default: return undefined; + } +} + +export function isPublished(v: MpItemVersionView): boolean { + return !!v.publishedTime && v.publishedTime > 0; +} + +export interface DeepLinkOpenItem { + version: MpItemVersionView; + preview: boolean; +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html new file mode 100644 index 0000000000..71506d090e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html @@ -0,0 +1,337 @@ + +
+ +
+
+ + + @if (installedItemsCount > 0) { + + } + + +
+ + @for (heroType of heroTypes; track heroType.type) { +
+ +
+ } + +
+

{{ 'iot-hub.home-title' | translate }}

+
+ {{ 'iot-hub.home-subtitle-prefix' | translate }} + @for (ht of heroTypes; track ht.type; let i = $index; let last = $last) { + @if (last) {& }{{ ht.labelKey | translate }}@if (!last) {,} + @if (i === 2) { + + } + } +
+ + + @if (searchText) { + + } + + + + @if (searchLoading) { + + + + } @else if (searchResults.length === 0 && searchLoaded) { + +
+ search_off + @if (searchText?.trim()) { + {{ 'iot-hub.no-search-results' | translate:{ search: searchText.trim() } }} + } @else { + {{ 'iot-hub.no-items-available' | translate }} + } +
+
+ } @else { + @for (group of searchResultGroups; track group.type) { + + @for (item of group.items; track item.id) { + + @if (isCompactType(item.type)) { +
+ {{ getCompactIcon(item) }} +
+ } @else { +
+ @if (getItemImage(item); as imgUrl) { + + } @else { + {{ getItemTypeIcon(item.type) }} + } +
+ } +
+ {{ item.name }} + + person + {{ item.creatorDisplayName }} + +
+
+ } +
+ } + } + +
+
+
+ + +
+ +
+ @for (card of categoryCards; track card.type) { +
+ + {{ card.titleKey | translate }} + chevron_right + +
+ +
+
+ } +
+ + @if (hasError) { +
+
+

{{ 'iot-hub.network-server-unavailable' | translate }}

+

{{ 'iot-hub.network-server-unavailable-text' | translate }}

+ + @if (isLoading) { +
+
+ +
+
+ } +
+ } + + @if (!isLoading && !hasError) { + @if (hasAnyPopularItems()) { +
+ + } + + + @if (popularDevices.length) { +
+ +
+ @for (item of popularDevices; track item.id) { + + + } +
+
+ } + + + @if (popularSolutionTemplates.length) { +
+ +
+ @for (item of popularSolutionTemplates; track item.id) { + + + } +
+
+ } + + + @if (popularWidgets.length) { +
+ +
+ @for (item of popularWidgets; track item.id) { + + + } +
+
+ } + + + @if (popularCalcFields.length) { +
+ +
+ @for (item of popularCalcFields; track item.id) { + + + } +
+
+ } + + + @if (popularAlarmRules.length) { +
+ +
+ @for (item of popularAlarmRules; track item.id) { + + + } +
+
+ } + + + @if (popularRuleChains.length) { +
+ +
+ @for (item of popularRuleChains; track item.id) { + + + } +
+
+ } + } + + @if (isLoading && !hasError) { +
+ +
+ } +
+ + + @if (!isLoading && !hasError) { +
+
+

{{ 'iot-hub.become-a-creator' | translate }}

+

{{ 'iot-hub.become-creator-text' | translate }}

+ +
+ } +
diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss new file mode 100644 index 0000000000..f0768bf330 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss @@ -0,0 +1,783 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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"; + +// Outer page — 16px padding around the white container +:host { + display: block; + padding: 16px; +} + +// Page container — white card with 8px radius, matches Design "page-container" +.tb-iot-hub-home { + position: relative; + background: white; + border-radius: 8px; + overflow: hidden; + + // Dot grid pattern — covers top 800px, fades out via gradient mask. + // Layered ABOVE the colored blobs so the dots overlay the glow. + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 800px; + background-image: radial-gradient(circle, rgba(0, 0, 0, 0.1) 1px, transparent 1px); + background-size: 20px 20px; + mask-image: linear-gradient(to bottom, black 0%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 0%, transparent 100%); + pointer-events: none; + z-index: 1; + } + + > *:not(.tb-iot-hub-installed-btn):not(.tb-iot-hub-blob) { + position: relative; + z-index: 2; + } +} + +// Installed Items button — Design: top-right, white bg, primary border, card shadow +.tb-iot-hub-installed-btn { + position: absolute; + top: 24px; + right: 24px; + z-index: 3; + background: white; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.08), 0 4px 8px 0 rgba(0, 0, 0, 0.08); + + &.mat-primary { + --mat-button-outlined-outline-color: #{$tb-primary-color}; + } +} + +// Hero section +.tb-iot-hub-hero { + text-align: center; + padding: 140px 40px 80px; + position: relative; + overflow: hidden; +} + +// Soft colored glow blobs — direct CSS port of the design's two +// 568×568 ellipses (Ellipse 399 right, Ellipse 400 left) with layer +// opacity 0.16. Children of the page container so the halo spreads +// across the whole page background, not just the hero. Fill comes +// from the active hero type via `currentColor` (driven by +// `[style.color]`). +// +// The design's "Layer Blur 240" is a Figma renderer parameter — when +// translated to CSS `filter: blur(<σ>)` the visual match is around +// σ=120 (Figma's radius is roughly twice the Gaussian σ used by CSS). +// At σ=240 the kernel reaches well outside the 568px ellipse and peak +// intensity collapses to ~58 % before opacity is applied, which +// produced an almost invisible wash. σ=120 keeps the centre near +// full strength while still spreading the halo across the page. +.tb-iot-hub-blob { + position: absolute; + width: 568px; + height: 568px; + border-radius: 50%; + background: currentColor; + opacity: 0.16; + filter: blur(120px); + pointer-events: none; + z-index: 0; + transition: color 0.6s ease; + will-change: filter; +} + +// Ellipse 400 — top-left corner at (-221, 56) inside the page-container. +.tb-iot-hub-blob-left { + top: 56px; + left: -221px; +} + +// Ellipse 399 — top-left corner at (1266, 259) inside a 1638-wide +// page-container. Anchored to the right edge so it stays in place on +// any viewport width: 1638 - (1266 + 568) = -196. +.tb-iot-hub-blob-right { + top: 259px; + right: -196px; +} + +// Floating icons cluster per type — one composite SVG with all icons baked in. +// Active variant fades in expanding outward from the hero center; inactive +// variants fade out collapsing back toward the center. This is achieved by +// uniformly scaling the wrapper around its centre — peripheral icons travel +// further than central ones, giving the "burst from / collapse to centre" +// motion shown in the design prototype. +.tb-iot-hub-hero-icons { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; + transform: scale(0.8); + transform-origin: 50% 50%; + transition: opacity 0.4s ease-out, transform 0.6s cubic-bezier(0.4, 0, 0.2, 1); + + &.active { + opacity: 1; + transform: scale(1); + } +} + +// Cluster SVG — sits above the title, centered, scales with viewport. +// Aspect ratio is preserved by the SVG's intrinsic dimensions. +.tb-iot-hub-hero-cluster { + position: absolute; + top: 40px; + left: 50%; + transform: translateX(-50%); + width: min(890px, calc(100% - 80px)); + height: auto; + display: block; + pointer-events: none; +} + +.tb-iot-hub-hero-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 40px; + position: relative; + z-index: 1; +} + +.tb-iot-hub-hero-title { + font-size: 56px; + font-weight: 500; + line-height: 1.2; + letter-spacing: 0.14px; + color: rgba(0, 0, 0, 0.87); + margin: 0; +} + +.tb-iot-hub-hero-subtitle { + font-size: 20px; + line-height: 24px; + letter-spacing: 0.1px; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 6px; +} + +.tb-iot-hub-hero-subtitle-break { + flex-basis: 100%; + height: 0; +} + +.tb-iot-hub-hero-prefix { + font-weight: 400; + color: rgba(0, 0, 0, 0.76); +} + +.tb-iot-hub-hero-keyword { + font-weight: 600; + color: rgba(0, 0, 0, 0.54); + opacity: 0.48; + cursor: pointer; + transition: color 0.3s ease, opacity 0.3s ease; + + &.active { + color: var(--keyword-color); + opacity: 1; + } + + &:hover { + opacity: 0.8; + } +} + +.tb-iot-hub-hero-search { + width: 600px; + max-width: 100%; + + ::ng-deep .mat-mdc-text-field-wrapper { + background: white; + border-radius: 8px; + box-shadow: + 0 7px 16px 0 rgba(0, 0, 0, 0.04), + 0 29px 29px 0 rgba(0, 0, 0, 0.03), + 0 64px 38px 0 rgba(0, 0, 0, 0.02), + 0 114px 46px 0 rgba(0, 0, 0, 0.01), + 0 178px 50px 0 rgba(0, 0, 0, 0); + } +} + +// Search popup panel (mat-autocomplete overlay) +::ng-deep .tb-search-popup-panel.mat-mdc-autocomplete-panel { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + padding: 16px 0 0; + max-height: 424px; + box-shadow: + 0 7px 16px 0 rgba(0, 0, 0, 0.04), + 0 29px 29px 0 rgba(0, 0, 0, 0.03), + 0 64px 38px 0 rgba(0, 0, 0, 0.02), + 0 114px 46px 0 rgba(0, 0, 0, 0.01), + 0 178px 50px 0 rgba(0, 0, 0, 0); + + // Section group — 12px gap between groups + .mat-mdc-optgroup + .mat-mdc-optgroup .mat-mdc-optgroup-label { + margin-top: 12px; + } + + // Section title — Design: 14px Medium, 0.25 tracking, black-tertiary, 4px V / 16px H + .mat-mdc-optgroup .mat-mdc-optgroup-label { + padding: 4px 16px; + min-height: 28px; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + white-space: nowrap; + + .mdc-list-item__primary-text { + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + } + } + + // Item row — Design: 8px V / 16px H, 16px gap, centered + .mat-mdc-optgroup .mat-mdc-option:not(.mat-mdc-option-multiple) { + padding: 8px 16px; + min-height: auto; + height: auto; + + &:hover:not(.mdc-list-item--disabled), + &.mat-mdc-option-active:not(.mdc-list-item--disabled) { + background: rgba(0, 0, 0, 0.04); + } + + .mat-icon, tb-icon { + margin-right: 0; + } + + .mdc-list-item__primary-text { + display: flex; + align-items: center; + gap: 16px; + width: 100%; + } + } + + // Thumbnail — Design: 99px x 56px, 4px radius, 1px border, 4% bg + .tb-search-popup-thumbnail { + width: 99px; + height: 56px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: rgba(0, 0, 0, 0.04); + overflow: hidden; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + } + + .tb-search-popup-thumb-placeholder { + font-size: 24px; + width: 24px; + height: 24px; + color: rgba(0, 0, 0, 0.38); + } + + // Icon square — Design: 56x56, 4.667px radius, colored bg + .tb-search-popup-icon-square { + width: 56px; + height: 56px; + border-radius: 4.667px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + } + + .tb-search-popup-icon-inner { + font-size: 28px; + width: 28px; + height: 28px; + color: white !important; + --tb-icon-color: white; + } + + // Text block — Design: flex-col, 4px gap, flex 1 + .tb-search-popup-text { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; + } + + // Name — Design: 14px Medium, 0.25 tracking, black-primary, ellipsis + .tb-search-popup-name { + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + // Author — Design: 12px Regular, 0.25 tracking, black-tertiary, 16px person icon + .tb-search-popup-author { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + white-space: nowrap; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + margin-right: 0; + color: rgba(0, 0, 0, 0.54); + } + } + + // Loading + .tb-search-popup-loading { + pointer-events: none; + opacity: 1 !important; + display: flex; + justify-content: center; + padding: 24px 16px; + min-height: auto; + } + + // No results + .tb-search-popup-no-results { + pointer-events: none; + opacity: 1 !important; + } + + .tb-search-popup-no-results-content { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 0; + color: rgba(0, 0, 0, 0.87); + font-size: 14px; + font-weight: 500; + line-height: 20px; + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + color: rgba(0, 0, 0, 0.76); + } + } + + // Footer — Design: 40px h, 8px V / 16px H, 1px top border, tb-primary text + .tb-search-popup-footer { + position: sticky; + bottom: 0; + border-top: 1px solid rgba(0, 0, 0, 0.12); + height: 40px; + min-height: 40px; + padding: 8px 16px; + display: flex; + align-items: center; + justify-content: flex-start; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + color: $tb-primary-color; + cursor: pointer; + background: white; + white-space: nowrap; + + &:hover { + background: #f5f5f5; + } + } +} + +// Main content — wraps categories + item sections +// Category cards — Design: centered 1200px container, 3-col rows +.tb-iot-hub-categories { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +// Card — design: flex-col, gap=24, pt=24, rounded-8, h=220, overflow clipped +// to crop the scaled hover image. The hover state mirrors the design's +// SMART_ANIMATE on hover (200ms ease-out): the image scales up slightly +// and the title chevron fades+slides in. +.tb-iot-hub-category-card { + border-radius: 8px; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + padding-top: 24px; + height: 220px; + border: 1px solid rgba(0, 0, 0, 0.12); + overflow: hidden; + + &:hover { + .tb-iot-hub-category-img img { + // 408 / 387 ≈ 1.054 — design grows the img frame from 387×148 → 408×156 + transform: scale(1.054); + } + .tb-iot-hub-category-chevron { + opacity: 1; + width: 24px; + margin-left: 4px; + transform: translateX(0); + } + } +} + +// Title — design: 18px Medium, centered horizontally with chevron, 4px gap +.tb-iot-hub-category-title { + font-size: 18px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.87); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.tb-iot-hub-category-title-text { + white-space: nowrap; +} + +// Chevron — hidden in default state, slides in on card hover (200ms ease-out) +.tb-iot-hub-category-chevron { + width: 0; + height: 24px; + margin-left: 0; + opacity: 0; + overflow: hidden; + transform: translateX(-4px); + font-size: 24px; + color: rgba(0, 0, 0, 0.87); + transition: + opacity 0.2s ease-out, + transform 0.2s ease-out, + width 0.2s ease-out, + margin-left 0.2s ease-out; +} + +// Card image wrapper — fixed 148px tall; overflow visible lets the 5.4% +// hover scale bleed past the wrapper. The card itself has overflow: +// hidden, so the bleed is still clipped at the card edges. +.tb-iot-hub-category-img { + width: 100%; + height: 148px; + flex-shrink: 0; + overflow: visible; + + img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center top; + transition: transform 0.2s ease-out; + } +} + +// Per-card flat background colors sampled from the design. +.category-devices { background: #ccd5ff; } +.category-solutions { background: #b7d8ff; } +.category-widgets { background: #a3ffc3; } +.category-calc-fields { background: #bcedff; } +.category-alarm-rules { background: #ffe6cc; } +.category-rule-chains { background: #ebd1ff; } + +// "Most popular in IoT Hub" title — design: 28/36 Medium, sits between +// the post-cards divider and the first popular section. +.tb-iot-hub-most-popular-title { + font-size: 28px; + font-weight: 500; + line-height: 36px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.87); + margin: 0; +} + +.tb-iot-hub-empty-state { + position: relative; + .tb-no-data-bg, + .tb-no-service-bg { + flex: auto; + height: 100px; + } + + h3 { + font-size: 18px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + margin: 16px 0 8px; + } + + p { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); + margin: 0 0 24px; + } +} + +// Sections — inside main-content, no individual padding needed +.tb-iot-hub-section { +} + +// Section header — mat-button, Design: height=40, font-size=20, font-weight=500 +.tb-iot-hub-section-header { + font-size: 20px; + font-weight: 500; + line-height: 40px; + color: rgba(0, 0, 0, 0.87); + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + +} + +// Big card row — matches Design "cards-row" (5 cards, gap=20, mt=12 from header) +.tb-iot-hub-big-cards-row { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 20px; + margin-top: 12px; +} + +// Compact card grid — matches Design "cards" (3 columns, row gap=20, col gap=20) +.tb-iot-hub-compact-cards-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-top: 12px; +} + +// Divider — matches Design "divider" (1px, rgba(0,0,0,0.12)) +.tb-iot-hub-divider { + height: 1px; + background: rgba(0, 0, 0, 0.12); +} + +// Bleed variant — used inside main-content (which has 40px horizontal +// padding) to span the full page-container width like in the design. +.tb-iot-hub-divider-bleed { + margin: 0 -40px; +} + +// Become a Creator — matches Design (pt=64, text centered, button at y=156) +.tb-iot-hub-become-creator { + text-align: center; + padding: 64px 40px; + + h2 { + font-size: 24px; + font-weight: 500; + line-height: 32px; + color: rgba(0, 0, 0, 0.87); + margin: 0 0 8px; + } + + p { + font-size: 14px; + line-height: 20px; + color: rgba(0, 0, 0, 0.54); + margin: 0 0 32px; + } +} + +// Page load animation — Design: Default→appear-first, Smart Animate 1.02s, Gentle easing +// Hero elements fade in + slide DOWN 20px; categories fade in + slide UP 20px +@keyframes iot-hub-fade-slide-down { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes iot-hub-fade-slide-up { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.tb-iot-hub-hero-title, +.tb-iot-hub-hero-subtitle, +.tb-iot-hub-hero-search { + animation: iot-hub-fade-slide-down 1s cubic-bezier(0.19, 1, 0.22, 1) 0.2s both; +} + +.tb-iot-hub-categories { + animation: iot-hub-fade-slide-up 1s cubic-bezier(0.19, 1, 0.22, 1) 0.2s both; +} + +// Responsive — breakpoints from design: XXXL=2448, XXL=1920, XL=1600, LG=1280, MD=960, SM=600, XS=375 + +// ≥2448px: 6-col big cards, 4-col compact +@media #{$mat-gt-xxl} { + .tb-iot-hub-big-cards-row { + grid-template-columns: repeat(6, 1fr); + } + + .tb-iot-hub-compact-cards-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +// ≤1919px: 4-col big cards, 3-col compact +@media #{$mat-lt-xl} { + .tb-iot-hub-big-cards-row { + grid-template-columns: repeat(4, 1fr); + } +} + +// ≤1599px: 3-col big cards, 2-col compact +@media #{$mat-lt-xmd} { + .tb-iot-hub-big-cards-row { + grid-template-columns: repeat(3, 1fr); + } + + .tb-iot-hub-compact-cards-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +// ≤1279px: 2-col big cards, 1-col compact, 2-col categories, smaller icons +@media #{$mat-lt-lg} { + .tb-iot-hub-big-cards-row { + grid-template-columns: repeat(2, 1fr); + } + + .tb-iot-hub-compact-cards-grid { + grid-template-columns: repeat(1, 1fr); + } + + .tb-iot-hub-categories { + grid-template-columns: repeat(2, 1fr); + } + + .tb-iot-hub-hero { + padding: 140px 24px 60px; + } + + .tb-iot-hub-hero-title { + font-size: 40px; + } + + .tb-iot-hub-hero-subtitle { + font-size: 18px; + line-height: 22px; + } + + .tb-iot-hub-main-content { + padding: 0 24px 64px; + } + + .tb-iot-hub-become-creator { + padding: 64px 24px; + } + + // Cluster shrinks with viewport — keep some gutter on the sides + .tb-iot-hub-hero-cluster { + top: 30px; + width: min(900px, calc(100% - 48px)); + } +} + +// ≤959px: cluster sits closer to the title and uses the full content width +@media #{$mat-lt-md} { + .tb-iot-hub-hero-cluster { + top: 64px; + width: calc(100% - 32px); + } +} + +// ≤599px: only top pair visible (51px), bottom pair hidden +@media #{$mat-lt-sm} { + :host { + padding: 8px; + } + + .tb-iot-hub-hero { + padding: 140px 16px 40px; + } + + .tb-iot-hub-categories { + grid-template-columns: 1fr; + } + + .tb-iot-hub-big-cards-row { + grid-template-columns: 1fr; + } + + .tb-iot-hub-main-content { + padding: 0 16px 48px; + } + + .tb-iot-hub-become-creator { + padding: 48px 16px; + } + + .tb-iot-hub-installed-btn { + top: 12px; + right: auto; + left: 50%; + transform: translateX(-50%); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts new file mode 100644 index 0000000000..87f8eee0b2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts @@ -0,0 +1,556 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; +import { Router } from '@angular/router'; +import { MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { BreakpointObserver } from '@angular/cdk/layout'; +import { forkJoin, Subject, Subscription } from 'rxjs'; +import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; +import { MediaBreakpoints } from '@shared/models/constants'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { getItemTypeIcon, ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { TranslateService } from '@ngx-translate/core'; +import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; +import { resolveIotHubItemImageUrl } from '@home/components/iot-hub/iot-hub-utils'; + +interface CategoryCard { + type: ItemType; + titleKey: string; + icon: string; + cssClass: string; + image: string; +} + +interface HeroTypeConfig { + type: ItemType; + labelKey: string; + color: string; + icon: string; +} + +interface SearchResultGroup { + type: ItemType; + items: MpItemVersionView[]; +} + +const SEARCH_GROUP_ORDER: ItemType[] = [ + ItemType.DEVICE, ItemType.SOLUTION_TEMPLATE, ItemType.WIDGET, + ItemType.CALCULATED_FIELD, ItemType.ALARM_RULE, ItemType.RULE_CHAIN +]; + +@Component({ + selector: 'tb-iot-hub-home', + standalone: false, + templateUrl: './iot-hub-home.component.html', + styleUrls: ['./iot-hub-home.component.scss'] +}) +export class TbIotHubHomeComponent implements OnInit, OnDestroy { + + readonly ItemType = ItemType; + + searchText = ''; + searchResults: MpItemVersionView[] = []; + searchResultGroups: SearchResultGroup[] = []; + searchLoaded = false; + searchLoading = false; + @ViewChild(MatAutocompleteTrigger) searchAutoTrigger: MatAutocompleteTrigger; + @ViewChild('searchInput', {read: ElementRef}) searchInputRef: ElementRef; + private searchSubject = new Subject(); + private searchSubscription: Subscription; + + + heroTypes: HeroTypeConfig[] = [ + { + type: ItemType.DEVICE, labelKey: 'item.type-device-plural', color: '#4b63cc', + icon: 'assets/iot-hub/hero-device-cluster.svg' + }, + { + type: ItemType.SOLUTION_TEMPLATE, labelKey: 'item.type-solution-template-plural', color: '#2b6bb4', + icon: 'assets/iot-hub/hero-solution-template-cluster.svg' + }, + { + type: ItemType.WIDGET, labelKey: 'item.type-widget-plural', color: '#2c9755', + icon: 'assets/iot-hub/hero-widget-cluster.svg' + }, + { + type: ItemType.CALCULATED_FIELD, labelKey: 'item.type-calculated-field-plural', color: '#3cb4e0', + icon: 'assets/iot-hub/hero-calculated-field-cluster.svg' + }, + { + type: ItemType.ALARM_RULE, labelKey: 'item.type-alarm-rule-plural', color: '#d66f2e', + icon: 'assets/iot-hub/hero-alarm-rule-cluster.svg' + }, + { + type: ItemType.RULE_CHAIN, labelKey: 'item.type-rule-chain-plural', color: '#a95ae2', + icon: 'assets/iot-hub/hero-rule-chain-cluster.svg' + } + ]; + + activeHeroType: HeroTypeConfig = this.heroTypes[0]; + heroIconsReady = false; + private heroInterval: any; + + categoryCards: CategoryCard[] = [ + { type: ItemType.DEVICE, titleKey: 'iot-hub.device-library', icon: 'memory', cssClass: 'category-devices', image: 'assets/iot-hub/category-device-library-img.png' }, + { type: ItemType.SOLUTION_TEMPLATE, titleKey: 'item.type-solution-template-plural', icon: 'integration_instructions', cssClass: 'category-solutions', image: 'assets/iot-hub/category-solution-templates-img.png' }, + { type: ItemType.WIDGET, titleKey: 'item.type-widget-plural', icon: 'widgets', cssClass: 'category-widgets', image: 'assets/iot-hub/category-widgets-img.png' }, + { type: ItemType.CALCULATED_FIELD, titleKey: 'item.type-calculated-field-plural', icon: 'functions', cssClass: 'category-calc-fields', image: 'assets/iot-hub/category-calculated-fields-img.png' }, + { type: ItemType.ALARM_RULE, titleKey: 'item.type-alarm-rule-plural', icon: 'notification_important', cssClass: 'category-alarm-rules', image: 'assets/iot-hub/category-alarm-rules-img.png' }, + { type: ItemType.RULE_CHAIN, titleKey: 'item.type-rule-chain-plural', icon: 'account_tree', cssClass: 'category-rule-chains', image: 'assets/iot-hub/category-rule-chains-img.png' } + ]; + + popularWidgets: MpItemVersionView[] = []; + popularSolutionTemplates: MpItemVersionView[] = []; + popularCalcFields: MpItemVersionView[] = []; + popularAlarmRules: MpItemVersionView[] = []; + popularRuleChains: MpItemVersionView[] = []; + popularDevices: MpItemVersionView[] = []; + + installedWidgets: IotHubInstalledItem[] = []; + installedSolutionTemplates: IotHubInstalledItem[] = []; + installedDeviceCounts: Record = {}; + installedCalcFieldCounts: Record = {}; + installedAlarmRuleCounts: Record = {}; + installedRuleChainCounts: Record = {}; + installedItemsCount = 0; + + isLoading = true; + hasError = false; + private retryTimer: any = null; + + bigCardCount = 5; + compactCardCount = 6; + private breakpointSubscription: Subscription; + + constructor( + private router: Router, + private iotHubApiService: IotHubApiService, + private iotHubActions: IotHubActionsService, + private translate: TranslateService, + private breakpointObserver: BreakpointObserver + ) {} + + ngOnInit(): void { + this.updateCardCounts(); + this.loadPopularItems(); + this.breakpointSubscription = this.breakpointObserver.observe([ + MediaBreakpoints['lt-sm'], + MediaBreakpoints['lt-md'], + MediaBreakpoints['lt-lg'], + MediaBreakpoints['lt-xmd'], + MediaBreakpoints['lt-xl'], + MediaBreakpoints['gt-xxl'] + ]).subscribe(() => { + const prev = { big: this.bigCardCount, compact: this.compactCardCount }; + this.updateCardCounts(); + if (this.bigCardCount !== prev.big || this.compactCardCount !== prev.compact) { + this.loadPopularItems(); + } + }); + this.searchSubscription = this.searchSubject.pipe( + debounceTime(300), + distinctUntilChanged(), + switchMap(text => { + this.searchLoading = true; + const sortOrder: SortOrder = { property: 'totalInstallCount', direction: Direction.DESC }; + const pageLink = new PageLink(10, 0, text.trim() || null, sortOrder); + const query = new MpItemVersionQuery(pageLink); + return this.iotHubApiService.getPublishedVersions(query, { ignoreLoading: true }); + }) + ).subscribe(result => { + this.searchResults = result.data; + this.searchResultGroups = this.groupSearchResults(result.data); + this.searchLoaded = true; + this.searchLoading = false; + }); + // One-tick delay so Angular renders icons in hidden state first, then triggers transition + requestAnimationFrame(() => { + this.heroIconsReady = true; + this.startHeroCycle(); + }); + } + + ngOnDestroy(): void { + this.stopHeroCycle(); + this.searchSubscription?.unsubscribe(); + this.breakpointSubscription?.unsubscribe(); + } + + onHeroTypeHover(config: HeroTypeConfig): void { + this.stopHeroCycle(); + this.activeHeroType = config; + } + + onHeroTypeLeave(): void { + this.startHeroCycle(); + } + + private startHeroCycle(): void { + this.stopHeroCycle(); + this.heroInterval = setInterval(() => { + const idx = this.heroTypes.indexOf(this.activeHeroType); + this.activeHeroType = this.heroTypes[(idx + 1) % this.heroTypes.length]; + }, 3000); + } + + private stopHeroCycle(): void { + if (this.heroInterval) { + clearInterval(this.heroInterval); + this.heroInterval = null; + } + } + + onSearchInput(): void { + this.searchLoading = true; + this.searchSubject.next(this.searchText || ''); + } + + onSearchFocus(): void { + if (!this.searchLoaded) { + this.searchLoading = true; + this.searchSubject.next(this.searchText || ''); + } + } + + searchDisplayFn = (value: any): string => { + return typeof value === 'string' ? value : this.searchText || ''; + }; + + clearSearch(): void { + this.searchText = ''; + this.searchSubject.next(''); + this.searchInputRef?.nativeElement?.focus(); + setTimeout(() => this.searchAutoTrigger?.openPanel()); + } + + onSearch(): void { + this.seeAllResults(); + } + + seeAllResults(): void { + this.searchAutoTrigger?.closePanel(); + const search = this.searchText?.trim() || undefined; + void this.router.navigate(['/iot-hub/search'], { queryParams: { search } }); + } + + isCompactType(type: ItemType): boolean { + return type === ItemType.CALCULATED_FIELD + || type === ItemType.ALARM_RULE + || type === ItemType.RULE_CHAIN; + } + + getCompactIcon(item: MpItemVersionView): string { + return item.icon || getItemTypeIcon(item.type); + } + + getItemImage(item: MpItemVersionView): string | null { + return resolveIotHubItemImageUrl(item, this.iotHubApiService); + } + + getItemTypeIcon(type: ItemType): string { + return getItemTypeIcon(type); + } + + getSearchGroupLabel(type: ItemType): string { + const key = itemTypeTranslations.get(type); + return key ? this.translate.instant(key + '-plural') : type; + } + + navigateToBrowse(type: ItemType): void { + void this.router.navigate(['/iot-hub', this.getTypeRoute(type)]); + } + + hasAnyPopularItems(): boolean { + return this.popularDevices.length > 0 + || this.popularSolutionTemplates.length > 0 + || this.popularWidgets.length > 0 + || this.popularCalcFields.length > 0 + || this.popularAlarmRules.length > 0 + || this.popularRuleChains.length > 0; + } + + private getTypeRoute(type: ItemType): string { + switch (type) { + case ItemType.WIDGET: return 'widgets'; + case ItemType.DASHBOARD: return 'dashboards'; + case ItemType.SOLUTION_TEMPLATE: return 'solution-templates'; + case ItemType.CALCULATED_FIELD: return 'calculated-fields'; + case ItemType.ALARM_RULE: return 'alarm-rules'; + case ItemType.RULE_CHAIN: return 'rule-chains'; + case ItemType.DEVICE: return 'devices'; + default: return 'widgets'; + } + } + + navigateToInstalledItems(): void { + void this.router.navigate(['/iot-hub/installed']); + } + + openItemDetail(item: MpItemVersionView): void { + this.iotHubActions.openItemDetail(item, this.findInstalledItem(item), this.findInstalledItemsCount(item)).subscribe(result => { + if (result === 'installed' || result === 'deleted') { + this.reloadInstalledItems(item.type); + } + }); + } + + private reloadInstalledItems(type: ItemType): void { + const config = { ignoreLoading: true }; + const pageLink = new PageLink(10000, 0); + this.iotHubApiService.getInstalledItemsCount(null, config).subscribe(count => { + this.installedItemsCount = count; + }); + if (type === ItemType.WIDGET) { + this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, undefined, config).subscribe(data => { + this.installedWidgets = data.data; + }); + } else if (type === ItemType.SOLUTION_TEMPLATE) { + this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, undefined, config).subscribe(data => { + this.installedSolutionTemplates = data.data; + }); + } else if (type === ItemType.DEVICE) { + this.iotHubApiService.getInstalledItemCounts(ItemType.DEVICE, config).subscribe(counts => { + this.installedDeviceCounts = counts; + }); + } else if (type === ItemType.CALCULATED_FIELD) { + this.iotHubApiService.getInstalledItemCounts(ItemType.CALCULATED_FIELD, config).subscribe(counts => { + this.installedCalcFieldCounts = counts; + }); + } else if (type === ItemType.ALARM_RULE) { + this.iotHubApiService.getInstalledItemCounts(ItemType.ALARM_RULE, config).subscribe(counts => { + this.installedAlarmRuleCounts = counts; + }); + } else if (type === ItemType.RULE_CHAIN) { + this.iotHubApiService.getInstalledItemCounts(ItemType.RULE_CHAIN, config).subscribe(counts => { + this.installedRuleChainCounts = counts; + }); + } + } + + private findInstalledItem(item: MpItemVersionView): IotHubInstalledItem | undefined { + switch (item.type) { + case ItemType.WIDGET: + return this.installedWidgets.find(i => i.itemId === item.itemId); + case ItemType.SOLUTION_TEMPLATE: + return this.installedSolutionTemplates.find(i => i.itemId === item.itemId); + default: + return undefined; + } + } + + findInstalledItemsCount(item: MpItemVersionView): number { + switch (item.type) { + case ItemType.DEVICE: + return this.installedDeviceCounts[item.itemId] || 0; + case ItemType.CALCULATED_FIELD: + return this.installedCalcFieldCounts[item.itemId] || 0; + case ItemType.ALARM_RULE: + return this.installedAlarmRuleCounts[item.itemId] || 0; + case ItemType.RULE_CHAIN: + return this.installedRuleChainCounts[item.itemId] || 0; + default: + return 0; + } + } + + installItem(item: MpItemVersionView): void { + this.iotHubActions.installItem(item).subscribe(result => { + if (result === 'installed') { + this.reloadInstalledItems(item.type); + } + }); + } + + updateItem(item: MpItemVersionView): void { + const installedItem = this.findInstalledItem(item); + if (!installedItem) { return; } + this.iotHubActions.updateItem(installedItem, item.version, item.id as string).subscribe(result => { + if (result === 'updated') { + this.reloadInstalledItems(item.type); + } + }); + } + + navigateToCreator(creatorId: string): void { + void this.router.navigate(['/iot-hub/creator', creatorId]); + } + + getInstalledWidget(item: MpItemVersionView): IotHubInstalledItem | undefined { + return this.installedWidgets.find(i => i.itemId === item.itemId); + } + + getInstalledSolutionTemplate(item: MpItemVersionView): IotHubInstalledItem | undefined { + return this.installedSolutionTemplates.find(i => i.itemId === item.itemId); + } + + deleteInstalledItem(item: MpItemVersionView): void { + const installedItem = this.findInstalledItem(item); + if (!installedItem) { return; } + this.iotHubActions.deleteItem(installedItem).subscribe(() => { + this.installedItemsCount = Math.max(0, this.installedItemsCount - 1); + if (item.type === ItemType.WIDGET) { + this.installedWidgets = this.installedWidgets.filter(i => i.id.id !== installedItem.id.id); + } else if (item.type === ItemType.SOLUTION_TEMPLATE) { + this.installedSolutionTemplates = this.installedSolutionTemplates.filter(i => i.id.id !== installedItem.id.id); + } else if (item.type === ItemType.DEVICE && this.installedDeviceCounts[item.itemId]) { + this.installedDeviceCounts[item.itemId] = Math.max(0, this.installedDeviceCounts[item.itemId] - 1); + } else if (item.type === ItemType.CALCULATED_FIELD && this.installedCalcFieldCounts[item.itemId]) { + this.installedCalcFieldCounts[item.itemId] = Math.max(0, this.installedCalcFieldCounts[item.itemId] - 1); + } else if (item.type === ItemType.ALARM_RULE && this.installedAlarmRuleCounts[item.itemId]) { + this.installedAlarmRuleCounts[item.itemId] = Math.max(0, this.installedAlarmRuleCounts[item.itemId] - 1); + } else if (item.type === ItemType.RULE_CHAIN && this.installedRuleChainCounts[item.itemId]) { + this.installedRuleChainCounts[item.itemId] = Math.max(0, this.installedRuleChainCounts[item.itemId] - 1); + } + }); + } + + openSignup(): void { + window.open(this.iotHubApiService.baseUrl + '/signup', '_blank'); + } + + private updateCardCounts(): void { + if (this.breakpointObserver.isMatched(MediaBreakpoints['lt-sm'])) { + // ≤599px: 1-col big cards, 1-col compact + this.bigCardCount = 2; + this.compactCardCount = 4; + } else if (this.breakpointObserver.isMatched(MediaBreakpoints['lt-md'])) { + // ≤959px: 2-col big cards, 1-col compact + this.bigCardCount = 4; + this.compactCardCount = 4; + } else if (this.breakpointObserver.isMatched(MediaBreakpoints['lt-lg'])) { + // ≤1279px: 2-col big cards, 1-col compact + this.bigCardCount = 4; + this.compactCardCount = 4; + } else if (this.breakpointObserver.isMatched(MediaBreakpoints['lt-xmd'])) { + // ≤1599px: 3-col big cards, 2-col compact + this.bigCardCount = 3; + this.compactCardCount = 4; + } else if (this.breakpointObserver.isMatched(MediaBreakpoints['lt-xl'])) { + // ≤1919px: 4-col big cards, 3-col compact + this.bigCardCount = 4; + this.compactCardCount = 6; + } else if (this.breakpointObserver.isMatched(MediaBreakpoints['gt-xxl'])) { + // ≥2448px: 6-col big cards, 4-col compact + this.bigCardCount = 6; + this.compactCardCount = 8; + } else { + // ≥1920px: 5-col big cards, 3-col compact + this.bigCardCount = 5; + this.compactCardCount = 6; + } + } + + retryLoadPopularItems(): void { + // Debounce frequent retry clicks: show the loading spinner + // immediately and defer the actual loadPopularItems() call by + // 350ms so rapid-fire clicks coalesce into a single request set. + // hasError stays as-is until the next request actually succeeds. + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + } + this.isLoading = true; + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.loadPopularItems(); + }, 350); + } + + private loadPopularItems(): void { + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + this.isLoading = true; + // hasError is intentionally NOT reset here — the error UI stays + // visible until the next forkJoin actually succeeds (cleared in + // the `next` callback below). + const sortOrder: SortOrder = { property: 'totalInstallCount', direction: Direction.DESC }; + const config = { ignoreLoading: true, ignoreErrors: true }; + const installedPageLink = new PageLink(10000, 0); + + const buildQuery = (type: ItemType, size: number): MpItemVersionQuery => { + const pageLink = new PageLink(size, 0, null, sortOrder); + return new MpItemVersionQuery(pageLink, { type }); + }; + + forkJoin({ + widgets: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.WIDGET, this.bigCardCount), config), + solutionTemplates: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.SOLUTION_TEMPLATE, this.bigCardCount), config), + calcFields: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.CALCULATED_FIELD, this.compactCardCount), config), + alarmRules: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.ALARM_RULE, this.compactCardCount), config), + ruleChains: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.RULE_CHAIN, this.compactCardCount), config), + devices: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.DEVICE, this.bigCardCount), config), + installedWidgets: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.WIDGET, undefined, config), + installedSolutionTemplates: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.SOLUTION_TEMPLATE, undefined, config), + installedDeviceCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DEVICE, config), + installedCalcFieldCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.CALCULATED_FIELD, config), + installedAlarmRuleCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.ALARM_RULE, config), + installedRuleChainCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.RULE_CHAIN, config), + installedCount: this.iotHubApiService.getInstalledItemsCount(null, config) + }).subscribe({ + next: (results) => { + this.popularWidgets = results.widgets.data; + this.popularSolutionTemplates = results.solutionTemplates.data; + this.popularCalcFields = results.calcFields.data; + this.popularAlarmRules = results.alarmRules.data; + this.popularRuleChains = results.ruleChains.data; + this.popularDevices = results.devices.data; + this.installedWidgets = results.installedWidgets.data; + this.installedSolutionTemplates = results.installedSolutionTemplates.data; + this.installedDeviceCounts = results.installedDeviceCounts; + this.installedCalcFieldCounts = results.installedCalcFieldCounts; + this.installedAlarmRuleCounts = results.installedAlarmRuleCounts; + this.installedRuleChainCounts = results.installedRuleChainCounts; + this.installedItemsCount = results.installedCount; + this.isLoading = false; + this.hasError = false; + }, + error: () => { + this.isLoading = false; + this.hasError = true; + this.popularWidgets = []; + this.popularSolutionTemplates = []; + this.popularCalcFields = []; + this.popularAlarmRules = []; + this.popularRuleChains = []; + this.popularDevices = []; + } + }); + } + + private groupSearchResults(items: MpItemVersionView[]): SearchResultGroup[] { + const groupMap = new Map(); + for (const item of items) { + if (!SEARCH_GROUP_ORDER.includes(item.type)) { + continue; + } + let list = groupMap.get(item.type); + if (!list) { + list = []; + groupMap.set(item.type, list); + } + list.push(item); + } + return SEARCH_GROUP_ORDER + .filter(type => groupMap.has(type)) + .map(type => ({ type, items: groupMap.get(type) })); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html new file mode 100644 index 0000000000..be6793f766 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html @@ -0,0 +1,100 @@ + +
+ + + + +
+

{{ 'iot-hub.installed-items' | translate }}

+ @if (!updatesChecked) { + + } +
+ + +
+ + + @for (type of allItemTypes; track type; let last = $last) { +
+ + {{ getItemTypeLabel(type) }} + +
+ @if (!last) { + + } + } +
+ @if (hasActiveFilters()) { + + @for (type of activeTypeFilters; track type) { + + {{ getItemTypeLabel(type) }} + close + + } + + + } +
+ + +
+ +
+ + + +
diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss new file mode 100644 index 0000000000..0ac5db7e61 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss @@ -0,0 +1,151 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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"; +@import "../../components/iot-hub/iot-hub-common"; + +:host { + display: block; + padding: 16px; + height: 100%; +} + +.tb-installed-items-page { + background: white; + border-radius: 8px; + padding: 40px 40px 40px; +} + +// Back button +.tb-installed-back-btn { + margin-bottom: 40px; +} + +// Header: title + check for updates +.tb-installed-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32px; +} + +.tb-installed-title { + font-size: 36px; + font-weight: 500; + line-height: 48px; + color: rgba(0, 0, 0, 0.87); + margin: 0; +} + +// Filter row — filter button + active chips on top +.tb-installed-filter-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 16px; + + .mat-mdc-chip { + margin-top: 0; + margin-bottom: 0; + } +} + +// Search row — full-width search input below the filter row +.tb-installed-search-row { + display: flex; + align-items: center; + margin-bottom: 16px; +} + +.tb-installed-filter-btn { + &.mat-primary { + --mat-button-outlined-outline-color: #{$tb-primary-color}; + } +} + +.tb-installed-filter-chips { + .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) { + --mat-chip-elevated-container-color: rgba(0, 0, 0, 0.04); + } +} + +.tb-installed-clear-btn { + font-size: 14px; + font-weight: 400; + color: rgba(0, 0, 0, 0.54); + cursor: pointer; + background: none; + border: none; + padding: 6px 8px; + font-family: inherit; + + &:hover { + color: rgba(0, 0, 0, 0.87); + } +} + +// Filter menu panel +::ng-deep .tb-installed-filter-menu { + .mat-mdc-menu-content { + padding: 0; + } + + .tb-installed-filter-item { + height: 40px; + padding: 0 16px; + + mat-checkbox { + width: 100%; + } + } + + mat-divider { + margin: 0; + } +} + +.tb-installed-search { + @include tb-iot-hub-page-search-container; +} + +.tb-installed-search-input { + @include tb-iot-hub-page-search-input; +} + +.tb-installed-search-action { + @include tb-iot-hub-page-search-action; +} + +// Responsive +@media #{$mat-lt-md} { + .tb-installed-items-page { + padding: 40px 24px; + } +} + +@media #{$mat-xs} { + .tb-installed-items-page { + padding-left: 16px; + padding-right: 16px; + } + + .tb-installed-header { + flex-direction: column; + align-items: flex-start; + gap: 16px; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts new file mode 100644 index 0000000000..9f118a4c1e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts @@ -0,0 +1,124 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, OnInit, OnDestroy, ViewChild } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { Subject } from 'rxjs'; +import { debounceTime, takeUntil } from 'rxjs/operators'; +import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { TbIotHubInstalledItemsTableComponent } from '@home/components/iot-hub/iot-hub-installed-items-table.component'; + +@Component({ + selector: 'tb-iot-hub-installed-items', + standalone: false, + templateUrl: './iot-hub-installed-items.component.html', + styleUrls: ['./iot-hub-installed-items.component.scss'] +}) +export class TbIotHubInstalledItemsComponent implements OnInit, OnDestroy { + + textSearch = ''; + appliedTextSearch = ''; + typeFilters: string[] = []; + + activeTypeFilters = new Set(); + allItemTypes: string[] = ['DEVICE', 'SOLUTION_TEMPLATE', 'WIDGET', 'CALCULATED_FIELD', 'ALARM_RULE', 'RULE_CHAIN']; + + @ViewChild(TbIotHubInstalledItemsTableComponent) tableComponent: TbIotHubInstalledItemsTableComponent; + + private searchSubject = new Subject(); + private destroy$ = new Subject(); + + constructor( + private translate: TranslateService, + private route: ActivatedRoute, + private router: Router + ) {} + + ngOnInit(): void { + const itemType = this.route.snapshot.queryParamMap.get('itemType'); + if (itemType && this.allItemTypes.includes(itemType)) { + this.activeTypeFilters.add(itemType); + this.typeFilters = Array.from(this.activeTypeFilters); + } + this.searchSubject.pipe( + debounceTime(300), + takeUntil(this.destroy$) + ).subscribe(value => { + this.appliedTextSearch = value; + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + navigateToMarketplace(): void { + void this.router.navigate(['/iot-hub']); + } + + onSearchChange(value: string): void { + this.textSearch = value; + this.searchSubject.next(value); + } + + isTypeFilterActive(type: string): boolean { + return this.activeTypeFilters.has(type); + } + + toggleTypeFilter(type: string): void { + if (this.activeTypeFilters.has(type)) { + this.activeTypeFilters.delete(type); + } else { + this.activeTypeFilters.add(type); + } + this.typeFilters = Array.from(this.activeTypeFilters); + } + + removeTypeFilter(type: string): void { + this.activeTypeFilters.delete(type); + this.typeFilters = Array.from(this.activeTypeFilters); + } + + clearAllFilters(): void { + this.activeTypeFilters.clear(); + this.typeFilters = []; + this.textSearch = ''; + this.appliedTextSearch = ''; + } + + hasActiveFilters(): boolean { + return this.activeTypeFilters.size > 0; + } + + getItemTypeLabel(itemType: string): string { + const key = itemTypeTranslations.get(itemType as ItemType); + return key ? this.translate.instant(key) : itemType; + } + + checkForUpdates(): void { + this.tableComponent?.checkForUpdates(); + } + + get isCheckingUpdates(): boolean { + return this.tableComponent?.isCheckingUpdates || false; + } + + get updatesChecked(): boolean { + return this.tableComponent?.updatesChecked || false; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts new file mode 100644 index 0000000000..606d6a29ae --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts @@ -0,0 +1,180 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { AppState } from '@core/core.state'; +import { Observable } from 'rxjs'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { ListingItemVersionNotFound, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { + DeepLinkOpenItem, + isPublished, + isUUID, + typeSegment +} from './iot-hub-deep-link.utils'; +import { + IotHubUnpublishedWarningDialogData, + TbIotHubUnpublishedWarningDialogComponent +} from '@home/components/iot-hub/iot-hub-unpublished-warning-dialog.component'; +import { + TbIotHubPeRequiredDialogComponent +} from '@home/components/iot-hub/iot-hub-pe-required-dialog.component'; +import { + IotHubUpgradeRequiredDialogData, + TbIotHubUpgradeRequiredDialogComponent +} from '@home/components/iot-hub/iot-hub-upgrade-required-dialog.component'; + +@Component({ + selector: 'tb-iot-hub-item-resolver', + standalone: false, + template: '' +}) +export class TbIotHubItemResolverComponent implements OnInit { + + constructor( + private route: ActivatedRoute, + private router: Router, + private dialog: MatDialog, + private store: Store, + private translate: TranslateService, + private iotHubApi: IotHubApiService + ) {} + + ngOnInit(): void { + const params = this.route.snapshot.paramMap; + const slug = params.get('slug'); + const itemVersionId = params.get('itemVersionId'); + const itemId = params.get('itemId'); + const bySlug = slug != null; + const byVersion = !bySlug && itemVersionId != null; + + let fetch$: Observable; + if (bySlug) { + fetch$ = this.iotHubApi.getListingItemVersion(slug, { ignoreErrors: true }); + } else { + const id = byVersion ? itemVersionId : itemId; + if (!isUUID(id)) { + this.failTo('iot-hub.deep-link-invalid-id'); + return; + } + fetch$ = byVersion + ? this.iotHubApi.getVersionInfo(id, { ignoreErrors: true }) + : this.iotHubApi.getPublishedVersion(id, { ignoreErrors: true }); + } + + fetch$.subscribe({ + next: v => this.handleResolved(v, byVersion), + error: err => { + if (bySlug && err?.status === 404) { + const body = (err?.error ?? {}) as ListingItemVersionNotFound; + if (body.peRequired) { + this.showPeRequired(); + return; + } + if (typeof body.minTbVersionRequired === 'number') { + this.showMinTbVersionRequired(body.minTbVersionRequired); + return; + } + if (body.noMatchingVersions) { + this.failTo('iot-hub.deep-link-not-found'); + return; + } + } + const key = err?.status === 404 + ? 'iot-hub.deep-link-not-found' + : 'iot-hub.deep-link-fetch-failed'; + this.failTo(key); + } + }); + } + + private showPeRequired(): void { + this.router.navigate(['/iot-hub'], { replaceUrl: true }).then(() => { + this.dialog.open(TbIotHubPeRequiredDialogComponent, { + panelClass: ['tb-dialog'], + disableClose: true, + autoFocus: false + }); + }); + } + + private showMinTbVersionRequired(minTbVersion: number): void { + this.router.navigate(['/iot-hub'], { replaceUrl: true }).then(() => { + this.dialog.open( + TbIotHubUpgradeRequiredDialogComponent, + { + panelClass: ['tb-dialog'], + disableClose: true, + autoFocus: false, + data: { minTbVersion } + } + ); + }); + } + + private handleResolved(version: MpItemVersionView, byVersion: boolean): void { + const segment = typeSegment(version.type); + if (!segment) { + this.failTo('iot-hub.deep-link-fetch-failed'); + return; + } + + const unpublished = byVersion && !isPublished(version); + + if (unpublished) { + this.dialog.open< + TbIotHubUnpublishedWarningDialogComponent, + IotHubUnpublishedWarningDialogData, + boolean + >(TbIotHubUnpublishedWarningDialogComponent, { + panelClass: ['tb-dialog'], + disableClose: true, + autoFocus: false, + data: { item: version } + }).afterClosed().subscribe(confirmed => { + if (confirmed) { + this.openOnTypePage(version, segment, true); + } else { + this.router.navigate(['/iot-hub'], { replaceUrl: true }); + } + }); + } else { + this.openOnTypePage(version, segment, false); + } + } + + private openOnTypePage(version: MpItemVersionView, segment: string, preview: boolean): void { + const openItem: DeepLinkOpenItem = { version, preview }; + this.router.navigate(['/iot-hub', segment], { + state: { openItem }, + replaceUrl: true + }); + } + + private failTo(messageKey: string): void { + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant(messageKey), + type: 'error', + duration: 5000 + })); + this.router.navigate(['/iot-hub'], { replaceUrl: true }); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.html new file mode 100644 index 0000000000..3d9de39460 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.html @@ -0,0 +1,61 @@ + +
+ +
+
+ + +

{{ config.titleKey | translate }}

+

{{ config.descriptionKey | translate }}

+
+ @if (installedItemsCount > 0) { + + } + +
+
+
+ +
+
+ + + + + + +
+ +
diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.scss new file mode 100644 index 0000000000..7991a3e665 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.scss @@ -0,0 +1,178 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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: block; + padding: 16px; +} + +.tb-iot-hub-items-page { + background: white; + border-radius: 8px; + padding: 40px 40px 48px; +} + +.tb-iot-hub-back-btn { + margin-bottom: 40px; +} + +.tb-iot-hub-items-title { + font-size: 36px; + font-weight: 500; + line-height: 48px; + color: rgba(0, 0, 0, 0.87); + margin: 0 0 12px; +} + +// Description — Design: 16px Regular, line-height 24, tracking 0.15, rgba(0,0,0,0.76) +.tb-iot-hub-items-desc { + font-size: 16px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.76); + margin: 0 0 24px; + max-width: 800px; +} + +.tb-iot-hub-items-actions { + display: flex; + align-items: center; + gap: 16px; +} + +.tb-iot-hub-items-installed-btn { + &.mat-primary { + --mat-button-outlined-outline-color: #{$tb-primary-color}; + } +} + +.tb-iot-hub-items-hero-image { + flex-shrink: 0; + display: flex; + align-items: flex-start; + + img { + max-height: 276px; + object-fit: contain; + } +} + +// Browse component — stretch to fill, ignore parent padding +tb-iot-hub-browse { + margin-left: -40px; + margin-right: -40px; +} + +// Footer divider +.tb-iot-hub-items-divider { + height: 1px; + background: rgba(0, 0, 0, 0.12); + margin-top: 48px; + margin-left: -40px; + margin-right: -40px; +} + +// Become a Creator footer +.tb-iot-hub-items-footer { + text-align: center; + padding: 40px 0 0; + + h2 { + font-size: 24px; + font-weight: 500; + line-height: 32px; + color: rgba(0, 0, 0, 0.87); + margin: 0 0 8px; + } + + p { + font-size: 14px; + line-height: 20px; + color: rgba(0, 0, 0, 0.54); + margin: 0 0 32px; + } +} + +// Responsive +@media #{$mat-lt-lg} { + .tb-iot-hub-items-page { + padding-left: 24px; + padding-right: 24px; + } + + .tb-iot-hub-items-divider { + margin-left: -24px; + margin-right: -24px; + } + + tb-iot-hub-browse { + margin-left: -24px; + margin-right: -24px; + } + + .tb-iot-hub-items-title { + font-size: 28px; + line-height: 36px; + } + + .tb-iot-hub-items-desc { + font-size: 14px; + line-height: 20px; + } +} + +@media #{$mat-lt-xmd} { + .tb-iot-hub-items-hero { + gap: 24px; + } + + .tb-iot-hub-items-hero-image { + min-width: 0; + flex: 1; + + img { + min-width: 0; + margin-left: auto; + margin-top: auto; + } + } +} + +@media #{$mat-lt-md} { + .tb-iot-hub-items-hero-image { + display: none; + } +} + +@media #{$mat-xs} { + .tb-iot-hub-items-page { + padding-left: 16px; + padding-right: 16px; + } + + .tb-iot-hub-items-divider { + margin-left: -16px; + margin-right: -16px; + } + + tb-iot-hub-browse { + margin-left: -16px; + margin-right: -16px; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts new file mode 100644 index 0000000000..90519f4e7f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts @@ -0,0 +1,173 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; +import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { PageLink } from '@shared/models/page/page-link'; +import { DeepLinkOpenItem } from './iot-hub-deep-link.utils'; + +interface ItemTypePageConfig { + type: ItemType; + titleKey: string; + descriptionKey: string; + image: string; + routeSegment: string; +} + +const PAGE_CONFIGS: Record = { + WIDGET: { + type: ItemType.WIDGET, + titleKey: 'item.type-widget-plural', + descriptionKey: 'iot-hub.items-page-desc-widgets', + image: 'assets/iot-hub/items-page-widgets-hero.svg', + routeSegment: 'widgets' + }, + DASHBOARD: { + type: ItemType.DASHBOARD, + titleKey: 'item.type-dashboard-plural', + descriptionKey: 'iot-hub.items-page-desc-dashboards', + image: 'assets/iot-hub/items-page-dashboards-hero.svg', + routeSegment: 'dashboards' + }, + SOLUTION_TEMPLATE: { + type: ItemType.SOLUTION_TEMPLATE, + titleKey: 'item.type-solution-template-plural', + descriptionKey: 'iot-hub.items-page-desc-solution-templates', + image: 'assets/iot-hub/items-page-solution-templates-hero.png', + routeSegment: 'solution-templates' + }, + CALCULATED_FIELD: { + type: ItemType.CALCULATED_FIELD, + titleKey: 'item.type-calculated-field-plural', + descriptionKey: 'iot-hub.items-page-desc-calculated-fields', + image: 'assets/iot-hub/items-page-calculated-fields-hero.svg', + routeSegment: 'calculated-fields' + }, + ALARM_RULE: { + type: ItemType.ALARM_RULE, + titleKey: 'item.type-alarm-rule-plural', + descriptionKey: 'iot-hub.items-page-desc-alarm-rules', + image: 'assets/iot-hub/items-page-alarm-rules-hero.svg', + routeSegment: 'alarm-rules' + }, + RULE_CHAIN: { + type: ItemType.RULE_CHAIN, + titleKey: 'item.type-rule-chain-plural', + descriptionKey: 'iot-hub.items-page-desc-rule-chains', + image: 'assets/iot-hub/items-page-rule-chains-hero.svg', + routeSegment: 'rule-chains' + }, + DEVICE: { + type: ItemType.DEVICE, + titleKey: 'iot-hub.device-library', + descriptionKey: 'iot-hub.items-page-desc-devices', + image: 'assets/iot-hub/items-page-devices-hero.svg', + routeSegment: 'devices' + } +}; + +@Component({ + selector: 'tb-iot-hub-items-page', + standalone: false, + templateUrl: './iot-hub-items-page.component.html', + styleUrls: ['./iot-hub-items-page.component.scss'] +}) +export class TbIotHubItemsPageComponent implements OnInit { + + config: ItemTypePageConfig; + installedItemsCount = 0; + + constructor( + private route: ActivatedRoute, + private router: Router, + private iotHubApiService: IotHubApiService, + private iotHubActions: IotHubActionsService + ) {} + + ngOnInit(): void { + const itemType = this.route.snapshot.data['itemType'] as string; + this.config = PAGE_CONFIGS[itemType]; + this.loadInstalledCount(); + this.maybeOpenDeepLinkedItem(); + } + + goBack(): void { + void this.router.navigate(['/iot-hub']); + } + + navigateToInstalledItems(): void { + void this.router.navigate(['/iot-hub/installed'], { queryParams: { itemType: this.config.type } }); + } + + openSignup(): void { + window.open(this.iotHubApiService.baseUrl + '/signup', '_blank'); + } + + private loadInstalledCount(): void { + this.iotHubApiService.getInstalledItemsCount(this.config.type, { ignoreLoading: true }).subscribe(count => { + this.installedItemsCount = count; + }); + } + + private maybeOpenDeepLinkedItem(): void { + const openItem = history.state?.openItem as DeepLinkOpenItem | undefined; + if (!openItem || openItem.version.type !== this.config.type) { + return; + } + history.replaceState({ ...history.state, openItem: undefined }, ''); + + // Types where multiple entities can be installed from the same + // IoT Hub item — we only care about the count per itemId + // (no singular installed entity to surface in the detail dialog). + const countBasedTypes: ReadonlySet = new Set([ + ItemType.DEVICE, + ItemType.CALCULATED_FIELD, + ItemType.ALARM_RULE, + ItemType.RULE_CHAIN + ]); + const isCountBased = countBasedTypes.has(this.config.type); + const resolution$: Observable<{ installed?: IotHubInstalledItem; count?: number }> = isCountBased + ? this.iotHubApiService + .getInstalledItemCounts(this.config.type, { ignoreLoading: true }) + .pipe(map(counts => ({ count: counts[openItem.version.itemId] || 0 }))) + : this.resolveInstalledItem(openItem.version) + .pipe(map(installed => ({ installed: installed ?? undefined }))); + + resolution$.subscribe(({ installed, count }) => { + this.iotHubActions.openItemDetail( + openItem.version, + installed, + count, + 'default', + true, + openItem.preview + ).subscribe(); + }); + } + + private resolveInstalledItem(version: MpItemVersionView): Observable { + return this.iotHubApiService + .getInstalledItems(new PageLink(1), undefined, version.itemId, { ignoreLoading: true }) + .pipe(map(page => page.data[0] ?? null)); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts new file mode 100644 index 0000000000..3e387e6c0a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts @@ -0,0 +1,185 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { RouterModule, Routes } from '@angular/router'; + +import { Authority } from '@shared/models/authority.enum'; +import { TbIotHubHomeComponent } from './iot-hub-home.component'; +import { TbIotHubItemsPageComponent } from './iot-hub-items-page.component'; +import { TbIotHubAlarmRulesUnavailablePageComponent } from './iot-hub-alarm-rules-unavailable-page.component'; +import { TbIotHubCreatorProfileComponent } from './iot-hub-creator-profile.component'; +import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component'; +import { TbIotHubSearchPageComponent } from './iot-hub-search-page.component'; +import { TbIotHubItemResolverComponent } from './iot-hub-item-resolver.component'; + +const routes: Routes = [ + { + path: 'iot-hub', + data: { + auth: [Authority.TENANT_ADMIN], + breadcrumb: { + label: 'iot-hub.iot-hub', + icon: 'hub' + } + }, + children: [ + { + path: '', + component: TbIotHubHomeComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'iot-hub.iot-hub' + } + }, + { + path: 'widgets', + component: TbIotHubItemsPageComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'item.type-widget-plural', + itemType: 'WIDGET', + breadcrumb: { label: 'item.type-widget-plural', icon: 'widgets' } + } + }, + { + path: 'dashboards', + component: TbIotHubItemsPageComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'item.type-dashboard-plural', + itemType: 'DASHBOARD', + breadcrumb: { label: 'item.type-dashboard-plural', icon: 'dashboard' } + } + }, + { + path: 'solution-templates', + component: TbIotHubItemsPageComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'item.type-solution-template-plural', + itemType: 'SOLUTION_TEMPLATE', + breadcrumb: { label: 'item.type-solution-template-plural', icon: 'integration_instructions' } + } + }, + { + path: 'calculated-fields', + component: TbIotHubItemsPageComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'item.type-calculated-field-plural', + itemType: 'CALCULATED_FIELD', + breadcrumb: { label: 'item.type-calculated-field-plural', icon: 'functions' } + } + }, + { + path: 'alarm-rules', + component: TbIotHubAlarmRulesUnavailablePageComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'item.type-alarm-rule-plural', + breadcrumb: { label: 'item.type-alarm-rule-plural', icon: 'notification_important' } + } + }, + { + path: 'rule-chains', + component: TbIotHubItemsPageComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'item.type-rule-chain-plural', + itemType: 'RULE_CHAIN', + breadcrumb: { label: 'item.type-rule-chain-plural', icon: 'account_tree' } + } + }, + { + path: 'devices', + component: TbIotHubItemsPageComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'iot-hub.device-library', + itemType: 'DEVICE', + breadcrumb: { label: 'iot-hub.device-library', icon: 'memory' } + } + }, + { + path: 'search', + component: TbIotHubSearchPageComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'iot-hub.search-results', + breadcrumb: { + label: 'iot-hub.search-results', + icon: 'search' + } + } + }, + { + path: 'installed', + component: TbIotHubInstalledItemsComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'iot-hub.installed-items', + breadcrumb: { + label: 'iot-hub.installed-items', + icon: 'inventory_2' + } + } + }, + { + path: 'creator/:creatorId', + component: TbIotHubCreatorProfileComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'iot-hub.creator-profile', + breadcrumb: { + label: 'iot-hub.creator-profile', + icon: 'person' + } + } + }, + { + path: 'listing/:slug', + component: TbIotHubItemResolverComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'iot-hub.item-detail' + } + }, + { + path: 'version/:itemVersionId', + component: TbIotHubItemResolverComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'iot-hub.item-detail' + } + }, + { + path: ':itemId', + component: TbIotHubItemResolverComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'iot-hub.item-detail' + } + } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class IotHubRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.html new file mode 100644 index 0000000000..27a461b283 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.html @@ -0,0 +1,36 @@ + +
+ + + + + + +

+ @if (searchText?.trim()) { + {{ 'iot-hub.search-results-for' | translate }}'{{ searchText.trim() }}' + } @else { + {{ 'iot-hub.search-results' | translate }} + } +

+
+
diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.scss new file mode 100644 index 0000000000..d76c2c5ae7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.scss @@ -0,0 +1,143 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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: block; + padding: 16px; + height: 100%; +} + + +// Back button +.tb-search-back-btn { + align-self: flex-start; + margin-top: 40px; + margin-left: 123px; +} + +// Title +.tb-search-title { + font-size: 28px; + font-weight: 500; + line-height: 36px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + margin: 0; +} + +.tb-search-title-query { + color: rgba(0, 0, 0, 0.54); +} + +tb-iot-hub-search { + flex: 1; + min-height: 0; + padding-left: 123px; + padding-right: 123px; + + ::ng-deep { + .tb-search-divider { + margin-left: -123px; + margin-right: -123px; + } + + .tb-results-container { + padding-right: 123px; + padding-bottom: 40px; + margin-right: -123px; + } + } +} + +// Responsive +@media #{$mat-lt-xxl} { + .tb-search-back-btn { + margin-left: 40px; + } + + tb-iot-hub-search { + padding-left: 40px; + padding-right: 40px; + + ::ng-deep { + .tb-search-divider { + margin-left: -40px; + margin-right: -40px; + } + + .tb-results-container { + padding-right: 40px; + margin-right: -40px; + } + } + } +} + +@media #{$mat-lt-lg} { + .tb-search-back-btn { + margin-top: 24px; + margin-left: 24px; + } + + tb-iot-hub-search { + padding-left: 24px; + padding-right: 24px; + + ::ng-deep { + .tb-search-divider { + margin-left: -24px; + margin-right: -24px; + } + + .tb-results-container { + padding-right: 24px; + padding-bottom: 24px; + margin-right: -24px; + } + } + } +} + +@media #{$mat-lt-sm} { + .tb-search-page { + gap: 16px; + } + + .tb-search-back-btn { + margin-top: 16px; + margin-left: 16px; + } + + tb-iot-hub-search { + padding-left: 16px; + padding-right: 16px; + + ::ng-deep { + .tb-search-divider { + margin-left: -16px; + margin-right: -16px; + } + + .tb-results-container { + padding-right: 16px; + padding-bottom: 16px; + margin-right: -16px; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.ts new file mode 100644 index 0000000000..22853207a7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.ts @@ -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 { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +@Component({ + selector: 'tb-iot-hub-search-page', + standalone: false, + templateUrl: './iot-hub-search-page.component.html', + styleUrls: ['./iot-hub-search-page.component.scss'] +}) +export class TbIotHubSearchPageComponent implements OnInit { + + searchText = ''; + + constructor( + private route: ActivatedRoute, + private router: Router + ) {} + + ngOnInit(): void { + const search = this.route.snapshot.queryParamMap.get('search'); + if (search) { + this.searchText = search; + } + } + + navigateBack(): void { + void this.router.navigate(['/iot-hub']); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts new file mode 100644 index 0000000000..23c4687ac0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { IotHubComponentsModule } from '@home/components/iot-hub/iot-hub-components.module'; +import { IotHubRoutingModule } from './iot-hub-routing.module'; +import { TbIotHubHomeComponent } from './iot-hub-home.component'; +import { TbIotHubItemsPageComponent } from './iot-hub-items-page.component'; +import { TbIotHubAlarmRulesUnavailablePageComponent } from './iot-hub-alarm-rules-unavailable-page.component'; +import { TbIotHubCreatorProfileComponent } from './iot-hub-creator-profile.component'; +import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component'; +import { TbIotHubSearchPageComponent } from './iot-hub-search-page.component'; +import { TbIotHubItemResolverComponent } from './iot-hub-item-resolver.component'; + +@NgModule({ + declarations: [ + TbIotHubHomeComponent, + TbIotHubItemsPageComponent, + TbIotHubCreatorProfileComponent, + TbIotHubInstalledItemsComponent, + TbIotHubSearchPageComponent, + TbIotHubItemResolverComponent, + TbIotHubAlarmRulesUnavailablePageComponent + ], + imports: [ + CommonModule, + SharedModule, + IotHubComponentsModule, + IotHubRoutingModule + ] +}) +export class IotHubModule { } diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts index b94b312893..e631eeaf15 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts @@ -49,6 +49,8 @@ import { isUndefined } from '@core/utils'; import { PageLink } from '@shared/models/page/page-link'; import { Edge } from '@shared/models/edge.models'; import { mergeMap } from 'rxjs/operators'; +import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; import { PageData } from '@shared/models/page/page-data'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; @@ -63,6 +65,7 @@ export class RuleChainsTableConfigResolver { private importExport: ImportExportService, private itembuffer: ItemBufferService, private edgeService: EdgeService, + private iotHubActions: IotHubActionsService, private translate: TranslateService, private datePipe: DatePipe, private router: Router, @@ -168,6 +171,12 @@ export class RuleChainsTableConfigResolver { icon: 'file_upload', isEnabled: () => true, onAction: ($event) => this.importRuleChain($event) + }, + { + name: this.translate.instant('iot-hub.add-from-iot-hub'), + icon: 'hub', + isEnabled: () => true, + onAction: (_$event) => this.addRuleChainFromIotHub() } ); } @@ -288,6 +297,21 @@ export class RuleChainsTableConfigResolver { return actions; } + addRuleChainFromIotHub() { + const ruleChainScope = this.config.componentsData.ruleChainScope; + const ruleChainType = ruleChainScope === 'edges' ? 'EDGE' : 'CORE'; + this.iotHubActions.addItem(ItemType.RULE_CHAIN, { itemSubType: ruleChainType }).subscribe(result => { + if (result?.descriptor?.type === 'RULE_CHAIN' && result.descriptor.ruleChainId?.id) { + const ruleChainId = result.descriptor.ruleChainId.id; + if (ruleChainScope === 'edges') { + this.router.navigateByUrl(`edgeManagement/ruleChains/${ruleChainId}`); + } else { + this.router.navigateByUrl(`ruleChains/${ruleChainId}`); + } + } + }); + } + importRuleChain($event: Event) { if ($event) { $event.stopPropagation(); diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.html b/ui-ngx/src/app/modules/home/pages/security/security.component.html index f2bdd657a8..4d0551f3b9 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.component.html +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.html @@ -134,7 +134,7 @@
diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-types-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-types-table-config.resolver.ts index 811d3e56e8..cb8b436166 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-types-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-types-table-config.resolver.ts @@ -47,6 +47,8 @@ import { WidgetTypeComponent } from '@home/pages/widget/widget-type.component'; import { WidgetTypeTabsComponent } from '@home/pages/widget/widget-type-tabs.component'; import { SelectWidgetTypeDialogComponent } from '@home/pages/widget/select-widget-type-dialog.component'; import { MatDialog } from '@angular/material/dialog'; +import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; @Injectable() export class WidgetTypesTableConfigResolver { @@ -60,7 +62,8 @@ export class WidgetTypesTableConfigResolver { private translate: TranslateService, private importExport: ImportExportService, private datePipe: DatePipe, - private router: Router) { + private router: Router, + private iotHubActions: IotHubActionsService) { this.config.entityType = EntityType.WIDGETS_BUNDLE; this.config.entityComponent = WidgetTypeComponent; @@ -87,21 +90,6 @@ export class WidgetTypesTableConfigResolver { entity => checkBoxCell(entity.deprecated)) ); - this.config.addActionDescriptors.push( - { - name: this.translate.instant('dashboard.create-new-widget'), - icon: 'insert_drive_file', - isEnabled: () => true, - onAction: ($event) => this.addWidgetType($event) - }, - { - name: this.translate.instant('widget.import'), - icon: 'file_upload', - isEnabled: () => true, - onAction: ($event) => this.importWidgetType($event) - } - ); - this.config.cellActionDescriptors.push( { name: this.translate.instant('widget.export'), @@ -151,6 +139,30 @@ export class WidgetTypesTableConfigResolver { resolve(): EntityTableConfig { this.config.tableTitle = this.translate.instant('widget.widgets'); const authUser = getCurrentAuthUser(this.store); + this.config.addActionDescriptors = [ + { + name: this.translate.instant('dashboard.create-new-widget'), + icon: 'insert_drive_file', + isEnabled: () => true, + onAction: ($event) => this.addWidgetType($event) + }, + { + name: this.translate.instant('widget.import'), + icon: 'file_upload', + isEnabled: () => true, + onAction: ($event) => this.importWidgetType($event) + } + ]; + if (authUser.authority === Authority.TENANT_ADMIN) { + this.config.addActionDescriptors.push( + { + name: this.translate.instant('iot-hub.add-from-iot-hub'), + icon: 'hub', + isEnabled: () => true, + onAction: (_$event) => this.addWidgetFromIotHub() + } + ); + } this.config.deleteEnabled = (widgetType) => this.isWidgetTypeEditable(widgetType, authUser.authority); this.config.entitySelectionEnabled = (widgetType) => this.isWidgetTypeEditable(widgetType, authUser.authority); this.config.detailsReadonly = (widgetType) => !this.isWidgetTypeEditable(widgetType, authUser.authority); @@ -183,6 +195,14 @@ export class WidgetTypesTableConfigResolver { ); } + addWidgetFromIotHub() { + this.iotHubActions.addItem(ItemType.WIDGET).subscribe(result => { + if (result?.descriptor) { + this.config.updateData(); + } + }); + } + importWidgetType($event: Event) { this.importExport.importWidgetType().subscribe( (widgetType) => { diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts index e045768712..fcc9fc9f83 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts @@ -298,6 +298,18 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit this.entityRequiredText = 'ai-models.model-required'; this.notFoundEntities = 'ai-models.no-model-text'; break; + case EntityType.DEVICE_PROFILE: + this.entityText = 'device-profile.device-profile'; + this.noEntitiesMatchingText = 'device-profile.no-device-profiles-matching'; + this.entityRequiredText = 'device-profile.device-profile-required'; + this.notFoundEntities = 'device-profile.no-device-profiles-text'; + break; + case EntityType.ASSET_PROFILE: + this.entityText = 'asset-profile.asset-profile'; + this.noEntitiesMatchingText = 'asset-profile.no-asset-profiles-matching'; + this.entityRequiredText = 'asset-profile.asset-profile-required'; + this.notFoundEntities = 'asset-profile.no-asset-profiles-text'; + break; case AliasEntityType.CURRENT_CUSTOMER: this.entityText = 'customer.default-customer'; this.noEntitiesMatchingText = 'customer.no-customers-matching'; diff --git a/ui-ngx/src/app/shared/components/entity/entity-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-select.component.html index 7d57e575ef..c88366e75e 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-select.component.html @@ -25,6 +25,7 @@ [useAliasEntityTypes]="useAliasEntityTypes" [allowedEntityTypes]="allowedEntityTypes" [additionEntityTypes]="additionEntityTypes" + [filterAllowedEntityTypes]="filterAllowedEntityTypes" formControlName="entityType"> { }; constructor(private store: Store, @@ -83,15 +87,17 @@ export class EntitySelectComponent implements ControlValueAccessor, OnInit, Afte const entityTypes = this.entityService.prepareAllowedEntityTypesList(this.allowedEntityTypes, this.useAliasEntityTypes); + + let defaultEntityType: EntityType | AliasEntityType = null; if (entityTypes.length === 1) { this.displayEntityTypeSelect = false; - this.defaultEntityType = entityTypes[0]; + defaultEntityType = entityTypes[0]; } else { this.displayEntityTypeSelect = true; } this.entitySelectFormGroup = this.fb.group({ - entityType: [this.defaultEntityType], + entityType: [defaultEntityType], entityId: [null] }); } @@ -123,6 +129,19 @@ export class EntitySelectComponent implements ControlValueAccessor, OnInit, Afte if (additionNullUIIDEntityTypes.length > 0) { additionNullUIIDEntityTypes.forEach((entityType) => this.entityTypeNullUUID.add(entityType)); } + + if (this.filterAllowedEntityTypes === false) { + if (this.allowedEntityTypes?.length === 1) { + this.displayEntityTypeSelect = false; + this.entitySelectFormGroup.get('entityType').setValue(this.allowedEntityTypes[0]); + } else { + this.displayEntityTypeSelect = true; + } + } + + if (this.defaultEntityType) { + this.entitySelectFormGroup.get('entityType').setValue(this.defaultEntityType); + } } ngAfterViewInit(): void { diff --git a/ui-ngx/src/app/shared/components/toggle-header.component.html b/ui-ngx/src/app/shared/components/toggle-header.component.html index bddaa896b6..c71e226dce 100644 --- a/ui-ngx/src/app/shared/components/toggle-header.component.html +++ b/ui-ngx/src/app/shared/components/toggle-header.component.html @@ -42,14 +42,18 @@ [(ngModel)]="value" (ngModelChange)="valueChange.emit(value)"> - {{ option.name }} - - warning - + @if (option.template) { + + } @else { + {{ option.name }} + + warning + + }
diff --git a/ui-ngx/src/app/shared/components/toggle-header.component.ts b/ui-ngx/src/app/shared/components/toggle-header.component.ts index c60faf810a..53b000d429 100644 --- a/ui-ngx/src/app/shared/components/toggle-header.component.ts +++ b/ui-ngx/src/app/shared/components/toggle-header.component.ts @@ -33,7 +33,7 @@ import { OnInit, Output, QueryList, - SimpleChanges, + SimpleChanges, TemplateRef, ViewChild } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; @@ -50,6 +50,7 @@ import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button- export interface ToggleHeaderOption { name: string; value: any; + template?: TemplateRef; error$?: Observable; } @@ -69,6 +70,8 @@ export class ToggleOption implements OnChanges, OnDestroy { @Input() value: any; + @Input() template: TemplateRef; + @Input() error: string; currentError = new ReplaySubject(1); @@ -127,6 +130,7 @@ export abstract class _ToggleBase extends PageComponent implements AfterContentI { name: option.viewValue, value: option.value, + template: option.template, error$: option.currentError.asObservable() } ); diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts deleted file mode 100644 index 8f15905141..0000000000 --- a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts +++ /dev/null @@ -1,75 +0,0 @@ -/// -/// Copyright © 2016-2026 The Thingsboard Authors -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT 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, forwardRef, Input, ViewChild, ViewEncapsulation } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; - -@Component({ - selector: 'tb-widgets-bundle-search', - templateUrl: './widgets-bundle-search.component.html', - styleUrls: ['./widgets-bundle-search.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => WidgetsBundleSearchComponent), - multi: true - }], - encapsulation: ViewEncapsulation.None, - standalone: false -}) -export class WidgetsBundleSearchComponent implements ControlValueAccessor { - - searchText: string; - focus = false; - - @Input() placeholder: string; - - @ViewChild('searchInput') searchInput: ElementRef; - - private propagateChange = (v: any) => { }; - - constructor() { - } - - registerOnChange(fn: any): void { - this.propagateChange = fn; - } - - registerOnTouched(fn: any): void { - } - - writeValue(value: string | null): void { - this.searchText = value; - } - - updateSearchText(): void { - this.updateView(); - } - - private updateView() { - this.propagateChange(this.searchText); - } - - clear($event: Event): void { - $event.preventDefault(); - $event.stopPropagation(); - this.searchText = ''; - this.updateView(); - } - - toggleFocus() { - this.focus = !this.focus; - } -} diff --git a/ui-ngx/src/app/shared/directives/autocomplete-auto-scroll-reposition.directive.ts b/ui-ngx/src/app/shared/directives/autocomplete-auto-scroll-reposition.directive.ts new file mode 100644 index 0000000000..5f620789f1 --- /dev/null +++ b/ui-ngx/src/app/shared/directives/autocomplete-auto-scroll-reposition.directive.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 { + Directive, + ElementRef, + OnDestroy, + inject, AfterViewInit, Renderer2, +} from '@angular/core'; +import { MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { Subscription } from 'rxjs'; +import { onParentScrollOrWindowResize } from '@core/utils'; + +@Directive({ + selector: 'input[matAutocomplete], textarea[matAutocomplete]', + standalone: false +}) +export class AutocompleteAutoScrollRepositionDirective implements AfterViewInit, OnDestroy { + private readonly trigger = inject(MatAutocompleteTrigger, { host: true }); + private readonly elementRef = inject(ElementRef); + private readonly renderer = inject(Renderer2); + + private parentScrollSubscription: Subscription = null; + private isIntersecting: boolean = false; + + private intersectionObserver = new IntersectionObserver((entries) => { + if (this.isIntersecting !== entries[0].isIntersecting) { + this.isIntersecting = entries[0].isIntersecting; + this.updatePanelVisibility(); + } + }, {threshold: [0.5]}); + + constructor() { + } + + ngAfterViewInit(): void { + this.parentScrollSubscription = onParentScrollOrWindowResize(this.elementRef.nativeElement).subscribe(() => { + if (this.trigger.panelOpen) { + this.trigger.updatePosition(); + } + }); + this.intersectionObserver.observe(this.elementRef.nativeElement); + } + + ngOnDestroy(): void { + if (this.parentScrollSubscription) { + this.parentScrollSubscription.unsubscribe(); + this.parentScrollSubscription = null; + } + if (this.intersectionObserver) { + this.intersectionObserver.unobserve(this.elementRef.nativeElement); + this.intersectionObserver.disconnect(); + this.intersectionObserver = null; + } + } + + private updatePanelVisibility(): void { + if (this.trigger.panelOpen) { + if (this.isIntersecting) { + this.renderer.removeStyle(this.trigger.autocomplete.panel.nativeElement, 'display'); + } else { + this.renderer.setStyle(this.trigger.autocomplete.panel.nativeElement, 'display', 'none'); + } + } + } + +} diff --git a/ui-ngx/src/app/shared/directives/chip-overflow.directive.ts b/ui-ngx/src/app/shared/directives/chip-overflow.directive.ts new file mode 100644 index 0000000000..10585f1453 --- /dev/null +++ b/ui-ngx/src/app/shared/directives/chip-overflow.directive.ts @@ -0,0 +1,197 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, Directive, ElementRef, Input, NgZone, OnDestroy, Renderer2 } from '@angular/core'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +@Directive({ + selector: '[tb-chip-overflow]', + standalone: false +}) +export class ChipOverflowDirective implements AfterViewInit, OnDestroy { + + @Input() chipSelector = '.tb-chip'; + @Input() gap = 8; + @Input() overflowTemplate = '+{n}'; + @Input() overflowClass = 'tb-overflow-chip'; + @Input() minChips = 1; + + @Input() + @coerceBoolean() + showOverflowedTitle = false; + + private resizeObserver?: ResizeObserver; + private mutationObserver?: MutationObserver; + private overflowEl!: HTMLElement; + private timeoutId?: NodeJS.Timeout; + + constructor( + private host: ElementRef, + private renderer: Renderer2, + private zone: NgZone + ) {} + + ngAfterViewInit(): void { + this.prepareHost(); + this.createOverflowChip(); + this.zone.runOutsideAngular(() => { + requestAnimationFrame(() => { + this.reflow(); + this.observeResize(); + this.observeMutations(); + }); + }); + } + + ngOnDestroy(): void { + this.resizeObserver?.disconnect(); + this.mutationObserver?.disconnect(); + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + } + + private prepareHost() { + const el = this.host.nativeElement; + const styles: { [key: string]: string } = { + display: 'flex', + flexWrap: 'nowrap', + alignItems: 'center', + overflow: 'hidden', + gap: `${this.gap}px`, + whiteSpace: 'nowrap', + minWidth: '0' + }; + Object.entries(styles).forEach(([prop, value]) => + this.renderer.setStyle(el, prop, value) + ); + } + + private createOverflowChip() { + this.overflowEl = this.renderer.createElement('div') as HTMLElement; + this.renderer.setProperty(this.overflowEl, 'className', this.overflowClass); + this.renderer.setStyle(this.overflowEl, 'display', 'none'); + this.renderer.setStyle(this.overflowEl, 'userSelect', 'none'); + this.renderer.setStyle(this.overflowEl, 'pointerEvents', 'none'); + this.renderer.appendChild(this.host.nativeElement, this.overflowEl); + } + + private observeResize(): void { + this.resizeObserver = new ResizeObserver(() => { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + this.timeoutId = setTimeout(() => this.reflow()); + }); + this.resizeObserver.observe(this.host.nativeElement); + } + + private observeMutations(): void { + this.mutationObserver = new MutationObserver(mutations => { + const relevantMutations = mutations.some( + mutation => + mutation.type === 'childList' && + Array.from(mutation.addedNodes).some(node => node instanceof HTMLElement && node.matches(this.chipSelector)) || + Array.from(mutation.removedNodes).some(node => node instanceof HTMLElement && node.matches(this.chipSelector)) + ); + + if (relevantMutations) { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + this.timeoutId = setTimeout(() => this.reflow()); + } + }); + + this.mutationObserver.observe(this.host.nativeElement, { + childList: true, + }); + } + + private reflow() { + const container = this.host.nativeElement; + const chips = Array.from(container.querySelectorAll(this.chipSelector)) + .filter(el => el !== this.overflowEl); + + if (!chips.length) { + return; + } + + // 1. Show all chips to measure their natural widths + chips.forEach(chip => this.renderer.setStyle(chip, 'display', '')); + this.renderer.setStyle(this.overflowEl, 'display', 'none'); + + const chipWidths = chips.map(chip => Math.ceil(chip.offsetWidth)); + + // 2. Hide everything to measure the true available width + // without content inflating the table cell + chips.forEach(chip => this.renderer.setStyle(chip, 'display', 'none')); + + const availableWidth = container.clientWidth; + + if (!availableWidth) { + chips.forEach(chip => this.renderer.setStyle(chip, 'display', '')); + return; + } + + // 3. Measure overflow chip width + this.renderer.setProperty(this.overflowEl, 'textContent', this.overflowTemplate.replace('{n}', '9')); + this.renderer.setStyle(this.overflowEl, 'display', 'inline-flex'); + const minOverflowWidth = this.overflowEl.offsetWidth; + this.renderer.setStyle(this.overflowEl, 'display', 'none'); + + // 4. Determine which chips fit + let usedWidth = 0; + let hiddenCount = 0; + const hiddenChips: HTMLElement[] = []; + const minChips = Math.max(1, this.minChips); + + for (let i = 0; i < chips.length; i++) { + const chipWidth = chipWidths[i]; + const nextUsedWidth = usedWidth ? usedWidth + this.gap + chipWidth : chipWidth; + const withOverflow = nextUsedWidth + (nextUsedWidth ? this.gap : 0) + minOverflowWidth; + + if (i < minChips || withOverflow <= availableWidth) { + this.renderer.setStyle(chips[i], 'display', 'inline-flex'); + usedWidth = nextUsedWidth; + } else { + hiddenCount++; + hiddenChips.push(chips[i]); + } + } + + if (hiddenCount > 0) { + this.renderer.setProperty(this.overflowEl, 'textContent', this.overflowTemplate.replace('{n}', String(hiddenCount))); + this.renderer.setStyle(this.overflowEl, 'display', 'inline-flex'); + if (this.showOverflowedTitle) { + const title = hiddenChips + .map(chip => (chip.textContent || '').trim()) + .filter(text => !!text) + .join(', '); + this.renderer.setAttribute(this.overflowEl, 'title', title); + // Allow pointer interaction so the browser's native title + // tooltip appears on hover. + this.renderer.removeStyle(this.overflowEl, 'pointerEvents'); + } else { + this.renderer.removeAttribute(this.overflowEl, 'title'); + this.renderer.setStyle(this.overflowEl, 'pointerEvents', 'none'); + } + } else { + this.renderer.removeAttribute(this.overflowEl, 'title'); + this.renderer.setStyle(this.overflowEl, 'pointerEvents', 'none'); + } + } +} diff --git a/ui-ngx/src/app/shared/directives/photoswipe-gallery.directive.ts b/ui-ngx/src/app/shared/directives/photoswipe-gallery.directive.ts new file mode 100644 index 0000000000..22eb3c76dd --- /dev/null +++ b/ui-ngx/src/app/shared/directives/photoswipe-gallery.directive.ts @@ -0,0 +1,217 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core'; +import PhotoSwipeLightbox from 'photoswipe/lightbox'; +import PhotoSwipe from 'photoswipe'; +import cssjs from '@core/css/css'; + +const PHOTO_GALLERY_STYLE_ID = 'photoswipe-gallery-style'; +const PHOTO_GALLERY_CLASS = 'tb-photoswipe-gallery'; +const PHOTO_GALLERY_STYLE = + '{\n'+ + ' background: rgba(10, 10, 20, 0.55);\n' + + ' backdrop-filter: blur(18px);\n' + + ' opacity: 1;\n' + + '}\n' + + '\n' + + '.pswp__tb-photoswipe-caption {\n' + + ' position: fixed;\n' + + ' bottom: 1.5rem;\n' + + ' left: 50%;\n' + + ' transform: translate(-50%);\n' + + ' z-index: 10000;\n' + + ' display: flex;\n' + + ' flex-direction: column;\n' + + ' align-items: center;\n' + + ' gap: .25rem;\n' + + ' max-width: 80vw;\n' + + ' text-align: center;\n' + + ' pointer-events: none;\n' + + ' line-height: 1.75;\n' + + '}\n' + + '\n' + + '.pswp__tb-photoswipe-caption .tb-gallery-caption {\n' + + ' color: #fff;\n' + + ' font-size: 1.125rem;\n' + + ' line-height: 1.5;\n' + + ' background: #000000a6;\n' + + ' padding: .5rem 1.25rem;\n' + + ' border-radius: 8px;\n' + + ' backdrop-filter: blur(8px);\n' + + ' -webkit-backdrop-filter: blur(8px);\n' + + '}\n' + + '\n' + + '.pswp__tb-photoswipe-caption .tb-gallery-counter {\n' + + ' color: #fff9;\n' + + ' font-size: .8rem;\n' + + '}\n'+ + '\n' + + '.pswp__item img.pswp__img {\n' + + ' display: block;\n' + + ' object-fit: contain;\n' + + ' border-radius: 4px; \n' + + ' box-shadow: 0 20px 60px #00000080;\n' + + '}\n' + + '\n' + + '.pswp__item .pswp__img--placeholder {\n' + + ' border-radius: 4px; \n' + + '}\n' + + '\n' + + '.pswp__button {\n' + + ' border-radius: 50%;\n' + + ' border: 1px solid rgba(255, 255, 255, .2);\n' + + ' background: #1e1e2899;\n' + + ' backdrop-filter: blur(8px);\n' + + ' -webkit-backdrop-filter: blur(8px);\n' + + ' color: #fff;\n' + + ' transition: background .18s ease, transform .18s ease;\n' + + ' outline: none;\n' + + '}\n' + + '\n' + + '.pswp__button:hover {\n' + + ' background: #3c3c50d9;\n' + + ' transform: scale(1.08);\n' + + ' outline: none;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--close, .pswp__button.pswp__button--zoom {\n' + + ' width: 40px;\n' + + ' height: 40px;\n' + + ' margin-top: 16px;\n' + + ' margin-right: 16px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--close .pswp__icn, .pswp__button.pswp__button--zoom .pswp__icn {\n' + + ' width: 24px;\n' + + ' height: 24px;\n' + + ' top: 7px;\n' + + ' left: 7px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow {\n' + + ' width: 48px;\n' + + ' height: 48px;\n' + + ' margin-top: -24px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow .pswp__icn {\n' + + ' width: 32px;\n' + + ' height: 32px;\n' + + ' margin-top: 0;\n' + + ' top: 7px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow.pswp__button--arrow--prev {\n' + + ' left: 16px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow.pswp__button--arrow--prev .pswp__icn {\n' + + ' left: 12px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow.pswp__button--arrow--next {\n' + + ' right: 16px\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow.pswp__button--arrow--next .pswp__icn { \n' + + ' right: 12px;\n' + + '}'; + +@Directive({ + selector: '[tbPhotoSwipeGallery]', + standalone: false +}) +export class PhotoSwipeGalleryDirective implements OnInit, OnDestroy { + + @Input() galleryChildrenSelector = '.tb-image'; + @Input() imageCaptionSelector = '.tb-image-tooltip'; + + private lightbox: PhotoSwipeLightbox; + + constructor( + private elementRef: ElementRef + ) {} + + ngOnInit(): void { + this.initPhotoSwipeGalleryStyle(); + this.lightbox = new PhotoSwipeLightbox({ + gallery: this.elementRef.nativeElement, + children: this.galleryChildrenSelector, + pswpModule: PhotoSwipe, + counter: false, + bgOpacity: 0, + mainClass: PHOTO_GALLERY_CLASS + }); + this.lightbox.addFilter('domItemData', (itemData, element) => { + let image: HTMLImageElement; + if (element instanceof HTMLImageElement) { + image = element; + } else { + image = element.querySelector('img'); + } + itemData.src = image.src; + itemData.width = image.naturalWidth; + itemData.height = image.naturalHeight; + itemData.thumbCropped = true; + return itemData; + }); + this.lightbox.on('uiRegister', () => { + this.lightbox.pswp.ui.registerElement({ + name: 'tb-photoswipe-caption', + order: 9, + isButton: false, + appendTo: 'root', + html: '', + onInit: (el, pswp) => { + const caption = el.querySelector('.tb-gallery-caption'); + const counter = el.querySelector('.tb-gallery-counter'); + this.lightbox.pswp.on('change', () => { + counter.innerText = pswp.currIndex + 1 + pswp.options.indexIndicatorSep + pswp.getNumItems(); + const currSlideElement = this.lightbox.pswp.currSlide.data.element; + let imageTooltip: Element; + if (currSlideElement) { + imageTooltip = currSlideElement.querySelector(this.imageCaptionSelector); + } + if (imageTooltip) { + caption.style.display = 'block'; + caption.innerHTML = imageTooltip.innerHTML || ''; + } else { + caption.style.display = 'none'; + } + }); + } + }); + }); + this.lightbox.init(); + } + + ngOnDestroy(): void { + if (this.lightbox) { + this.lightbox.destroy(); + } + } + + private initPhotoSwipeGalleryStyle(): void { + const existingElement = document.getElementById(PHOTO_GALLERY_STYLE_ID); + if (!existingElement) { + const cssParser = new cssjs(); + cssParser.testMode = false; + cssParser.cssPreviewNamespace = PHOTO_GALLERY_CLASS; + cssParser.createStyleElement(PHOTO_GALLERY_STYLE_ID, PHOTO_GALLERY_STYLE); + } + } +} diff --git a/ui-ngx/src/app/shared/directives/public-api.ts b/ui-ngx/src/app/shared/directives/public-api.ts index 98c68dfd28..1ff38116fc 100644 --- a/ui-ngx/src/app/shared/directives/public-api.ts +++ b/ui-ngx/src/app/shared/directives/public-api.ts @@ -17,3 +17,4 @@ export * from './truncate-with-tooltip.directive'; export * from './ellipsis-chip-list.directive'; export * from './context-menu.directive'; +export * from './chip-overflow.directive'; diff --git a/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts b/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts index e6ce4b3ef7..757677fadd 100644 --- a/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts +++ b/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts @@ -632,6 +632,28 @@ export const widgetContextCompletionsWithSettings = (settingsCompletions?: TbEdi optional: true } ] + }, + invokeAction: { + description: 'Invoke action with JavaScript action source.', + meta: 'function', + args: [ + { + name: '$event', + description: 'DOM event object associated with action.', + type: 'Event' + }, + { + name: 'actionName', + description: 'Name of the configured action with JavaScript action source.', + type: 'string' + }, + { + name: 'additionalParams', + description: 'Optional payload merged into the action context and forwarded to the configured JavaScript action function as its additionalParams argument. Use it to pass row data, button state, or any extra values the action handler should react to.', + type: 'object', + optional: true + } + ] } } }, @@ -736,6 +758,17 @@ export const widgetContextCompletionsWithSettings = (settingsCompletions?: TbEdi } } } + }, + registerDestroyCallback: { + description: 'Registers a teardown callback that will be invoked exactly once when the widget is about to be destroyed (dashboard navigation, edit/view switch, layout change, etc.). Use it to release resources acquired during widget setup so they don\'t leak across widget reloads — e.g. unsubscribe RxJS subscriptions, detach DOM/window event listeners, clear setInterval / setTimeout timers, abort outstanding HTTP requests, destroy third-party plugin instances. Multiple callbacks may be registered; they are executed in registration order.', + meta: 'function', + args: [ + { + description: 'Zero-argument function executed when the widget is destroyed. Should be idempotent — synchronously dispose of one specific resource (e.g. one subscription or one listener) and avoid throwing; throwing here may prevent later cleanup callbacks from running.', + name: 'destroyCallback', + type: '() => void', + } + ] } }, ...serviceCompletions diff --git a/ui-ngx/src/app/shared/models/ai-model.models.ts b/ui-ngx/src/app/shared/models/ai-model.models.ts index 504e4d2659..575eba1d33 100644 --- a/ui-ngx/src/app/shared/models/ai-model.models.ts +++ b/ui-ngx/src/app/shared/models/ai-model.models.ts @@ -105,11 +105,11 @@ export const AiModelMap = new Map { + switch (provider) { + case AiProvider.ANTHROPIC: + case AiProvider.AMAZON_BEDROCK: + return [ResponseFormat.TEXT, ResponseFormat.JSON_SCHEMA]; + default: + return [ResponseFormat.TEXT, ResponseFormat.JSON, ResponseFormat.JSON_SCHEMA]; + } +}; + export interface AiModelWithUserMsg { userMessage: { contents: Array<{contentType: string; text: string}>; diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index 429bfe217a..1117f032ed 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -69,11 +69,14 @@ export const MediaBreakpoints = { 'lt-sm': 'screen and (max-width: 599px)', 'lt-md': 'screen and (max-width: 959px)', 'lt-lg': 'screen and (max-width: 1279px)', + 'lt-xmd': 'screen and (max-width: 1599px)', 'lt-xl': 'screen and (max-width: 1919px)', 'gt-xs': 'screen and (min-width: 600px)', 'gt-sm': 'screen and (min-width: 960px)', 'gt-md': 'screen and (min-width: 1280px)', + 'gt-xmd': 'screen and (min-width: 1600px)', 'gt-lg': 'screen and (min-width: 1920px)', + 'gt-xxl': 'screen and (min-width: 2448px)', 'gt-xl': 'screen and (min-width: 5001px)', 'md-lg': 'screen and (min-width: 960px) and (max-width: 1819px)' }; diff --git a/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts b/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts new file mode 100644 index 0000000000..97c3a3f7b6 --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts @@ -0,0 +1,329 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +export enum InstallMethod { + // Direct device-to-platform transports + DIRECT_HTTP = 'DIRECT_HTTP', + DIRECT_MQTT = 'DIRECT_MQTT', + DIRECT_COAP = 'DIRECT_COAP', + DIRECT_LWM2M = 'DIRECT_LWM2M', + DIRECT_SNMP = 'DIRECT_SNMP', + // ThingsBoard IoT Gateway connectors + GATEWAY_MQTT = 'GATEWAY_MQTT', + GATEWAY_MODBUS = 'GATEWAY_MODBUS', + GATEWAY_OPCUA = 'GATEWAY_OPCUA', + GATEWAY_BACNET = 'GATEWAY_BACNET', + GATEWAY_BLE = 'GATEWAY_BLE', + GATEWAY_CAN = 'GATEWAY_CAN', + GATEWAY_FTP = 'GATEWAY_FTP', + GATEWAY_KNX = 'GATEWAY_KNX', + GATEWAY_OCPP = 'GATEWAY_OCPP', + GATEWAY_ODBC = 'GATEWAY_ODBC', + GATEWAY_REQUEST = 'GATEWAY_REQUEST', + GATEWAY_REST = 'GATEWAY_REST', + GATEWAY_SNMP = 'GATEWAY_SNMP', + GATEWAY_SOCKET = 'GATEWAY_SOCKET', + GATEWAY_XMPP = 'GATEWAY_XMPP', + // ChirpStack (CE-compatible LoRaWAN integration) + CHIRPSTACK = 'CHIRPSTACK', + // ThingsBoard PE integrations (CE shows "PE only" gate) + INTEGRATION_APACHE_PULSAR = 'INTEGRATION_APACHE_PULSAR', + INTEGRATION_AWS_IOT = 'INTEGRATION_AWS_IOT', + INTEGRATION_AWS_KINESIS = 'INTEGRATION_AWS_KINESIS', + INTEGRATION_AWS_SQS = 'INTEGRATION_AWS_SQS', + INTEGRATION_AZURE_EVENT_HUB = 'INTEGRATION_AZURE_EVENT_HUB', + INTEGRATION_AZURE_IOT_HUB = 'INTEGRATION_AZURE_IOT_HUB', + INTEGRATION_AZURE_SERVICE_BUS = 'INTEGRATION_AZURE_SERVICE_BUS', + INTEGRATION_CHIRPSTACK = 'INTEGRATION_CHIRPSTACK', + INTEGRATION_COAP = 'INTEGRATION_COAP', + INTEGRATION_CUSTOM = 'INTEGRATION_CUSTOM', + INTEGRATION_HTTP = 'INTEGRATION_HTTP', + INTEGRATION_IOT_CREATORS = 'INTEGRATION_IOT_CREATORS', + INTEGRATION_KAFKA = 'INTEGRATION_KAFKA', + INTEGRATION_KPN_THINGS = 'INTEGRATION_KPN_THINGS', + INTEGRATION_LORIOT = 'INTEGRATION_LORIOT', + INTEGRATION_MQTT = 'INTEGRATION_MQTT', + INTEGRATION_OPC_UA = 'INTEGRATION_OPC_UA', + INTEGRATION_PARTICLE = 'INTEGRATION_PARTICLE', + INTEGRATION_PUB_SUB = 'INTEGRATION_PUB_SUB', + INTEGRATION_RABBITMQ = 'INTEGRATION_RABBITMQ', + INTEGRATION_REMOTE = 'INTEGRATION_REMOTE', + INTEGRATION_SIGFOX = 'INTEGRATION_SIGFOX', + INTEGRATION_TCP = 'INTEGRATION_TCP', + INTEGRATION_THINGPARK = 'INTEGRATION_THINGPARK', + INTEGRATION_THINGPARK_ENTERPRISE = 'INTEGRATION_THINGPARK_ENTERPRISE', + INTEGRATION_TTI = 'INTEGRATION_TTI', + INTEGRATION_TTN = 'INTEGRATION_TTN', + INTEGRATION_TUYA = 'INTEGRATION_TUYA', + INTEGRATION_UDP = 'INTEGRATION_UDP' +} + +export const installMethodLabels = new Map( + [ + // Direct + [InstallMethod.DIRECT_HTTP, 'HTTP'], + [InstallMethod.DIRECT_MQTT, 'MQTT'], + [InstallMethod.DIRECT_COAP, 'CoAP'], + [InstallMethod.DIRECT_LWM2M, 'LwM2M'], + [InstallMethod.DIRECT_SNMP, 'SNMP'], + // Gateway connectors + [InstallMethod.GATEWAY_MQTT, 'MQTT Gateway'], + [InstallMethod.GATEWAY_MODBUS, 'Modbus Gateway'], + [InstallMethod.GATEWAY_OPCUA, 'OPC-UA Gateway'], + [InstallMethod.GATEWAY_BACNET, 'BACnet Gateway'], + [InstallMethod.GATEWAY_BLE, 'BLE Gateway'], + [InstallMethod.GATEWAY_CAN, 'CAN Gateway'], + [InstallMethod.GATEWAY_FTP, 'FTP Gateway'], + [InstallMethod.GATEWAY_KNX, 'KNX Gateway'], + [InstallMethod.GATEWAY_OCPP, 'OCPP Gateway'], + [InstallMethod.GATEWAY_ODBC, 'ODBC Gateway'], + [InstallMethod.GATEWAY_REQUEST, 'Request Gateway'], + [InstallMethod.GATEWAY_REST, 'REST Gateway'], + [InstallMethod.GATEWAY_SNMP, 'SNMP Gateway'], + [InstallMethod.GATEWAY_SOCKET, 'Socket Gateway'], + [InstallMethod.GATEWAY_XMPP, 'XMPP Gateway'], + // ChirpStack + [InstallMethod.CHIRPSTACK, 'ChirpStack'], + // PE integrations + [InstallMethod.INTEGRATION_APACHE_PULSAR, 'Apache Pulsar'], + [InstallMethod.INTEGRATION_AWS_IOT, 'AWS IoT'], + [InstallMethod.INTEGRATION_AWS_KINESIS, 'AWS Kinesis'], + [InstallMethod.INTEGRATION_AWS_SQS, 'AWS SQS'], + [InstallMethod.INTEGRATION_AZURE_EVENT_HUB, 'Azure Event Hub'], + [InstallMethod.INTEGRATION_AZURE_IOT_HUB, 'Azure IoT Hub'], + [InstallMethod.INTEGRATION_AZURE_SERVICE_BUS, 'Azure Service Bus'], + [InstallMethod.INTEGRATION_CHIRPSTACK, 'ChirpStack (Integration)'], + [InstallMethod.INTEGRATION_COAP, 'CoAP Integration'], + [InstallMethod.INTEGRATION_CUSTOM, 'Custom Integration'], + [InstallMethod.INTEGRATION_HTTP, 'HTTP Integration'], + [InstallMethod.INTEGRATION_IOT_CREATORS, 'IoT Creators'], + [InstallMethod.INTEGRATION_KAFKA, 'Apache Kafka'], + [InstallMethod.INTEGRATION_KPN_THINGS, 'KPN Things'], + [InstallMethod.INTEGRATION_LORIOT, 'LORIOT'], + [InstallMethod.INTEGRATION_MQTT, 'MQTT Integration'], + [InstallMethod.INTEGRATION_OPC_UA, 'OPC-UA Integration'], + [InstallMethod.INTEGRATION_PARTICLE, 'Particle'], + [InstallMethod.INTEGRATION_PUB_SUB, 'Google Pub/Sub'], + [InstallMethod.INTEGRATION_RABBITMQ, 'RabbitMQ'], + [InstallMethod.INTEGRATION_REMOTE, 'Remote Integration'], + [InstallMethod.INTEGRATION_SIGFOX, 'Sigfox'], + [InstallMethod.INTEGRATION_TCP, 'TCP Integration'], + [InstallMethod.INTEGRATION_THINGPARK, 'ThingPark Wireless'], + [InstallMethod.INTEGRATION_THINGPARK_ENTERPRISE, 'ThingPark Enterprise'], + [InstallMethod.INTEGRATION_TTI, 'The Things Industries'], + [InstallMethod.INTEGRATION_TTN, 'The Things Stack'], + [InstallMethod.INTEGRATION_TUYA, 'Tuya'], + [InstallMethod.INTEGRATION_UDP, 'UDP Integration'] + ] +); + +export const installMethodIcons = new Map( + [ + // Direct connect — assets/direct-connect-icon + [InstallMethod.DIRECT_HTTP, 'assets/direct-connect-icon/http.svg'], + [InstallMethod.DIRECT_MQTT, 'assets/direct-connect-icon/mqtt.svg'], + [InstallMethod.DIRECT_COAP, 'assets/direct-connect-icon/coap.svg'], + [InstallMethod.DIRECT_LWM2M, 'assets/direct-connect-icon/lwm2m.svg'], + [InstallMethod.DIRECT_SNMP, 'assets/direct-connect-icon/snmp.svg'], + // Gateway connectors — assets/gateway-connect-icon + [InstallMethod.GATEWAY_MQTT, 'assets/gateway-connect-icon/mqtt.svg'], + [InstallMethod.GATEWAY_MODBUS, 'assets/gateway-connect-icon/modbus.svg'], + [InstallMethod.GATEWAY_OPCUA, 'assets/gateway-connect-icon/opc-ua.svg'], + [InstallMethod.GATEWAY_BACNET, 'assets/gateway-connect-icon/bacnet.svg'], + [InstallMethod.GATEWAY_BLE, 'assets/gateway-connect-icon/ble.svg'], + [InstallMethod.GATEWAY_CAN, 'assets/gateway-connect-icon/can.svg'], + [InstallMethod.GATEWAY_FTP, 'assets/gateway-connect-icon/ftp.svg'], + [InstallMethod.GATEWAY_OCPP, 'assets/gateway-connect-icon/ocpp.svg'], + [InstallMethod.GATEWAY_ODBC, 'assets/gateway-connect-icon/odbc.svg'], + [InstallMethod.GATEWAY_REQUEST, 'assets/gateway-connect-icon/request.svg'], + [InstallMethod.GATEWAY_REST, 'assets/gateway-connect-icon/rest.svg'], + [InstallMethod.GATEWAY_SNMP, 'assets/gateway-connect-icon/snmp.svg'], + [InstallMethod.GATEWAY_SOCKET, 'assets/gateway-connect-icon/socket.svg'], + [InstallMethod.GATEWAY_XMPP, 'assets/gateway-connect-icon/xmpp.svg'], + // ChirpStack — reuses the integration icon + [InstallMethod.CHIRPSTACK, 'assets/integration-icon/chirpstack.svg'], + // PE integrations — assets/integration-icon + [InstallMethod.INTEGRATION_APACHE_PULSAR, 'assets/integration-icon/apache-pulsar.svg'], + [InstallMethod.INTEGRATION_AWS_IOT, 'assets/integration-icon/aws-iot.svg'], + [InstallMethod.INTEGRATION_AWS_KINESIS, 'assets/integration-icon/aws-kinesis.svg'], + [InstallMethod.INTEGRATION_AWS_SQS, 'assets/integration-icon/aws-sqs.svg'], + [InstallMethod.INTEGRATION_AZURE_EVENT_HUB, 'assets/integration-icon/azure-event-hub.svg'], + [InstallMethod.INTEGRATION_AZURE_IOT_HUB, 'assets/integration-icon/azure-iot-hub.svg'], + [InstallMethod.INTEGRATION_AZURE_SERVICE_BUS, 'assets/integration-icon/azure-service-bus.svg'], + [InstallMethod.INTEGRATION_CHIRPSTACK, 'assets/integration-icon/chirpstack.svg'], + [InstallMethod.INTEGRATION_COAP, 'assets/integration-icon/coap.svg'], + [InstallMethod.INTEGRATION_CUSTOM, 'assets/integration-icon/custom.svg'], + [InstallMethod.INTEGRATION_HTTP, 'assets/integration-icon/http.svg'], + [InstallMethod.INTEGRATION_IOT_CREATORS, 'assets/integration-icon/iotcreators.com.svg'], + [InstallMethod.INTEGRATION_KAFKA, 'assets/integration-icon/kafka.svg'], + [InstallMethod.INTEGRATION_KPN_THINGS, 'assets/integration-icon/kpn.svg'], + [InstallMethod.INTEGRATION_LORIOT, 'assets/integration-icon/loriot.svg'], + [InstallMethod.INTEGRATION_MQTT, 'assets/integration-icon/mqtt.svg'], + [InstallMethod.INTEGRATION_OPC_UA, 'assets/integration-icon/opc-ua.svg'], + [InstallMethod.INTEGRATION_PARTICLE, 'assets/integration-icon/particle.svg'], + [InstallMethod.INTEGRATION_PUB_SUB, 'assets/integration-icon/pub-sub.svg'], + [InstallMethod.INTEGRATION_RABBITMQ, 'assets/integration-icon/rabbitmq.svg'], + [InstallMethod.INTEGRATION_SIGFOX, 'assets/integration-icon/sigfox.svg'], + [InstallMethod.INTEGRATION_TCP, 'assets/integration-icon/tcp.svg'], + [InstallMethod.INTEGRATION_THINGPARK, 'assets/integration-icon/thingpark.svg'], + [InstallMethod.INTEGRATION_THINGPARK_ENTERPRISE, 'assets/integration-icon/thingpark-enterprise.svg'], + [InstallMethod.INTEGRATION_TTI, 'assets/integration-icon/things-stack-industries.svg'], + [InstallMethod.INTEGRATION_TTN, 'assets/integration-icon/things-stack-сommunity.svg'], + [InstallMethod.INTEGRATION_TUYA, 'assets/integration-icon/tuya.svg'], + [InstallMethod.INTEGRATION_UDP, 'assets/integration-icon/udp.svg'] + ] +); + +export const peOnlyInstallMethods: ReadonlySet = new Set([ + InstallMethod.INTEGRATION_APACHE_PULSAR, + InstallMethod.INTEGRATION_AWS_IOT, + InstallMethod.INTEGRATION_AWS_KINESIS, + InstallMethod.INTEGRATION_AWS_SQS, + InstallMethod.INTEGRATION_AZURE_EVENT_HUB, + InstallMethod.INTEGRATION_AZURE_IOT_HUB, + InstallMethod.INTEGRATION_AZURE_SERVICE_BUS, + InstallMethod.INTEGRATION_CHIRPSTACK, + InstallMethod.INTEGRATION_COAP, + InstallMethod.INTEGRATION_CUSTOM, + InstallMethod.INTEGRATION_HTTP, + InstallMethod.INTEGRATION_IOT_CREATORS, + InstallMethod.INTEGRATION_KAFKA, + InstallMethod.INTEGRATION_KPN_THINGS, + InstallMethod.INTEGRATION_LORIOT, + InstallMethod.INTEGRATION_MQTT, + InstallMethod.INTEGRATION_OPC_UA, + InstallMethod.INTEGRATION_PARTICLE, + InstallMethod.INTEGRATION_PUB_SUB, + InstallMethod.INTEGRATION_RABBITMQ, + InstallMethod.INTEGRATION_REMOTE, + InstallMethod.INTEGRATION_SIGFOX, + InstallMethod.INTEGRATION_TCP, + InstallMethod.INTEGRATION_THINGPARK, + InstallMethod.INTEGRATION_THINGPARK_ENTERPRISE, + InstallMethod.INTEGRATION_TTI, + InstallMethod.INTEGRATION_TTN, + InstallMethod.INTEGRATION_TUYA, + InstallMethod.INTEGRATION_UDP +]); + +export enum InstallStepType { + SHOW_INSTRUCTION = 'SHOW_INSTRUCTION', + SHOW_FORM = 'SHOW_FORM', + DEVICE_PROFILE = 'DEVICE_PROFILE', + CONVERTER = 'CONVERTER', + INTEGRATION = 'INTEGRATION', + DEVICE = 'DEVICE', + GATEWAY = 'GATEWAY', + GATEWAY_CONNECTOR = 'GATEWAY_CONNECTOR', + DASHBOARD = 'DASHBOARD', + RULE_CHAIN = 'RULE_CHAIN' +} + +export const ENTITY_STEP_TYPES = new Set([ + InstallStepType.DEVICE_PROFILE, + InstallStepType.DEVICE, + InstallStepType.GATEWAY, + InstallStepType.GATEWAY_CONNECTOR, + InstallStepType.DASHBOARD, + InstallStepType.RULE_CHAIN +]); + +export const stepTypeAliasMap: Record = { + [InstallStepType.DEVICE_PROFILE]: 'deviceProfile', + [InstallStepType.DEVICE]: 'device', + [InstallStepType.GATEWAY]: 'gateway', + [InstallStepType.GATEWAY_CONNECTOR]: 'gatewayConnector', + [InstallStepType.DASHBOARD]: 'dashboard', + [InstallStepType.RULE_CHAIN]: 'ruleChain' +}; + +export interface DeviceInstallStep { + type: InstallStepType; + name: string; + file?: string; + template?: string; + serverAttributes?: string; + sharedAttributes?: string; + credentials?: string; + dockerCompose?: string; +} + +export interface DevicePackageInfo { + name: string; + description: string; + vendor: string; + hardwareType: string; + installMethods: string[]; + installSteps: Record; + productURL?: string; + datasheetURL?: string; +} + +export enum FormFieldType { + STRING = 'STRING', + INTEGER = 'INTEGER', + BOOLEAN = 'BOOLEAN', + SELECT = 'SELECT', + PASSWORD = 'PASSWORD' +} + +export interface FormFieldValidator { + pattern: string; + message: string; +} + +export interface FormFieldOption { + value: string; + label: string; +} + +export interface FormFieldDefinition { + key: string; + label: string; + type: FormFieldType; + defaultValue?: any; + required?: boolean; + helpText?: string; + helpImage?: string; + validators?: FormFieldValidator[]; + options?: FormFieldOption[]; + randomGenerator?: boolean; + randomSize?: number; + randomByDefault?: boolean; +} + +export interface EntityStepOutput { + id: string; + name: string; + url?: string; + token?: string; + dockerComposeUrl?: string; +} + +export type EntityStepStatus = 'pending' | 'running' | 'success' | 'error' | 'conflict'; + +export type ConflictType = 'use-or-overwrite' | 'overwrite-or-copy'; + +export interface EntityStepProgress { + step: DeviceInstallStep; + status: EntityStepStatus; + resolvedName?: string; + entityOutput?: EntityStepOutput; + errorMessage?: string; + existingEntity?: EntityStepOutput; + conflictType?: ConflictType; + resolution?: string; +} diff --git a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-creator.models.ts b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-creator.models.ts new file mode 100644 index 0000000000..3cf365fbb5 --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-creator.models.ts @@ -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. +/// + +export interface CreatorView { + id: string; + createdTime: number; + displayName: string; + contactEmail: string; + website: string; + description: string; + avatarUrl: string; + githubUrl: string; + linkedinUrl: string; + twitterUrl: string; + youtubeUrl: string; + publishedCount: number; + verified: boolean; +} diff --git a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts new file mode 100644 index 0000000000..b2320453bb --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts @@ -0,0 +1,183 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { BaseData } from '@shared/models/base-data'; +import { EntityType } from '@shared/models/entity-type.models'; +import { getEntityDetailsPageURL } from '@core/utils'; + +export interface WidgetInstalledItemDescriptor { + type: 'WIDGET'; + widgetTypeId: { id: string }; +} + +export interface DashboardInstalledItemDescriptor { + type: 'DASHBOARD'; + dashboardId: { id: string }; +} + +export interface CalculatedFieldInstalledItemDescriptor { + type: 'CALCULATED_FIELD'; + calculatedFieldId: { id: string }; + entityId: { entityType: string; id: string }; +} + +export interface AlarmRuleInstalledItemDescriptor { + type: 'ALARM_RULE'; + calculatedFieldId: { id: string }; + entityId: { entityType: string; id: string }; +} + +export interface RuleChainInstalledItemDescriptor { + type: 'RULE_CHAIN'; + ruleChainId: { id: string }; +} + +export interface DeviceInstalledItemDescriptor { + type: 'DEVICE'; + createdEntityIds?: { entityType: string; id: string }[]; + dashboardId?: { id: string }; + selectedInstallMethod?: string; + installState?: Record; +} + +export interface SolutionTemplateInstalledItemDescriptor { + type: 'SOLUTION_TEMPLATE'; + createdEntityIds: { entityType: string; id: string }[]; + dashboardId: { id: string }; + publicId: { id: string }; + mainDashboardPublic: boolean; + details: string; +} + +export type IotHubInstalledItemDescriptor = + | WidgetInstalledItemDescriptor + | DashboardInstalledItemDescriptor + | CalculatedFieldInstalledItemDescriptor + | AlarmRuleInstalledItemDescriptor + | RuleChainInstalledItemDescriptor + | DeviceInstalledItemDescriptor + | SolutionTemplateInstalledItemDescriptor; + +export interface InstallItemVersionResult { + success: boolean; + errorMessage: string; + descriptor: IotHubInstalledItemDescriptor; +} + +export enum InstallPlanEntryStatus { + WILL_INSTALL = 'WILL_INSTALL', + ALREADY_INSTALLED = 'ALREADY_INSTALLED', + MISSING = 'MISSING' +} + +export interface InstallPlanEntry { + itemId: string; + versionId: string; + name: string; + type: string; + version: string; + status: InstallPlanEntryStatus; + root: boolean; + errorMessage?: string; +} + +export interface InstallPlan { + rootVersionId: string; + entries: InstallPlanEntry[]; +} + +export interface InstallPlanResult { + success: boolean; + rolledBack: boolean; + errorMessage?: string; + rootDescriptor?: IotHubInstalledItemDescriptor; + entries: InstallPlanEntry[]; + missingItemIds: string[]; +} + +export interface UpdateItemVersionResult { + success: boolean; + entityModified: boolean; + errorMessage: string; + descriptor: IotHubInstalledItemDescriptor; +} + +export interface ItemPublishedVersionInfo { + itemId: string; + publishedVersionId: string; + publishedVersion: string; +} + +export interface IotHubInstalledItem extends BaseData<{id: string}> { + tenantId: { id: string }; + itemId: string; + itemVersionId: string; + itemName: string; + itemType: string; + version: string; + descriptor: IotHubInstalledItemDescriptor; +} + +export const getInstalledItemUrl = (descriptor?: IotHubInstalledItemDescriptor): string | null => { + if (!descriptor) { + return null; + } + let entityId: string | null = null; + let entityType: EntityType | null = null; + let query: string | null = null; + switch (descriptor.type) { + case 'DEVICE': + entityId = descriptor.dashboardId?.id; + entityType = EntityType.DASHBOARD; + break; + case 'WIDGET': + entityId = descriptor.widgetTypeId?.id; + entityType = EntityType.WIDGET_TYPE; + break; + case 'DASHBOARD': + entityId = descriptor.dashboardId?.id; + entityType = EntityType.DASHBOARD; + break; + case 'CALCULATED_FIELD': + case 'ALARM_RULE': + entityId = descriptor.entityId?.id; + entityType = descriptor.entityId?.entityType as EntityType; + if (descriptor.type === 'CALCULATED_FIELD') { + query = 'selectedTab=cf'; + } + break; + case 'RULE_CHAIN': + entityId = descriptor.ruleChainId?.id; + entityType = EntityType.RULE_CHAIN; + break; + case 'SOLUTION_TEMPLATE': + entityId = descriptor.dashboardId?.id; + entityType = EntityType.DASHBOARD; + break; + } + if (entityType && entityId) { + let url = getEntityDetailsPageURL(entityId, entityType); + if (url) { + if (query) { + url = `${url}?${query}`; + } + return url; + } else { + return null; + } + } + return null; +} diff --git a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts new file mode 100644 index 0000000000..6c528384c4 --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.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. +/// + +export enum ItemType { + WIDGET = 'WIDGET', + DASHBOARD = 'DASHBOARD', + SOLUTION_TEMPLATE = 'SOLUTION_TEMPLATE', + CALCULATED_FIELD = 'CALCULATED_FIELD', + ALARM_RULE = 'ALARM_RULE', + RULE_CHAIN = 'RULE_CHAIN', + DEVICE = 'DEVICE' +} + +export const itemTypeTranslations = new Map( + [ + [ItemType.WIDGET, 'item.type-widget'], + [ItemType.DASHBOARD, 'item.type-dashboard'], + [ItemType.SOLUTION_TEMPLATE, 'item.type-solution-template'], + [ItemType.CALCULATED_FIELD, 'item.type-calculated-field'], + [ItemType.ALARM_RULE, 'item.type-alarm-rule'], + [ItemType.RULE_CHAIN, 'item.type-rule-chain'], + [ItemType.DEVICE, 'item.type-device'] + ] +); + +// Canonical icon lookup per item type. Values are tb-icon +// identifiers (Material symbol names or `mdi:*` strings) and should +// be used everywhere an icon is rendered for an item type so the +// mapping stays consistent across the app. +export const itemTypeIcons: Record = { + [ItemType.WIDGET]: 'widgets', + [ItemType.DASHBOARD]: 'dashboard', + [ItemType.SOLUTION_TEMPLATE]: 'apps', + [ItemType.CALCULATED_FIELD]: 'mdi:function-variant', + [ItemType.RULE_CHAIN]: 'settings_ethernet', + [ItemType.ALARM_RULE]: 'mdi:bell-cog', + [ItemType.DEVICE]: 'devices_other' +}; + +export const getItemTypeIcon = (type?: string | null): string => + type && itemTypeIcons[type] ? itemTypeIcons[type] : 'category'; + +/** + * Item types discoverable to creators in the marketplace UI. + * DASHBOARD is intentionally absent (IoT Hub no longer accepts Dashboard contributions). + * Defensive code paths (item card, detail dialog descriptor switch, installed-items table, + * install handler, /iot-hub/dashboards route) remain functional for already-installed items. + */ +export const CREATOR_VISIBLE_ITEM_TYPES: ItemType[] = [ + ItemType.WIDGET, + ItemType.SOLUTION_TEMPLATE, + ItemType.DEVICE, + ItemType.CALCULATED_FIELD, + ItemType.ALARM_RULE, + ItemType.RULE_CHAIN, +]; + +export interface FilterParamInfo { + key: string; + totalItems: number; + totalInstallCount: number; +} + +export interface ItemTypeFilterInfo { + types: FilterParamInfo[]; + categories: FilterParamInfo[]; + useCases: FilterParamInfo[]; + vendors: FilterParamInfo[]; + hardwareTypes: FilterParamInfo[]; + connectivities: Record; +} + +export interface WidgetCategory { + name: string; + image: string; +} diff --git a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts new file mode 100644 index 0000000000..250a2f19f7 --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.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 { ItemType } from './iot-hub-item.models'; +import { PageLink } from '@shared/models/page/page-link'; + +export const widgetTypeTranslations = new Map([ + ['timeseries', 'item-data.widget-type-timeseries'], + ['latest', 'item-data.widget-type-latest'], + ['rpc', 'item-data.widget-type-rpc'], + ['alarm', 'item-data.widget-type-alarm'], + ['static', 'item-data.widget-type-static'], +]); + +export const cfTypeTranslations = new Map([ + ['SIMPLE', 'item-data.cf-type-simple'], + ['SCRIPT', 'item-data.cf-type-script'], + ['GEOFENCING', 'item-data.cf-type-geofencing'], + ['ALARM', 'item-data.cf-type-alarm'], + ['PROPAGATION', 'item-data.cf-type-propagation'], + ['RELATED_ENTITIES_AGGREGATION', 'item-data.cf-type-related-entities-aggregation'], + ['ENTITY_AGGREGATION', 'item-data.cf-type-entity-aggregation'], +]); + +export const cfTypeIcons = new Map([ + ['SIMPLE', 'calculate'], + ['SCRIPT', 'code'], + ['GEOFENCING', 'share_location'], + ['ALARM', 'notification_important'], + ['PROPAGATION', 'account_tree'], + ['RELATED_ENTITIES_AGGREGATION', 'hub'], + ['ENTITY_AGGREGATION', 'functions'], +]); + +export enum NodeComponentType { + ENRICHMENT = 'ENRICHMENT', + FILTER = 'FILTER', + TRANSFORMATION = 'TRANSFORMATION', + ACTION = 'ACTION', + ANALYTICS = 'ANALYTICS', + EXTERNAL = 'EXTERNAL', + FLOW = 'FLOW', + UNKNOWN = 'UNKNOWN' +} + +export const nodeComponentTypeTranslations = new Map([ + [NodeComponentType.ENRICHMENT, 'item-data.node-type-enrichment'], + [NodeComponentType.FILTER, 'item-data.node-type-filter'], + [NodeComponentType.TRANSFORMATION, 'item-data.node-type-transformation'], + [NodeComponentType.ACTION, 'item-data.node-type-action'], + [NodeComponentType.ANALYTICS, 'item-data.node-type-analytics'], + [NodeComponentType.EXTERNAL, 'item-data.node-type-external'], + [NodeComponentType.FLOW, 'item-data.node-type-flow'], + [NodeComponentType.UNKNOWN, 'item-data.node-type-unknown'], +]); + +export interface NodeInfo { + name: string; + type: NodeComponentType; +} + +export const ruleChainTypeTranslations = new Map([ + ['CORE', 'item-data.rule-chain-type-core'], + ['EDGE', 'item-data.rule-chain-type-edge'], +]); + +export interface MpItemVersionResource { + id: string; + type: string; +} + +export interface MpItemVersionView { + id: string; + createdTime: number; + version: string; + publishedTime: number; + changelog: string; + dataDescriptor: any; + image: string; + icon: string; + color: string; + description: string; + categories: string[]; + useCases: string[]; + itemId: string; + creatorId: string; + name: string; + type: ItemType; + peOnly: boolean; + tags: string[]; + creatorDisplayName: string; + creatorWebsite: string; + creatorContactEmail: string; + creatorDescription: string; + creatorAvatarUrl: string; + creatorVerified: boolean; + installCount: number; + totalInstallCount: number; + resources: MpItemVersionResource[]; + relatedItems?: string[]; + checksum?: string; +} + +// 404 body shapes returned by the public listing item-version endpoint +// (GET /api/listings/public/by-slug/{slug}/item-version) when no +// version matches the caller's edition / platform combination. +export interface ListingItemVersionNotFound { + noMatchingVersions?: boolean; + peRequired?: boolean; + minTbVersionRequired?: number; +} + +export interface MpItemVersionQueryOptions { + type?: string; + peOnly?: boolean; + creatorId?: string; + categories?: string[]; + useCases?: string[]; + cfTypes?: string[]; + widgetTypes?: string[]; + ruleChainTypes?: string[]; + tbVersion?: number; + hardwareTypes?: string[]; + connectivity?: string[]; + vendors?: string[]; + scadaFirst?: boolean; +} + +export class MpItemVersionQuery { + constructor(public pageLink: PageLink, public options: MpItemVersionQueryOptions = {}) {} + + public toQuery(): string { + let query = this.pageLink.toQuery(); + const o = this.options; + if (o.type) { + query += `&type=${o.type}`; + } + if (o.peOnly != null) { + query += `&peOnly=${o.peOnly}`; + } + if (o.creatorId) { + query += `&creatorId=${o.creatorId}`; + } + if (o.categories?.length) { + query += o.categories.map(c => `&categories=${encodeURIComponent(c)}`).join(''); + } + if (o.useCases?.length) { + query += o.useCases.map(u => `&useCases=${encodeURIComponent(u)}`).join(''); + } + if (o.cfTypes?.length) { + query += o.cfTypes.map(t => `&cfTypes=${encodeURIComponent(t)}`).join(''); + } + if (o.widgetTypes?.length) { + query += o.widgetTypes.map(t => `&widgetTypes=${encodeURIComponent(t)}`).join(''); + } + if (o.ruleChainTypes?.length) { + query += o.ruleChainTypes.map(t => `&ruleChainTypes=${encodeURIComponent(t)}`).join(''); + } + if (o.tbVersion != null) { + query += `&tbVersion=${o.tbVersion}`; + } + if (o.hardwareTypes?.length) { + query += o.hardwareTypes.map(ht => `&hardwareTypes=${encodeURIComponent(ht)}`).join(''); + } + if (o.connectivity?.length) { + query += o.connectivity.map(c => `&connectivity=${encodeURIComponent(c)}`).join(''); + } + if (o.vendors?.length) { + query += o.vendors.map(v => `&vendors=${encodeURIComponent(v)}`).join(''); + } + if (o.scadaFirst != null) { + query += `&scadaFirst=${o.scadaFirst}`; + } + return query; + } +} diff --git a/ui-ngx/src/app/shared/models/widget/maps/map.models.ts b/ui-ngx/src/app/shared/models/widget/maps/map.models.ts index 6db3b1481b..e8141e9eb1 100644 --- a/ui-ngx/src/app/shared/models/widget/maps/map.models.ts +++ b/ui-ngx/src/app/shared/models/widget/maps/map.models.ts @@ -1127,6 +1127,8 @@ export interface ImageMapSourceSettings { entityKey?: DataKey; } +export const WEBGL_ERROR_EVENT = 'gl-error'; + export const imageMapSourceSettingsValid = (imageSource: ImageMapSourceSettings): boolean => { if (!imageSource?.sourceType) { return false; diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index c3791c1332..d5073332d2 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -68,6 +68,7 @@ import { EditorModule, TINYMCE_SCRIPT_SRC } from '@tinymce/tinymce-angular'; import { UserMenuComponent } from '@shared/components/user-menu.component'; import { TruncateWithTooltipDirective } from '@shared/directives/truncate-with-tooltip.directive'; import { ContextMenuDirective } from '@shared/directives/context-menu.directive'; +import { ChipOverflowDirective } from '@shared/directives/chip-overflow.directive'; import { NospacePipe } from '@shared/pipe/nospace.pipe'; import { TranslateModule } from '@ngx-translate/core'; import { TbCheckboxComponent } from '@shared/components/tb-checkbox.component'; @@ -146,7 +147,6 @@ import { QueueAutocompleteComponent } from '@shared/components/queue/queue-autoc import { ContactComponent } from '@shared/components/contact.component'; import { TimezoneSelectComponent } from '@shared/components/time/timezone-select.component'; import { FileSizePipe } from '@shared/pipe/file-size.pipe'; -import { WidgetsBundleSearchComponent } from '@shared/components/widgets-bundle-search.component'; import { SelectableColumnsPipe } from '@shared/pipe/selectable-columns.pipe'; import { QuickTimeIntervalComponent } from '@shared/components/time/quick-time-interval.component'; import { OtaPackageAutocompleteComponent } from '@shared/components/ota-package/ota-package-autocomplete.component'; @@ -229,6 +229,10 @@ import { EntityKeyAutocompleteComponent } from '@shared/components/entity/entity import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; import { MqttVersionSelectComponent } from '@shared/components/mqtt-version-select.component'; import { MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS } from '@angular/material/button-toggle'; +import { PhotoSwipeGalleryDirective } from '@shared/directives/photoswipe-gallery.directive'; +import { + AutocompleteAutoScrollRepositionDirective +} from '@shared/directives/autocomplete-auto-scroll-reposition.directive'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -277,8 +281,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, useValue: { - hasBackdrop: true, - backdropClass: 'cdk-overlay-transparent-backdrop', hideSingleSelectionIndicator: true } }, @@ -386,6 +388,9 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) MarkdownEditorComponent, TruncateWithTooltipDirective, ContextMenuDirective, + ChipOverflowDirective, + PhotoSwipeGalleryDirective, + AutocompleteAutoScrollRepositionDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -406,7 +411,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) EntityGatewaySelectComponent, ContactComponent, OtaPackageAutocompleteComponent, - WidgetsBundleSearchComponent, CopyButtonComponent, TogglePasswordComponent, WidgetButtonToggleComponent, @@ -651,6 +655,9 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) MarkdownEditorComponent, TruncateWithTooltipDirective, ContextMenuDirective, + ChipOverflowDirective, + PhotoSwipeGalleryDirective, + AutocompleteAutoScrollRepositionDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -672,7 +679,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) EntityGatewaySelectComponent, ContactComponent, OtaPackageAutocompleteComponent, - WidgetsBundleSearchComponent, CopyButtonComponent, TogglePasswordComponent, WidgetButtonToggleComponent, diff --git a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json index 6e3b698200..44c1541429 100644 --- a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json +++ b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json @@ -6,6 +6,31 @@ "configuration": { "description": "", "widgets": { + "fe678c4f-ea86-4cae-8d81-6fca493fe311": { + "type": "static", + "sizeX": 7.5, + "sizeY": 6.5, + "config": { + "showTitle": false, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "16px", + "settings": {}, + "title": "IoT Hub", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": {}, + "widgetCss": "", + "pageSize": 1024, + "noDataDisplayMessage": "", + "showLegend": false, + "datasources": [] + }, + "row": 0, + "col": 0, + "id": "fe678c4f-ea86-4cae-8d81-6fca493fe311", + "typeFullFqn": "system.home_page_widgets.iot_hub" + }, "d70cc256-4c7b-ee06-9905-b8c5e546605f": { "type": "latest", "sizeX": 5, @@ -1228,55 +1253,63 @@ "widgets": { "d70cc256-4c7b-ee06-9905-b8c5e546605f": { "sizeX": 42, - "sizeY": 29, - "row": 13, + "sizeY": 17, + "row": 27, "col": 43, - "mobileOrder": 4, + "mobileOrder": 5, "mobileHeight": 10 }, "867f82cf-ecf2-2d5c-35cb-08c6f2edc3a4": { "sizeX": 29, - "sizeY": 16, - "row": 42, + "sizeY": 14, + "row": 44, "col": 28, "mobileHide": true }, "ebbd0a6e-8a47-e770-5086-7f4974250f2d": { "sizeX": 28, - "sizeY": 16, - "row": 42, + "sizeY": 14, + "row": 44, "col": 57, - "mobileOrder": 3, + "mobileOrder": 4, "mobileHeight": 10 }, "9e3ef045-d8bc-1640-a3f4-2dd10b19d50e": { "sizeX": 28, - "sizeY": 16, - "row": 42, + "sizeY": 14, + "row": 44, "col": 0, "mobileHide": true }, "6d6e2b1d-6ce7-4678-3745-c6b0897b2674": { "sizeX": 43, - "sizeY": 29, - "row": 13, + "sizeY": 17, + "row": 27, "col": 0, "mobileOrder": 2, "mobileHeight": 10 }, + "fe678c4f-ea86-4cae-8d81-6fca493fe311": { + "sizeX": 85, + "sizeY": 15, + "row": 0, + "col": 0, + "mobileOrder": 3, + "mobileHeight": 12 + }, "35d3ac3b-f1af-dc3d-a1d1-11f351d16fc6": { "sizeX": 43, - "sizeY": 13, - "row": 0, + "sizeY": 12, + "row": 15, "col": 0, "mobileHeight": 9, "mobileOrder": 1 }, "7ac20b6a-dc40-b18e-9f5f-bca20bc693bb": { "sizeX": 42, - "sizeY": 13, + "sizeY": 12, "mobileHeight": 9, - "row": 0, + "row": 15, "col": 43, "mobileOrder": 0 }, @@ -1365,15 +1398,15 @@ "widgets": { "ed33e56f-293b-aaaa-ad19-0cf499dc926f": { "sizeX": 120, - "sizeY": 45, + "sizeY": 44, "row": 0, "col": 0, "mobileHide": true }, "8c5207d8-7103-4dc2-db48-d61c0c8c61fd": { "sizeX": 120, - "sizeY": 17, - "row": 45, + "sizeY": 14, + "row": 44, "col": 0 } }, diff --git a/ui-ngx/src/assets/direct-connect-icon/coap.svg b/ui-ngx/src/assets/direct-connect-icon/coap.svg new file mode 100644 index 0000000000..9405b5b791 --- /dev/null +++ b/ui-ngx/src/assets/direct-connect-icon/coap.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui-ngx/src/assets/direct-connect-icon/http.svg b/ui-ngx/src/assets/direct-connect-icon/http.svg new file mode 100644 index 0000000000..9e97fe918c --- /dev/null +++ b/ui-ngx/src/assets/direct-connect-icon/http.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui-ngx/src/assets/direct-connect-icon/lwm2m.svg b/ui-ngx/src/assets/direct-connect-icon/lwm2m.svg new file mode 100644 index 0000000000..6762a50a7c --- /dev/null +++ b/ui-ngx/src/assets/direct-connect-icon/lwm2m.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/direct-connect-icon/mqtt.svg b/ui-ngx/src/assets/direct-connect-icon/mqtt.svg new file mode 100644 index 0000000000..50ef36f2f7 --- /dev/null +++ b/ui-ngx/src/assets/direct-connect-icon/mqtt.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui-ngx/src/assets/direct-connect-icon/snmp.svg b/ui-ngx/src/assets/direct-connect-icon/snmp.svg new file mode 100644 index 0000000000..d11c791135 --- /dev/null +++ b/ui-ngx/src/assets/direct-connect-icon/snmp.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/bacnet.svg b/ui-ngx/src/assets/gateway-connect-icon/bacnet.svg new file mode 100644 index 0000000000..a967a94c6c --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/bacnet.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/ble.svg b/ui-ngx/src/assets/gateway-connect-icon/ble.svg new file mode 100644 index 0000000000..a8fbd84952 --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/ble.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/can.svg b/ui-ngx/src/assets/gateway-connect-icon/can.svg new file mode 100644 index 0000000000..d01d4b2d3a --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/can.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/ftp.svg b/ui-ngx/src/assets/gateway-connect-icon/ftp.svg new file mode 100644 index 0000000000..41f46d4f1c --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/ftp.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/modbus.svg b/ui-ngx/src/assets/gateway-connect-icon/modbus.svg new file mode 100644 index 0000000000..e93eb319ba --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/modbus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/mqtt.svg b/ui-ngx/src/assets/gateway-connect-icon/mqtt.svg new file mode 100644 index 0000000000..50ef36f2f7 --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/mqtt.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/ocpp.svg b/ui-ngx/src/assets/gateway-connect-icon/ocpp.svg new file mode 100644 index 0000000000..218010ee50 --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/ocpp.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/odbc.svg b/ui-ngx/src/assets/gateway-connect-icon/odbc.svg new file mode 100644 index 0000000000..44dac5506c --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/odbc.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/opc-ua.svg b/ui-ngx/src/assets/gateway-connect-icon/opc-ua.svg new file mode 100644 index 0000000000..a1eb4bf55c --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/opc-ua.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/request.svg b/ui-ngx/src/assets/gateway-connect-icon/request.svg new file mode 100644 index 0000000000..de4a5616f1 --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/request.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/rest.svg b/ui-ngx/src/assets/gateway-connect-icon/rest.svg new file mode 100644 index 0000000000..919462645b --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/rest.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/snmp.svg b/ui-ngx/src/assets/gateway-connect-icon/snmp.svg new file mode 100644 index 0000000000..d11c791135 --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/snmp.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/socket.svg b/ui-ngx/src/assets/gateway-connect-icon/socket.svg new file mode 100644 index 0000000000..636b022f2a --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/socket.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/xmpp.svg b/ui-ngx/src/assets/gateway-connect-icon/xmpp.svg new file mode 100644 index 0000000000..236ae42abb --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/xmpp.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_angular_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_angular_fn.md new file mode 100644 index 0000000000..2f739b9770 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_angular_fn.md @@ -0,0 +1,135 @@ +#### Container function + +
+
+ +*function (ctx): void* + +A JavaScript function used to expose data and handlers to the Angular template by assigning them to this. Reference them in the template without the this prefix. + +**Parameters:** + +
    +
  • ctx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance.
    + Provides access to send RPC, switch dashboard states, create data subscriptions, read settings and access platform services. +
  • +
+ +**Returns:** + +This function does not return any value. Expose values and handlers to the template by assigning them to this. + +
+ +##### Notes + +
    +
  • There is no container parameter in Angular mode. Access the root element via ctx.$container[0] or event.currentTarget.closest(...) from a handler.
  • +
  • Define helper functions as arrow functions to preserve this.
  • +
  • Call ctx.detectChanges() after changing template-bound values so the template re-renders.
  • +
  • This widget does not bind widget datasources automatically. There is no ctx.data populated from the widget configuration. To read live data create an explicit subscription via ctx.subscriptionApi.createSubscription(...) and release it from ctx.registerDestroyCallback(...).
  • +
+ +
+ +##### Examples + +###### Resizable split master–detail with embedded dashboard state + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +###### Slide-over device detail with two-way RPC + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +###### Composite dashboard with tab navigation + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_plain_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_plain_fn.md new file mode 100644 index 0000000000..5874a6e657 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_plain_fn.md @@ -0,0 +1,124 @@ +#### Container function + +
+
+ +*function (ctx, container): void* + +A JavaScript function used to initialize libraries, build or update the DOM inside the widget content element, wire up event handlers and drive the widget through the widget context. + +**Parameters:** + +
    +
  • ctx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance.
    + Provides access to send RPC, switch dashboard states, create data subscriptions, read settings and access platform services. +
  • +
  • container: HTMLElement - The widget's content element, a native DOM node (<div class="tb-absolute-fill">) wrapping the rendered template.
    + Query it with container.querySelector(...). +
  • +
+ +**Returns:** + +This function does not return any value. + +
+ +##### Notes + +
    +
  • This widget does not bind widget datasources automatically. There is no ctx.data populated from the widget configuration. To read live data create an explicit subscription via ctx.subscriptionApi.createSubscription(...).
  • +
  • Release every subscription, timer and global event listener you create from ctx.registerDestroyCallback(...) so the widget cleans up correctly.
  • +
+ +
+ +##### Examples + +###### Kanban board with a CDN drag-and-drop library + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +###### Visitor analytics with a custom range and working hours + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_css.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_css.md new file mode 100644 index 0000000000..8bd68b8197 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_css.md @@ -0,0 +1,201 @@ +#### CSS of slide-over device detail + +```css +{:code-style="max-height: 400px;"} +.dd { + height: 100%; + font-family: 'Inter', 'Roboto', system-ui, sans-serif; + color: #0f172a; +} + +.dd__main { + padding: 20px; + box-sizing: border-box; +} + +.dd__title { + margin: 0 0 16px; + font-family: 'Roboto', sans-serif; + font-size: 20px; + font-weight: 500; +} + +.dd__card { + background: #fff; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.12); +} + +.dd__table { + width: 100%; +} + +.dd__table .mat-column-name { + flex: 1; +} + +.dd__table .mat-column-people { + flex: 0 0 120px; + justify-content: flex-end; +} + +.dd__table .mat-mdc-row { + cursor: pointer; +} + +.dd__table .mat-mdc-row:hover { + background: rgba(0, 0, 0, 0.04); +} + +.dd__table .mat-mdc-row.is-active { + background: rgba(47, 107, 255, 0.08); +} + +.dd__drawer { + width: 70%; + box-sizing: border-box; +} + +.dd__panel { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; +} + +.dd__panel-bar { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.dd__panel-eyebrow { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .08em; + color: #94a3b8; +} + +.dd__panel-title { + margin: 2px 0 0; + font-size: 22px; + font-weight: 800; +} + +.dd__close { + border: 0; + background: #f1f5f9; + border-radius: 10px; + width: 36px; + height: 36px; + font-size: 16px; + cursor: pointer; + color: #475569; +} + +.dd__close:hover { + background: #e2e8f0; +} + +.dd__cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 16px; + border-radius: 14px; + background: linear-gradient(180deg, #f8fafc, #eef2f7); + border: 1px solid #e6ebf2; +} + +.card__label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .08em; + color: #94a3b8; +} + +.card__value { + font-size: 18px; + font-weight: 800; +} + +.dd__actions { + display: flex; + gap: 10px; +} + +.dd__action { + display: inline-flex; + flex-direction: column; + gap: 6px; +} + +.dd__btn { + padding: 9px 16px; + border: 1px solid #d8dee9; + border-radius: 10px; + background: #fff; + color: #0f172a; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease; +} + +.dd__btn:hover { + background: #f8fafc; +} + +.dd__btn:disabled { + opacity: 0.6; + cursor: default; +} + +.dd__btn--warn { + color: #b91c1c; + border-color: #fecaca; +} + +.dd__btn--warn:hover { + background: #fef2f2; +} + +.dd__section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dd__section-title { + margin: 0; + font-size: 13px; + font-weight: 700; + color: #334155; +} + +.dd__state { + height: 280px; + border: 1px solid #eef1f6; + border-radius: 14px; + overflow: hidden; +} + +.dd__state tb-dashboard-state { + display: block; + width: 100%; + height: 100%; +} +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_html.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_html.md new file mode 100644 index 0000000000..eeabbf94ae --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_html.md @@ -0,0 +1,66 @@ +#### Angular template of slide-over device detail + +```html +{:code-style="max-height: 400px;"} + + +

Devices

+
+ + + Device + {{ d.name }} + + + People + {{ d.lastValue }} + + + + +
+
+ + +
+
+
+
Device
+

{{ selected.name }}

+
+ +
+ +
+
People{{ selected.lastValue }}
+
Entity type{{ selected.entityType }}
+
Last update{{ selected.lastSeen }}
+
+ +
+
+ + +
+
+ + +
+
+ +
+

Alarms

+
+
+
+

Charts

+
+
+
+
+
+{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_js.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_js.md new file mode 100644 index 0000000000..78c8ecae0b --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_js.md @@ -0,0 +1,100 @@ +#### JavaScript function of slide-over device detail + +```javascript +{:code-style="max-height: 400px;"} +this.ctx = ctx; +this.devices = []; +this.columns = ['name', 'people']; +this.selected = null; +this.selectedId = null; +this.drawerOpen = false; + +this.openDevice = (d) => { + this.selected = d; + this.selectedId = d.entityId; + this.drawerOpen = true; + ctx.stateController.updateState(null, { + entityId: { entityType: d.entityType, id: d.entityId }, + entityName: d.name, + }); + ctx.detectChanges(); +}; + +this.rebooting = false; +this.resetting = false; + +this.reboot = () => { + if (!this.selected || this.rebooting) return; + this.rebooting = true; + ctx.detectChanges(); + ctx.http.post('/api/rpc/twoway/' + this.selected.entityId, { method: 'reboot', params: {} }).subscribe({ + error: (e) => { + console.error(e); + this.rebooting = false; + ctx.detectChanges(); + }, + complete: () => { + this.rebooting = false; + ctx.detectChanges(); + }, + }); +}; + +this.resetCounter = () => { + if (!this.selected || this.resetting) return; + this.resetting = true; + ctx.detectChanges(); + ctx.http.post('/api/rpc/twoway/' + this.selected.entityId, { method: 'resetCounter', params: {} }).subscribe({ + error: (e) => { + console.error(e); + this.resetting = false; + ctx.detectChanges(); + }, + complete: () => { + this.resetting = false; + ctx.detectChanges(); + }, + }); +}; + +const subOpts = { + type: 'latest', + datasources: [ + { + type: 'entity', + entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true }, + dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }], + }, + ], + callbacks: { + onDataUpdated: (subscription) => { + this.devices = (subscription.data || []).map((entry) => { + const ds = entry.datasource; + const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null; + return { + entityId: ds.entityId, + entityType: ds.entityType, + name: ds.entityName, + lastValue: last ? last[1] : '—', + lastSeen: last ? new Date(last[0]).toLocaleString() : '—', + }; + }); + ctx.detectChanges(); + }, + onDataUpdateError: (subscription, e) => console.error(e), + }, +}; + +let subscriptionId = null; +ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => { + subscriptionId = subscription.id; +}); + +ctx.registerDestroyCallback(() => { + if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId); +}); +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_css.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_css.md new file mode 100644 index 0000000000..2297dfcf75 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_css.md @@ -0,0 +1,142 @@ +#### CSS of resizable split master–detail + +```css +{:code-style="max-height: 400px;"} +.split { + display: flex; + height: 100%; + box-sizing: border-box; + font-family: 'Inter', 'Roboto', system-ui, sans-serif; + color: #0f172a; + background: #fff; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(15, 23, 42, 0.06); + overflow: hidden; +} + +.split__list { + flex: 0 0 auto; + min-width: 160px; + overflow: auto; + border-right: 1px solid #eef1f6; +} + +.split__title { + margin: 0; + padding: 16px; + font-family: 'Roboto', sans-serif; + font-size: 20px; + font-weight: 500; +} + +.split__items { + list-style: none; + margin: 0; + padding: 0; +} + +.split__item { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + cursor: pointer; + border-bottom: 1px solid #f1f5f9; + transition: background 0.15s ease; +} + +.split__item:hover { + background: #f8fafc; +} + +.split__item.is-active { + background: rgba(47, 107, 255, 0.08); + box-shadow: inset 3px 0 0 #2f6bff; +} + +.split__value { + font-weight: 700; + color: #475569; +} + +.split__divider { + flex: 0 0 12px; + display: flex; + align-items: center; + justify-content: center; + cursor: col-resize; + background: #eef1f6; + transition: background 0.15s ease; +} + +.split__divider:hover { + background: #e2e8f0; +} + +.split__divider::before { + content: ''; + width: 4px; + height: 32px; + background-image: radial-gradient(circle, #94a3b8 1.2px, transparent 1.4px); + background-position: center; + background-size: 4px 6px; + background-repeat: repeat-y; +} + +.split--dragging { + user-select: none; +} + +.split--dragging .split__list, +.split--dragging .split__detail { + pointer-events: none; +} + +.split__detail { + flex: 1 1 0; + min-width: 220px; + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; + overflow: auto; +} + +.split__eyebrow { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #94a3b8; +} + +.split__detail-title { + margin: 2px 0 0; + font-size: 20px; + font-weight: 800; +} + +.split__state { + flex: 1 1 auto; + min-height: 260px; + border: 1px solid #eef1f6; + border-radius: 14px; + overflow: hidden; +} + +.split__state tb-dashboard-state { + display: block; + width: 100%; + height: 100%; +} + +.split__empty { + margin: auto; + color: #94a3b8; + font-size: 14px; +} +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_html.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_html.md new file mode 100644 index 0000000000..0d4394015d --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_html.md @@ -0,0 +1,38 @@ +#### Angular template of resizable split master–detail + +```html +{:code-style="max-height: 400px;"} +
+ + +
+ +
+ +
+
Device
+

{{ selected.name }}

+
+
+ +
+
+
Select a device
+
+
+{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_js.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_js.md new file mode 100644 index 0000000000..7c101ae20e --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_js.md @@ -0,0 +1,73 @@ +#### JavaScript function of resizable split master–detail + +```javascript +{:code-style="max-height: 400px;"} +this.ctx = ctx; +this.devices = []; +this.selected = null; +this.selectedId = null; +this.listWidth = 280; + +this.select = (d) => { + this.selected = d; + this.selectedId = d.entityId; + ctx.stateController.updateState(null, { + entityId: { entityType: d.entityType, id: d.entityId }, + entityName: d.name, + }); + ctx.detectChanges(); +}; + +this.startDrag = (event) => { + event.preventDefault(); + const root = event.currentTarget.closest('.split'); + const left = root.getBoundingClientRect().left; + root.classList.add('split--dragging'); + const onMove = (e) => { + this.listWidth = Math.max(160, Math.min(e.clientX - left, root.clientWidth - 220)); + ctx.detectChanges(); + }; + const onUp = () => { + root.classList.remove('split--dragging'); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); +}; + +const subOpts = { + type: 'latest', + datasources: [ + { + type: 'entity', + entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true }, + dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }], + }, + ], + callbacks: { + onDataUpdated: (subscription) => { + this.devices = (subscription.data || []).map((entry) => { + const ds = entry.datasource; + const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null; + return { entityId: ds.entityId, entityType: ds.entityType, name: ds.entityName, lastValue: last ? last[1] : '—' }; + }); + ctx.detectChanges(); + }, + onDataUpdateError: (subscription, e) => console.error(e), + }, +}; + +let subscriptionId = null; +ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => { + subscriptionId = subscription.id; +}); + +ctx.registerDestroyCallback(() => { + if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId); +}); +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_css.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_css.md new file mode 100644 index 0000000000..ffb7e4833f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_css.md @@ -0,0 +1,66 @@ +#### CSS of composite dashboard with tab navigation + +```css +{:code-style="max-height: 400px;"} +.composite { + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; + box-sizing: border-box; + padding: 12px; + font-family: 'Roboto', system-ui, sans-serif; +} + +.tabs { + display: inline-flex; + gap: 4px; + padding: 4px; + align-self: flex-start; + background: #eef1f6; + border-radius: 10px; +} + +.tab { + border: 0; + background: transparent; + padding: 8px 16px; + border-radius: 7px; + font-size: 13px; + font-weight: 600; + color: #5b6472; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.tab:hover { + color: #1f2733; +} + +.tab.is-active { + background: #fff; + color: #2f6bff; + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08); +} + +.container { + flex-grow: 1; + width: 100%; + display: flex; + padding: 0; + border: 1px solid #e6eaf0; + border-radius: 12px; + overflow: hidden; +} + +.state { + flex-grow: 1; + width: 100%; + height: 100%; + padding: 0; +} +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_html.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_html.md new file mode 100644 index 0000000000..7e1648c4ef --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_html.md @@ -0,0 +1,22 @@ +#### Angular template of composite dashboard with tab navigation + +```html +{:code-style="max-height: 400px;"} +
+ +
+ +
+
+{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_js.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_js.md new file mode 100644 index 0000000000..1eddefb304 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_js.md @@ -0,0 +1,35 @@ +#### JavaScript function of composite dashboard with tab navigation + +```javascript +{:code-style="max-height: 400px;"} +this.ctx = ctx; + +this.tabs = [ + { id: 'assets', label: 'Assets', stateId: 'assetsState' }, + { id: 'devices', label: 'Devices', stateId: 'devicesState' }, + { id: 'customers', label: 'Customers', stateId: 'customersState' }, +]; + +const apply = (subState) => { + const tab = this.tabs.find((t) => t.id === subState) || this.tabs[0]; + this.activeTab = tab.id; + this.stateId = tab.stateId; +}; + +apply(ctx.stateController.getStateParams().subState); + +this.selectTab = (subState) => ctx.stateController.updateState(null, { subState }); + +const stateSub = ctx.stateController.dashboardCtrl.dashboardCtx.stateChanged.subscribe(() => { + apply(ctx.stateController.getStateParams().subState); + ctx.detectChanges(); +}); + +ctx.registerDestroyCallback(() => { + stateSub.unsubscribe(); +}); +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_css.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_css.md new file mode 100644 index 0000000000..11fb741d53 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_css.md @@ -0,0 +1,38 @@ +#### CSS of visitor analytics + +```css +{:code-style="max-height: 400px;"} +.vc { + display: flex; + flex-direction: column; + gap: 16px; + height: 100%; + box-sizing: border-box; + padding: 20px; + background: #fff; + font-family: 'Inter', 'Roboto', system-ui, sans-serif; + color: #0f172a; +} +.vc__head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; } +.vc__title { margin: 0; font-family: 'Roboto', sans-serif; font-size: 20px; font-weight: 500; } +.vc__sub { font-size: 12px; font-weight: 600; color: #94a3b8; } +.vc__controls { display: flex; gap: 10px; flex-wrap: wrap; } +.vc__field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: #64748b; } +.vc__field input { padding: 7px 10px; border: 1px solid #d8dee9; border-radius: 10px; font-size: 13px; color: #0f172a; } +.vc__field input:focus { outline: none; border-color: #2f6bff; box-shadow: 0 0 0 3px rgba(47, 107, 255, 0.15); } +.vc__kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } +.vc-kpi { display: flex; flex-direction: column; gap: 6px; padding: 14px 16px; border-radius: 14px; background: #f8fafc; border: 1px solid #e6ebf2; } +.vc-kpi__label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #94a3b8; } +.vc-kpi__value { font-size: 26px; font-weight: 800; line-height: 1; color: #1e293b; } +.vc__card { flex: 1; min-height: 240px; display: flex; flex-direction: column; padding: 16px; border: 1px solid #e6ebf2; border-radius: 14px; } +.vc__card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.vc__card-title { font-size: 13px; font-weight: 700; color: #334155; } +.vc__back { padding: 6px 12px; border: 1px solid #d8dee9; border-radius: 8px; background: #fff; color: #2f6bff; font-size: 12px; font-weight: 600; cursor: pointer; } +.vc__back[hidden] { display: none; } +.vc__back:hover { background: #f0f5ff; border-color: #2f6bff; } +.vc__chart { flex: 1; min-height: 200px; } +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_html.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_html.md new file mode 100644 index 0000000000..90d664ed5c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_html.md @@ -0,0 +1,36 @@ +#### HTML code of visitor analytics + +```html +{:code-style="max-height: 400px;"} +
+
+
+

Visitor analytics

+ all peopleCount devices +
+
+ + + + +
+
+
+
Total visitors—
+
Busiest day—
+
Peak hour—
+
Daily average—
+
+
+
+ Visitors by day + +
+
+
+
+{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_js.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_js.md new file mode 100644 index 0000000000..1fc2b85b82 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_js.md @@ -0,0 +1,185 @@ +#### JavaScript function of visitor analytics + +```javascript +{:code-style="max-height: 400px;"} +// Plain HTML mode: `container` is the widget's DOM; ECharts comes from the CDN. +const echarts = window.echarts; +const el = (sel) => container.querySelector(sel); +const pad = (n) => String(n).padStart(2, '0'); +const isoDate = (d) => d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); +const dayLabel = (d) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +const minutes = (v) => { + const [h, m] = (v || '0:0').split(':').map(Number); + return h * 60 + m; +}; +const palette = ['#2563eb', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#0ea5e9']; + +const chart = echarts.init(el('#vcChart')); +chart.setOption({ + tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, + legend: { top: 0 }, + grid: { left: 8, right: 16, top: 40, bottom: 8, containLabel: true }, + xAxis: { type: 'category', data: [], axisLabel: { hideOverlap: true } }, + yAxis: { type: 'value' }, + series: [], +}); +const resizeObserver = new window.ResizeObserver(() => chart.resize()); +resizeObserver.observe(el('#vcChart')); + +let lastEntries = []; +let mode = 'daily'; +let selectedDate = null; +let dailyDates = []; // iso date per daily x-index, for drill-down + +// Click a day → drill into its hourly breakdown. +chart.on('click', (p) => { + if (mode !== 'daily' || p.dataIndex == null || dailyDates[p.dataIndex] == null) return; + selectedDate = dailyDates[p.dataIndex]; + mode = 'hourly'; + el('#vcBack').hidden = false; + recompute(); +}); +el('#vcBack').addEventListener('click', () => { + mode = 'daily'; + selectedDate = null; + el('#vcBack').hidden = true; + recompute(); +}); + +// One stacked series per device, summing values into the given bucket index. +function buildSeries(labelCount, bucketOf) { + return lastEntries.map((entry, k) => { + const arr = new Array(labelCount).fill(0); + (entry.data || []).forEach(([ts, value]) => { + const i = bucketOf(new Date(Number(ts))); + if (i != null && i >= 0) arr[i] += Number(value); + }); + return { name: entry.datasource.entityName, type: 'bar', stack: 'v', data: arr, itemStyle: { color: palette[k % palette.length] } }; + }); +} + +function recompute() { + const open = minutes(el('#vcOpen').value); + const close = minutes(el('#vcClose').value); + const inHours = (d) => { + const m = d.getHours() * 60 + d.getMinutes(); + return m >= open && m < close; + }; + + // KPIs over the whole range (per day, summed across devices). + const start = new Date(el('#vcStart').value + 'T00:00:00'); + const end = new Date(el('#vcEnd').value + 'T00:00:00'); + const dayIndex = {}; + const dayLabels = []; + dailyDates = []; + for (let t = new Date(start); t <= end; t.setDate(t.getDate() + 1)) { + dayIndex[isoDate(t)] = dayLabels.length; + dailyDates.push(isoDate(t)); + dayLabels.push(dayLabel(t)); + } + const dayTotals = dayLabels.map(() => 0); + const perHour = {}; + let total = 0; + lastEntries.forEach((entry) => + (entry.data || []).forEach(([ts, value]) => { + const d = new Date(Number(ts)); + if (!inHours(d)) return; + const i = dayIndex[isoDate(d)]; + if (i === undefined) return; + const v = Number(value); + dayTotals[i] += v; + perHour[d.getHours()] = (perHour[d.getHours()] || 0) + v; + total += v; + }), + ); + el('#vcTotal').textContent = total; + el('#vcPeakDay').textContent = total ? dayLabels[dayTotals.indexOf(Math.max(...dayTotals))] : '—'; + const ph = Object.keys(perHour).sort((a, b) => perHour[b] - perHour[a])[0]; + el('#vcPeakHour').textContent = ph != null ? ph + ':00' : '—'; + el('#vcAvg').textContent = Math.round(total / (dayTotals.filter((x) => x > 0).length || 1)); + + // Chart: daily (stacked by device) or the hourly breakdown of the selected day. + if (mode === 'daily') { + el('#vcChartTitle').textContent = 'Visitors by day'; + const series = buildSeries(dayLabels.length, (d) => (inHours(d) ? dayIndex[isoDate(d)] : null)); + chart.setOption({ xAxis: { data: dayLabels }, series }, { replaceMerge: ['series'] }); + } else { + const h0 = Math.floor(open / 60); + const h1 = Math.ceil(close / 60); + const labels = []; + for (let h = h0; h < h1; h++) labels.push(pad(h) + ':00'); + el('#vcChartTitle').textContent = 'Visitors by hour — ' + dayLabel(new Date(selectedDate + 'T00:00:00')); + const series = buildSeries(labels.length, (d) => + isoDate(d) === selectedDate && inHours(d) ? d.getHours() - h0 : null, + ); + chart.setOption({ xAxis: { data: labels }, series }, { replaceMerge: ['series'] }); + } +} + +// (Re)subscribe for the chosen date range (a fixed history window). +let subscriptionId = null; +function subscribe() { + if (subscriptionId != null) { + ctx.subscriptionApi.removeSubscription(subscriptionId); + subscriptionId = null; + } + const startTimeMs = new Date(el('#vcStart').value + 'T00:00:00').getTime(); + const endTimeMs = new Date(el('#vcEnd').value + 'T23:59:59').getTime(); + const subOpts = { + type: 'timeseries', + useDashboardTimewindow: false, + timeWindowConfig: { + selectedTab: 1, + history: { historyType: 1, fixedTimewindow: { startTimeMs, endTimeMs } }, + aggregation: { type: 'NONE', limit: 50000 }, + }, + datasources: [ + { + type: 'entity', + entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true }, + dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }], + }, + ], + callbacks: { + onDataUpdated: (subscription) => { + lastEntries = subscription.data || []; + recompute(); + }, + onDataUpdateError: (subscription, e) => console.error(e), + }, + }; + ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => { + subscriptionId = subscription.id; + }); +} + +// Changing the range resets the drill-down and re-subscribes; hours just re-filter. +function onRangeChange() { + mode = 'daily'; + selectedDate = null; + el('#vcBack').hidden = true; + subscribe(); +} + +// Defaults: the current month (1st → today). +const now = new Date(); +el('#vcEnd').value = isoDate(now); +el('#vcStart').value = isoDate(new Date(now.getFullYear(), now.getMonth(), 1)); + +el('#vcStart').addEventListener('change', onRangeChange); +el('#vcEnd').addEventListener('change', onRangeChange); +el('#vcOpen').addEventListener('change', recompute); +el('#vcClose').addEventListener('change', recompute); +subscribe(); + +// The JS re-runs on every reload — release the subscription, observer and chart. +ctx.registerDestroyCallback(() => { + if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId); + resizeObserver.disconnect(); + chart.dispose(); +}); +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_resources.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_resources.md new file mode 100644 index 0000000000..0b3ae21dd1 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_resources.md @@ -0,0 +1,12 @@ +#### Resources of visitor analytics + +Add the following JavaScript URL under the Resources tab. It loads ECharts before your function runs and exposes the echarts global (accessed as window.echarts): + +```text +{:code-style="max-height: 400px;"} +https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_css.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_css.md new file mode 100644 index 0000000000..6f9a857f19 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_css.md @@ -0,0 +1,45 @@ +#### CSS of kanban board + +```css +{:code-style="max-height: 400px;"} +.kanban { + display: flex; + gap: 12px; + height: 100%; + box-sizing: border-box; + padding: 16px; + overflow-x: auto; + background: #f9fafb; + font-family: 'Inter', 'Roboto', system-ui, sans-serif; + color: #111827; + font-size: 13px; +} +.kanban__col { flex: 1 1 0; min-width: 220px; display: flex; flex-direction: column; background: #f3f4f6; border-radius: 10px; } +.kanban__head { display: flex; align-items: center; gap: 8px; padding: 12px 14px 8px; } +.kanban__title { font-size: 12px; font-weight: 600; color: #374151; } +.kanban__count { margin-left: auto; min-width: 20px; height: 20px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 999px; background: #e5e7eb; color: #6b7280; font-size: 11px; font-weight: 600; } +.kanban__cards { display: flex; flex-direction: column; gap: 8px; padding: 4px 8px 12px; flex: 1; min-height: 24px; overflow-y: auto; } +.kanban__card { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + cursor: grab; + transition: border-color 0.12s ease, box-shadow 0.12s ease; +} +.kanban__card:hover { border-color: #d1d5db; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); } +.kanban__name { font-weight: 500; color: #111827; } +.kanban__dot { width: 8px; height: 8px; border-radius: 50%; flex: none; } +.kanban__dot--idle { background: #9ca3af; } +.kanban__dot--active { background: #22c55e; } +.kanban__dot--maintenance { background: #f59e0b; } +.kanban__card.sortable-ghost { opacity: 0; } +.kanban__card.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); } +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_html.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_html.md new file mode 100644 index 0000000000..d2ed3008b4 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_html.md @@ -0,0 +1,10 @@ +#### HTML code of kanban board + +```html +{:code-style="max-height: 400px;"} +
+{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_js.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_js.md new file mode 100644 index 0000000000..6b04e2551e --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_js.md @@ -0,0 +1,89 @@ +#### JavaScript function of kanban board + +```javascript +{:code-style="max-height: 400px;"} +// Plain HTML mode: `container` is the widget's DOM. Build a status board and, +// on drop, persist the card's new column (status) back to the device. +const Sortable = window.Sortable; // loaded from the CDN added under Resources +const statuses = ['idle', 'active', 'maintenance']; +const labels = { idle: 'Idle', active: 'Active', maintenance: 'Maintenance' }; + +const board = container.querySelector('#board'); +let devices = []; // { id, entityType, name, status } + +// Escape untrusted strings (e.g. device names) before putting them in innerHTML. +const esc = (s) => + String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + +function render() { + board.innerHTML = statuses + .map((s) => { + const items = devices.filter((d) => d.status === s); + const cards = items + .map( + (d) => + `
${esc(d.name)}
`, + ) + .join(''); + return `
+
${labels[s]}${items.length}
+
${cards}
+
`; + }) + .join(''); + + // Make each column a Sortable list; cards share one group so they move between columns. + board.querySelectorAll('.kanban__cards').forEach((listEl) => { + new Sortable(listEl, { + group: 'kanban', + animation: 150, + onAdd: (evt) => { + const id = evt.item.dataset.id; + const entityType = evt.item.dataset.type; + const status = evt.to.dataset.status; // destination column + const dev = devices.find((d) => d.id === id); + if (dev) dev.status = status; + ctx.attributeService + .saveEntityAttributes({ id, entityType }, 'SERVER_SCOPE', [{ key: 'status', value: status }]) + .subscribe(); + }, + }); + }); +} + +// Live data: devices of type "machine" with their "status" attribute. +const subOpts = { + type: 'latest', + datasources: [ + { + type: 'entity', + entityFilter: { type: 'deviceType', deviceTypes: ['machine'], deviceNameFilter: '', resolveMultiple: true }, + dataKeys: [{ type: 'attribute', name: 'status', settings: {} }], + }, + ], + callbacks: { + onDataUpdated: (subscription) => { + devices = (subscription.data || []).map((entry) => { + const ds = entry.datasource; + const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null; + const status = last ? String(last[1]) : 'idle'; + return { id: ds.entityId, entityType: ds.entityType, name: ds.entityName, status: statuses.includes(status) ? status : 'idle' }; + }); + render(); + }, + onDataUpdateError: (subscription, e) => console.error(e), + }, +}; +// The JS re-runs on every reload — capture the id and remove the subscription on destroy. +let subscriptionId = null; +ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => { + subscriptionId = subscription.id; +}); +ctx.registerDestroyCallback(() => { + if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId); +}); +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_resources.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_resources.md new file mode 100644 index 0000000000..a85de9604a --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_resources.md @@ -0,0 +1,12 @@ +#### Resources of kanban board + +Add the following JavaScript URL under the Resources tab. It loads SortableJS before your function runs and exposes the Sortable global (accessed as window.Sortable): + +```text +{:code-style="max-height: 400px;"} +https://cdn.jsdelivr.net/npm/sortablejs@1/Sortable.min.js +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/home/no-service.svg b/ui-ngx/src/assets/home/no-service.svg new file mode 100644 index 0000000000..2c591b268a --- /dev/null +++ b/ui-ngx/src/assets/home/no-service.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/apache-pulsar.svg b/ui-ngx/src/assets/integration-icon/apache-pulsar.svg new file mode 100644 index 0000000000..1ff16cb1ed --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/apache-pulsar.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/aws-iot.svg b/ui-ngx/src/assets/integration-icon/aws-iot.svg new file mode 100644 index 0000000000..ce93615737 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/aws-iot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui-ngx/src/assets/integration-icon/aws-kinesis.svg b/ui-ngx/src/assets/integration-icon/aws-kinesis.svg new file mode 100644 index 0000000000..fdf672f1d4 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/aws-kinesis.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/aws-sqs.svg b/ui-ngx/src/assets/integration-icon/aws-sqs.svg new file mode 100644 index 0000000000..2b9fdbf1b5 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/aws-sqs.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/azure-event-hub.svg b/ui-ngx/src/assets/integration-icon/azure-event-hub.svg new file mode 100644 index 0000000000..fec9fd51a4 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/azure-event-hub.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/azure-iot-hub.svg b/ui-ngx/src/assets/integration-icon/azure-iot-hub.svg new file mode 100644 index 0000000000..7f080283a8 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/azure-iot-hub.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/azure-service-bus.svg b/ui-ngx/src/assets/integration-icon/azure-service-bus.svg new file mode 100644 index 0000000000..bdc50a6978 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/azure-service-bus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/chirpstack.svg b/ui-ngx/src/assets/integration-icon/chirpstack.svg new file mode 100644 index 0000000000..d4cfc414cc --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/chirpstack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui-ngx/src/assets/integration-icon/coap.svg b/ui-ngx/src/assets/integration-icon/coap.svg new file mode 100644 index 0000000000..63d096c7d9 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/coap.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/custom.svg b/ui-ngx/src/assets/integration-icon/custom.svg new file mode 100644 index 0000000000..ba1077e068 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/custom.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/http.svg b/ui-ngx/src/assets/integration-icon/http.svg new file mode 100644 index 0000000000..d964e6b91c --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/http.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui-ngx/src/assets/integration-icon/iotcreators.com.svg b/ui-ngx/src/assets/integration-icon/iotcreators.com.svg new file mode 100644 index 0000000000..64224848b6 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/iotcreators.com.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/kafka.svg b/ui-ngx/src/assets/integration-icon/kafka.svg new file mode 100644 index 0000000000..2ff631845c --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/kafka.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/kpn.svg b/ui-ngx/src/assets/integration-icon/kpn.svg new file mode 100644 index 0000000000..edc4afe28d --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/kpn.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/loriot.svg b/ui-ngx/src/assets/integration-icon/loriot.svg new file mode 100644 index 0000000000..8c4f139439 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/loriot.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/mqtt.svg b/ui-ngx/src/assets/integration-icon/mqtt.svg new file mode 100644 index 0000000000..05ed77deac --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/mqtt.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/ocean-connect.svg b/ui-ngx/src/assets/integration-icon/ocean-connect.svg new file mode 100644 index 0000000000..97ff84f3fc --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/ocean-connect.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui-ngx/src/assets/integration-icon/opc-ua.svg b/ui-ngx/src/assets/integration-icon/opc-ua.svg new file mode 100644 index 0000000000..53968794c6 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/opc-ua.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui-ngx/src/assets/integration-icon/particle.svg b/ui-ngx/src/assets/integration-icon/particle.svg new file mode 100644 index 0000000000..cc823fa6f8 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/particle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-ngx/src/assets/integration-icon/pub-sub.svg b/ui-ngx/src/assets/integration-icon/pub-sub.svg new file mode 100644 index 0000000000..7ed9730d64 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/pub-sub.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/rabbitmq.svg b/ui-ngx/src/assets/integration-icon/rabbitmq.svg new file mode 100644 index 0000000000..9b2d81160f --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/rabbitmq.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/sigfox.svg b/ui-ngx/src/assets/integration-icon/sigfox.svg new file mode 100644 index 0000000000..6bd29d8c56 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/sigfox.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-ngx/src/assets/integration-icon/tcp.svg b/ui-ngx/src/assets/integration-icon/tcp.svg new file mode 100644 index 0000000000..5972d82f07 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/tcp.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/thingpark-enterprise.svg b/ui-ngx/src/assets/integration-icon/thingpark-enterprise.svg new file mode 100644 index 0000000000..677421c7a3 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/thingpark-enterprise.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/thingpark.svg b/ui-ngx/src/assets/integration-icon/thingpark.svg new file mode 100644 index 0000000000..2518cc273f --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/thingpark.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/things-stack-industries.svg b/ui-ngx/src/assets/integration-icon/things-stack-industries.svg new file mode 100644 index 0000000000..e85c828a24 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/things-stack-industries.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/things-stack-сommunity.svg b/ui-ngx/src/assets/integration-icon/things-stack-сommunity.svg new file mode 100644 index 0000000000..e85c828a24 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/things-stack-сommunity.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/ttn.svg b/ui-ngx/src/assets/integration-icon/ttn.svg new file mode 100644 index 0000000000..88a388db9d --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/ttn.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/tuya.svg b/ui-ngx/src/assets/integration-icon/tuya.svg new file mode 100644 index 0000000000..3498d0a1c0 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/tuya.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/udp.svg b/ui-ngx/src/assets/integration-icon/udp.svg new file mode 100644 index 0000000000..e8f81f83db --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/udp.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/iot-hub/category-alarm-rules-img.png b/ui-ngx/src/assets/iot-hub/category-alarm-rules-img.png new file mode 100644 index 0000000000..ab374fadef Binary files /dev/null and b/ui-ngx/src/assets/iot-hub/category-alarm-rules-img.png differ diff --git a/ui-ngx/src/assets/iot-hub/category-calculated-fields-img.png b/ui-ngx/src/assets/iot-hub/category-calculated-fields-img.png new file mode 100644 index 0000000000..acdcd322e8 Binary files /dev/null and b/ui-ngx/src/assets/iot-hub/category-calculated-fields-img.png differ diff --git a/ui-ngx/src/assets/iot-hub/category-device-library-img.png b/ui-ngx/src/assets/iot-hub/category-device-library-img.png new file mode 100644 index 0000000000..9e7817b70a Binary files /dev/null and b/ui-ngx/src/assets/iot-hub/category-device-library-img.png differ diff --git a/ui-ngx/src/assets/iot-hub/category-rule-chains-img.png b/ui-ngx/src/assets/iot-hub/category-rule-chains-img.png new file mode 100644 index 0000000000..bd136772b3 Binary files /dev/null and b/ui-ngx/src/assets/iot-hub/category-rule-chains-img.png differ diff --git a/ui-ngx/src/assets/iot-hub/category-solution-templates-img.png b/ui-ngx/src/assets/iot-hub/category-solution-templates-img.png new file mode 100644 index 0000000000..3c649f92ef Binary files /dev/null and b/ui-ngx/src/assets/iot-hub/category-solution-templates-img.png differ diff --git a/ui-ngx/src/assets/iot-hub/category-widgets-img.png b/ui-ngx/src/assets/iot-hub/category-widgets-img.png new file mode 100644 index 0000000000..0808b123cc Binary files /dev/null and b/ui-ngx/src/assets/iot-hub/category-widgets-img.png differ diff --git a/ui-ngx/src/assets/iot-hub/hero-alarm-rule-cluster.svg b/ui-ngx/src/assets/iot-hub/hero-alarm-rule-cluster.svg new file mode 100644 index 0000000000..545e7db89b --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-alarm-rule-cluster.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-calculated-field-cluster.svg b/ui-ngx/src/assets/iot-hub/hero-calculated-field-cluster.svg new file mode 100644 index 0000000000..a5206aa4ea --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-calculated-field-cluster.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-device-cluster.svg b/ui-ngx/src/assets/iot-hub/hero-device-cluster.svg new file mode 100644 index 0000000000..7e75c744a7 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-device-cluster.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-rule-chain-cluster.svg b/ui-ngx/src/assets/iot-hub/hero-rule-chain-cluster.svg new file mode 100644 index 0000000000..ee3a4d39de --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-rule-chain-cluster.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-solution-template-cluster.svg b/ui-ngx/src/assets/iot-hub/hero-solution-template-cluster.svg new file mode 100644 index 0000000000..2afd1f2ab8 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-solution-template-cluster.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-widget-cluster.svg b/ui-ngx/src/assets/iot-hub/hero-widget-cluster.svg new file mode 100644 index 0000000000..0df6ad4336 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-widget-cluster.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/items-page-alarm-rules-hero.svg b/ui-ngx/src/assets/iot-hub/items-page-alarm-rules-hero.svg new file mode 100644 index 0000000000..74ba7569b4 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/items-page-alarm-rules-hero.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/items-page-calculated-fields-hero.svg b/ui-ngx/src/assets/iot-hub/items-page-calculated-fields-hero.svg new file mode 100644 index 0000000000..916c521ea0 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/items-page-calculated-fields-hero.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/items-page-dashboards-hero.svg b/ui-ngx/src/assets/iot-hub/items-page-dashboards-hero.svg new file mode 100644 index 0000000000..c699714457 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/items-page-dashboards-hero.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/items-page-devices-hero.svg b/ui-ngx/src/assets/iot-hub/items-page-devices-hero.svg new file mode 100644 index 0000000000..d951dd8744 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/items-page-devices-hero.svg @@ -0,0 +1,243 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/items-page-rule-chains-hero.svg b/ui-ngx/src/assets/iot-hub/items-page-rule-chains-hero.svg new file mode 100644 index 0000000000..05cb14b0e2 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/items-page-rule-chains-hero.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/items-page-solution-templates-hero.png b/ui-ngx/src/assets/iot-hub/items-page-solution-templates-hero.png new file mode 100644 index 0000000000..690c23f552 Binary files /dev/null and b/ui-ngx/src/assets/iot-hub/items-page-solution-templates-hero.png differ diff --git a/ui-ngx/src/assets/iot-hub/items-page-widgets-hero.svg b/ui-ngx/src/assets/iot-hub/items-page-widgets-hero.svg new file mode 100644 index 0000000000..84c4b37a2f --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/items-page-widgets-hero.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/pe-only-illustration.svg b/ui-ngx/src/assets/iot-hub/pe-only-illustration.svg new file mode 100644 index 0000000000..c93239d290 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/pe-only-illustration.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/upgrade-required.svg b/ui-ngx/src/assets/iot-hub/upgrade-required.svg new file mode 100644 index 0000000000..1c0973a83a --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/upgrade-required.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/locale/locale.constant-da_DK.json b/ui-ngx/src/assets/locale/locale.constant-da_DK.json index 0992ccd6ff..0058dc7df6 100644 --- a/ui-ngx/src/assets/locale/locale.constant-da_DK.json +++ b/ui-ngx/src/assets/locale/locale.constant-da_DK.json @@ -1097,8 +1097,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -9538,4 +9538,4 @@ "language": { "language": "Sprog" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-de_DE.json b/ui-ngx/src/assets/locale/locale.constant-de_DE.json index d218d2b96b..03ec53a2ed 100644 --- a/ui-ngx/src/assets/locale/locale.constant-de_DE.json +++ b/ui-ngx/src/assets/locale/locale.constant-de_DE.json @@ -1097,8 +1097,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -9538,4 +9538,4 @@ "language": { "language": "Sprache" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-el_GR.json b/ui-ngx/src/assets/locale/locale.constant-el_GR.json index a12db1a589..f97ebf47f1 100644 --- a/ui-ngx/src/assets/locale/locale.constant-el_GR.json +++ b/ui-ngx/src/assets/locale/locale.constant-el_GR.json @@ -1097,8 +1097,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -9538,4 +9538,4 @@ "language": { "language": "Γλώσσα" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 039e7a33e7..acfe5c344a 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -82,7 +82,8 @@ "upload": "Upload", "delete-anyway": "Delete anyway", "delete-selected": "Delete selected", - "set": "Set" + "set": "Set", + "retry": "Retry" }, "aggregation": { "aggregation": "Aggregation", @@ -1107,8 +1108,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -3682,7 +3683,277 @@ } }, "item": { - "selected": "Selected" + "type-widget": "Widget", + "type-widget-plural": "Widgets", + "type-dashboard": "Dashboard", + "type-dashboard-plural": "Dashboards", + "type-solution-template": "Solution Template", + "type-solution-template-plural": "Solution Templates", + "type-calculated-field": "Calculated Field", + "type-calculated-field-plural": "Calculated Fields", + "type-alarm-rule": "Alarm Rule", + "type-alarm-rule-plural": "Alarm Rules", + "type-rule-chain": "Rule Chain", + "type-rule-chain-plural": "Rule Chains", + "type-device": "Device", + "type-device-plural": "Devices" + }, + "iot-hub": { + "iot-hub": "IoT Hub", + "generate-random-value": "Generate random value", + "home-title": "ThingsBoard IoT Hub", + "home-subtitle-prefix": "Discover ready-to-use", + "and-devices": "& Devices", + "search-placeholder": "Search in IoT Hub...", + "search": "Search...", + "search-published-items": "Search published items...", + "see-all-results": "See all results", + "no-search-results": "No results found for '{{ search }}'", + "no-items-available": "No items available", + "search-results": "Search results", + "search-results-for": "Search results for ", + "try-adjusting-search": "Try adjusting your search", + "add-widget-from-iot-hub": "Add widget from IoT Hub", + "all-widgets": "All widgets", + "all-iot-hub-widgets": "All IoT Hub widgets", + "installed-from-iot-hub": "Installed from IoT Hub", + "item-link-unavailable": "Item unavailable", + "include-deprecated": "Include deprecated", + "widget-category-value": "{{ category }} widgets", + "search-categories": "Search categories...", + "search-in-category": "Search widgets...", + "search-bundles": "Search bundles...", + "search-widgets": "Search widgets...", + "add-item-from-iot-hub": "Add {{ type }} from IoT Hub", + "add-from-iot-hub": "Add from IoT Hub", + "filters": "Filters", + "type": "Type", + "items-per-page": "Items per page", + "filter": "Filter", + "available": "Available", + "items-page-desc-widgets": "Jumpstart your IoT journey with pre-configured widgets designed for your industry. Deploy proven architectures instantly and focus your energy on what matters: your unique business logic.", + "items-page-desc-dashboards": "Explore dashboards to find pre-built visualization templates and interactive UI layouts you can deploy in minutes to bring your IoT data to life.", + "items-page-desc-solution-templates": "Complete IoT solution packages with dashboards, rule chains, and device configurations. Get started with proven architectures.", + "items-page-desc-calculated-fields": "Use pre-configured Calculated Fields to automate complex metrics like fuel efficiency or power factor. Skip the manual logic and keep your dashboards clean and actionable.", + "items-page-desc-alarm-rules": "Use pre-built Alarm Rule templates to detect critical conditions like low battery, threshold breaches, or devices going offline. Skip writing the rule logic and start reacting to incidents the moment they happen.", + "items-page-desc-rule-chains": "From sophisticated data processing to seamless API integrations, Rule Chain templates provide the architectural foundation you need to scale without building from scratch.", + "items-page-desc-devices": "Explore the device library to find pre-configured connectivity templates you can deploy in minutes to connect your hardware instantly.", + "device-library": "Device Library", + "most-popular-in-iot-hub": "Most popular in IoT Hub", + "alarm-rule-install-update-required": "Update required", + "alarm-rule-install-update-required-text": "Alarm Rules require ThingsBoard 4.3 or later. Please update your platform instance to install Alarm Rule packages.", + "alarm-rules-unavailable-title-prefix": "Alarm Rules are available starting from", + "alarm-rules-unavailable-tb-version": "Thingsboard v4.3", + "alarm-rules-unavailable-desc": "Use pre-built Alarm Rule templates to detect critical conditions like low battery, threshold breaches, or devices going offline. Skip writing the rule logic and start reacting to incidents the moment they happen.", + "alarm-rules-unavailable-upgrade": "Upgrade instance", + "alarm-rules-unavailable-current": "Current platform version: ThingsBoard v{{version}}", + "pe-required-title": "Professional Edition required", + "pe-required-message": "This item is available only on ThingsBoard Professional Edition. You are currently running Community Edition. To install it, upgrade to Professional Edition.", + "upgrade-required-title": "ThingsBoard upgrade required", + "upgrade-required-message": "This item requires ThingsBoard {{minVersion}} or newer. You are currently running {{currentVersion}}. Please upgrade your instance to install it.", + "become-creator-text": "Submit your templates to the ThingsBoard IoT Hub to get featured and showcase your solutions to our global community.", + "contribute": "Contribute", + "creator-profile": "Creator Profile", + "title-widgets": "Widgets", + "title-dashboards": "Dashboards", + "title-calculated-fields": "Calculated Fields", + "title-alarm-rules": "Alarm Rules", + "title-rule-chains": "Rule Chains", + "title-devices": "Devices", + "title-solution-templates": "Solution Templates", + "subtitle-widgets": "Browse community widgets for your dashboards", + "subtitle-dashboards": "Browse ready-to-use dashboards", + "subtitle-solution-templates": "Browse complete IoT solution packages", + "subtitle-calculated-fields": "Browse calculated field templates", + "subtitle-alarm-rules": "Browse alarm rule templates", + "subtitle-rule-chains": "Browse rule chain templates", + "subtitle-devices": "Browse device configurations", + "search-dashboards": "Search dashboards...", + "search-solution-templates": "Search solution templates...", + "search-calculated-fields": "Search calculated fields...", + "search-alarm-rules": "Search alarm rules...", + "search-rule-chains": "Search rule chains...", + "search-devices": "Search devices...", + "sort-most-installed": "Most installed", + "sort-newest": "Newest", + "sort-name": "Name (A-Z)", + "type-filter-label": "Type", + "category": "Category", + "use-case": "Use case", + "vendor": "Vendor", + "view-setup-guide": "View setup guide", + "hardware-type": "Hardware Type", + "connectivity": "Connectivity", + "clear-all-filters": "Clear all filters", + "clear-all": "Clear all", + "results-count": "{{count}} results", + "unable-to-load": "Unable to load", + "unable-to-load-text": "There was a problem loading items. Please try again.", + "network-server-unavailable": "Network or server unavailable", + "network-server-unavailable-text": "We couldn’t reach the catalog. Please try again in a moment.", + "try-again": "Try again", + "retry": "Retry", + "no-items-found": "No items found", + "no-items-found-text": "Try adjusting your search or filters.", + "no-widgets-found": "No widgets found", + "browse-tip-prefix": "Can't find what you need?", + "browse-tip-link": "Browse IoT Hub", + "browse-tip-suffix": "for more widgets.", + "creator": "Creator", + "manage": "Manage", + "details": "Details", + "become-a-creator": "Become a creator", + "back-to-iot-hub": "Back to IoT Hub", + "verified-creator": "Verified Creator", + "published-items": "Published items", + "by-creator": "by {{name}}", + "installs": "installs", + "more": "more", + "categories": "Categories", + "use-cases": "Use Cases", + "changelog": "Changelog", + "created-by": "Created by {{name}}", + "install-confirm": "Install {{name}} v{{version}}?", + "install-confirm-title": "Install '{{name}}'?", + "install-desc": "Installing this will automatically create and configure the necessary resources within your tenant", + "install-success-desc": "'{{name}}' has been installed successfully", + "remove": "Remove", + "remove-confirm-title": "Remove '{{name}}'?", + "remove-desc": "Removing this will also remove the installed entity and its associated resources within your tenant", + "remove-desc-solution-template": "Removing this solution template will also remove all created entities including dashboards, rule chains, devices, and other associated resources within your tenant", + "remove-anyway": "Remove anyway", + "select-entity-for-cf": "Select entity for calculated field '{{name}}'", + "rule-chain-install-subtitle": "Installing automatically creates the rule chain within your tenant. Optionally set it as a profile's default so entities of that profile route through it.", + "rule-chain-set-as-profile-default-toggle": "Set as profile default rule chain", + "rule-chain-set-as-profile-default-tooltip": "If enabled, all entities of the selected profile will route messages through this rule chain.", + "rule-chain-overwrite-title": "Replace existing default rule chain?", + "rule-chain-overwrite-body": "Profile {{profile}} currently uses {{existing}} as its default rule chain. Installing will replace it with this rule chain.", + "rule-chain-overwrite-replace": "Replace", + "install": "Install", + "connect": "Connect", + "connect-device": "Connect device", + "installing": "Installing...", + "installed": "Installed", + "install-success": "{{name}} installed successfully", + "install-success-title": "Installation Successful", + "install-success-message": "'{{name}}' v{{version}} has been installed successfully.", + "install-error": "Failed to install {{name}}", + "solution-install-title": "Solution template successfully installed", + "solution-instructions": "Solution instructions", + "goto-main-dashboard": "Go to main dashboard", + "install-error-title": "Installation Failed", + "install-error-message": "Failed to install '{{name}}'.", + "install-plan-title": "Install '{{name}}' and its dependencies", + "install-plan-will-install": "{{count}} item(s) will be installed.", + "install-plan-will-update": "{{count}} item(s) already installed and will be skipped.", + "install-plan-missing-warning": "{{count}} required item(s) could not be found on the marketplace. '{{ name }}' may not work fully without them.", + "install-plan-status-will": "Install", + "install-plan-status-installed": "Already installed", + "install-plan-status-missing": "Missing", + "install-plan-confirm": "Install", + "install-partial-title": "Installed with warnings", + "install-partial-desc": "'{{name}}' was installed, but some required items were missing.", + "install-rollback-partial": "Some of the items installed before the failure could not be removed automatically and may need to be deleted manually.", + "check-for-updates": "Check for updates", + "updates": "Updates", + "up-to-date": "Up to date", + "update": "Update", + "update-to-version": "Update to v {{ version }}", + "update-item-title": "Update Item", + "update-confirm": "Update '{{name}}' to version {{version}}?", + "update-confirm-title": "Update '{{name}}'?", + "update-desc": "Updating this will replace the current version with the latest and reconfigure the associated resources within your tenant", + "update-success-desc": "'{{name}}' has been updated successfully", + "updating": "Updating...", + "update-success-title": "Update Successful", + "update-success-message": "'{{name}}' has been updated to version {{version}}.", + "entity-modified-title": "Entity Modified", + "entity-modified-text": "The {{type}} has been modified locally. Do you still want to update it?", + "update-error-title": "Update Failed", + "update-error-message": "Failed to update '{{name}}'.", + "update-error": "Failed to update {{name}}", + "most-popular": "Most popular", + "all": "All", + "installed-items": "Installed Items", + "installed-items-for": "Installed Items for \"{{name}}\"", + "search-installed-items": "Search installed items...", + "item-name": "Name", + "item-type": "Type", + "item-version": "Version", + "installed-date": "Installed", + "no-installed-items": "No installed items found", + "no-installed-items-text": "Try adjusting your search or filters.", + "remove-installed-item-title": "Remove Installed Item", + "remove-installed-item-text": "Are you sure you want to remove '{{name}}'? The installed entity will also be removed.", + "installed-item-removed": "'{{name}}' has been removed", + "installed-item-remove-error": "Failed to remove '{{name}}'", + "open-installed-entity": "Open installed entity", + "view-item-details": "View item details", + "review-device-instructions": "Review device instructions", + "open-item-type": "Open {{type}}", + "back-to-marketplace": "Back to Marketplace", + "device-install-select-connectivity": "Choose a connection method for your device", + "pe-only": "PE Only", + "pe-connectivity-prompt-title-suffix": "available only in ThingsBoard Professional Edition", + "pe-connectivity-prompt-try-pe": "Try Professional Edition", + "connection-method": "Connection method", + "step-prerequisites": "Prerequisites", + "step-configuration": "Configuration", + "step-provisioning": "Provisioning", + "device-install-step-error": "Failed", + "device-install-step-pending": "Pending", + "device-install-step-running": "Creating...", + "device-install-step-success": "Created", + "device-install-step-type-DASHBOARD": "Dashboard", + "device-install-step-type-DEVICE": "Device", + "device-install-step-type-DEVICE_PROFILE": "Device Profile", + "device-install-step-type-GATEWAY": "Gateway", + "device-install-step-type-GATEWAY_CONNECTOR": "Gateway Connector", + "device-install-step-type-RULE_CHAIN": "Rule Chain", + "device-install-conflict-exists": "{{type}} with this name already exists", + "device-install-use-existing": "Use existing", + "device-install-overwrite": "Overwrite", + "device-install-create-copy": "Create copy", + "device-install-resolution-created": "Created", + "device-install-resolution-use-existing": "Used existing", + "device-install-resolution-overwrite": "Overwritten", + "device-install-resolution-create-copy": "Created copy", + "device-install-title": "Connect \"{{name}}\"", + "device-installing-title": "Installing \"{{name}}\"", + "done": "Done", + "in-progress": "In progress", + "item-detail": "IoT Hub item", + "unpublished-warning-title": "Unpublished content", + "unpublished-warning-text": "This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator.", + "unpublished-warning-confirm": "I understand the risk, continue", + "unpublished-preview": "Unpublished preview", + "deep-link-invalid-id": "Invalid IoT Hub item link.", + "deep-link-not-found": "This IoT Hub item doesn't exist or was removed.", + "deep-link-fetch-failed": "Couldn't load IoT Hub item. Please try again." + }, + "item-data": { + "widget-type-timeseries": "Timeseries", + "widget-type-latest": "Latest", + "widget-type-rpc": "Control", + "widget-type-alarm": "Alarm", + "widget-type-static": "Static", + "cf-type-simple": "Simple", + "cf-type-script": "Script", + "cf-type-geofencing": "Geofencing", + "cf-type-alarm": "Alarm", + "cf-type-propagation": "Propagation", + "cf-type-related-entities-aggregation": "Related Entities Aggregation", + "cf-type-entity-aggregation": "Entity Aggregation", + "rule-chain-type-core": "Core", + "rule-chain-type-edge": "Edge", + "node-type-enrichment": "Enrichment", + "node-type-filter": "Filter", + "node-type-transformation": "Transformation", + "node-type-action": "Action", + "node-type-analytics": "Analytics", + "node-type-external": "External", + "node-type-flow": "Flow", + "node-type-unknown": "Unknown" }, "js-func": { "no-return-error": "Function must return value!", @@ -6731,6 +7002,7 @@ "widget-bundle": "Widgets Bundle", "all-bundles": "All bundles", "select-widgets-bundle": "Select widgets bundle", + "installed": "Installed", "widgets": "Widgets", "all-widgets": "All widgets", "widget": "Widget", @@ -6761,6 +7033,11 @@ "rpc-short": "control", "alarm-short": "alarm", "static-short": "static", + "type-timeseries": "Timeseries", + "type-latest": "Latest", + "type-rpc": "Control", + "type-alarm": "Alarm", + "type-static": "Static", "select-widget-type": "Select widget type", "missing-widget-title-error": "Widget title must be specified!", "widget-saved": "Widget saved", @@ -8261,6 +8538,7 @@ "roadmap": "Roadmap", "satellite": "Satellite", "hybrid": "Hybrid", + "webgl-not-available": "The selected map layer requires graphics acceleration to display. Please enable 'Hardware acceleration' in your browser settings, or switch to a different layer.", "reference": { "reference-layer": "Reference layer", "no-layer": "No layer", @@ -9363,6 +9641,9 @@ "no-links-matching": "No links matching '{{name}}' were found.", "columns": "Columns" }, + "iot-hub": { + "title-text": "Build faster with community-proven resources from" + }, "recent-dashboards": { "title": "Dashboards", "last": "Last viewed", diff --git a/ui-ngx/src/assets/locale/locale.constant-es_ES.json b/ui-ngx/src/assets/locale/locale.constant-es_ES.json index cca6b2a6ff..0913972e47 100644 --- a/ui-ngx/src/assets/locale/locale.constant-es_ES.json +++ b/ui-ngx/src/assets/locale/locale.constant-es_ES.json @@ -1097,8 +1097,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -9538,4 +9538,4 @@ "language": { "language": "Idioma" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json index 6406930aab..8934bb139c 100644 --- a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json +++ b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json @@ -1097,8 +1097,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -9538,4 +9538,4 @@ "language": { "language": "Langue" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-it_IT.json b/ui-ngx/src/assets/locale/locale.constant-it_IT.json index ac47330f30..3117d40df7 100644 --- a/ui-ngx/src/assets/locale/locale.constant-it_IT.json +++ b/ui-ngx/src/assets/locale/locale.constant-it_IT.json @@ -1097,8 +1097,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -9538,4 +9538,4 @@ "language": { "language": "Lingua" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-ja_JP.json b/ui-ngx/src/assets/locale/locale.constant-ja_JP.json index b7b87fd579..526cabd1dd 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ja_JP.json +++ b/ui-ngx/src/assets/locale/locale.constant-ja_JP.json @@ -1097,8 +1097,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -9538,4 +9538,4 @@ "language": { "language": "言語" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-lt_LT.json b/ui-ngx/src/assets/locale/locale.constant-lt_LT.json index c3ddb5af74..4069fe951f 100644 --- a/ui-ngx/src/assets/locale/locale.constant-lt_LT.json +++ b/ui-ngx/src/assets/locale/locale.constant-lt_LT.json @@ -1106,8 +1106,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", diff --git a/ui-ngx/src/assets/locale/locale.constant-nl_NL.json b/ui-ngx/src/assets/locale/locale.constant-nl_NL.json index f9adb7f86c..3281909806 100644 --- a/ui-ngx/src/assets/locale/locale.constant-nl_NL.json +++ b/ui-ngx/src/assets/locale/locale.constant-nl_NL.json @@ -1097,8 +1097,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -9538,4 +9538,4 @@ "language": { "language": "Taal" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-no_NO.json b/ui-ngx/src/assets/locale/locale.constant-no_NO.json index c7d1a4d7f3..9a8ee78206 100644 --- a/ui-ngx/src/assets/locale/locale.constant-no_NO.json +++ b/ui-ngx/src/assets/locale/locale.constant-no_NO.json @@ -1097,8 +1097,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -9538,4 +9538,4 @@ "language": { "language": "Språk" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-pt_BR.json b/ui-ngx/src/assets/locale/locale.constant-pt_BR.json index f13ad03a3b..37e8bbae34 100644 --- a/ui-ngx/src/assets/locale/locale.constant-pt_BR.json +++ b/ui-ngx/src/assets/locale/locale.constant-pt_BR.json @@ -1097,8 +1097,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -9538,4 +9538,4 @@ "language": { "language": "Idioma" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json index 66bc099e84..dfc2d1be39 100644 --- a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json +++ b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json @@ -1097,8 +1097,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -9538,4 +9538,4 @@ "language": { "language": "Dil" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json index 654f305ae0..d71e1039b2 100644 --- a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json +++ b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json @@ -1097,8 +1097,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -9538,4 +9538,4 @@ "language": { "language": "Мова" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json index fb1c849057..7f11a953ad 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json @@ -1097,8 +1097,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -9538,4 +9538,4 @@ "language": { "language": "语言" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/widget/all-iot-hub-widgets.png b/ui-ngx/src/assets/widget/all-iot-hub-widgets.png new file mode 100644 index 0000000000..bf9eb1a16c Binary files /dev/null and b/ui-ngx/src/assets/widget/all-iot-hub-widgets.png differ diff --git a/ui-ngx/src/assets/widget/all-widgets.png b/ui-ngx/src/assets/widget/all-widgets.png new file mode 100644 index 0000000000..a5b9dca579 Binary files /dev/null and b/ui-ngx/src/assets/widget/all-widgets.png differ diff --git a/ui-ngx/src/assets/widget/installed-from-iot-hub.png b/ui-ngx/src/assets/widget/installed-from-iot-hub.png new file mode 100644 index 0000000000..79f5d73988 Binary files /dev/null and b/ui-ngx/src/assets/widget/installed-from-iot-hub.png differ diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index a694e04a65..ab178e29ae 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -720,6 +720,31 @@ } } + .tb-no-service-bg { + margin: 10px; + position: relative; + flex: 1; + width: 100%; + max-height: 100px; + &:before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: $tb-primary-color; + mask-image: url(/assets/home/no-service.svg); + -webkit-mask-image: url(/assets/home/no-service.svg); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-size: contain; + mask-position: center; + -webkit-mask-position: center; + } + } + .tb-no-data-text { font-weight: 500; font-size: 14px; diff --git a/ui-ngx/src/scss/constants.scss b/ui-ngx/src/scss/constants.scss index aca02be591..addc319a4d 100644 --- a/ui-ngx/src/scss/constants.scss +++ b/ui-ngx/src/scss/constants.scss @@ -25,7 +25,10 @@ $mat-lt-xl: "screen and (max-width: 1919px)"; $mat-gt-xs: "screen and (min-width: 600px)"; $mat-gt-sm: "screen and (min-width: 960px)"; $mat-gt-md: "screen and (min-width: 1280px)"; +$mat-lt-xmd: "screen and (max-width: 1599px)"; $mat-gt-xmd: "screen and (min-width: 1600px)"; +$mat-lt-xxl: "screen and (max-width: 2447px)"; +$mat-gt-xxl: "screen and (min-width: 2448px)"; $mat-gt-xl: "screen and (min-width: 1920px)"; $mat-md-lg: "screen and (min-width: 960px) and (max-width: 1819px)"; diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index 9058ce4960..00e051c497 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -1078,6 +1078,25 @@ pre.tb-highlight { } } + .tb-fullscreen-dialog-lt-md { + @media #{$mat-lt-md} { + min-height: 100%; + min-width: 100%; + max-width: none !important; + position: absolute !important; + inset: 0; + .mat-mdc-dialog-container { + > *:first-child, form { + min-width: 100% !important; + height: 100%; + } + .mat-mdc-dialog-content { + max-height: 100%; + } + } + } + } + .tb-fullscreen-dialog { @media #{$mat-lt-sm} { min-height: 100%; diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 6b5f48283b..e05a9e4a7c 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -160,24 +160,24 @@ "@angular-devkit/core" "^20.0.0" "@angular/build" "^20.0.0" -"@angular-devkit/architect@0.2003.24", "@angular-devkit/architect@>= 0.2000.0 < 0.2100.0", "@angular-devkit/architect@>=0.2000.0 < 0.2100.0": - version "0.2003.24" - resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.2003.24.tgz#1c59822ffeada4248f669424c7c0534f39fe8bc5" - integrity sha512-E7mCdkL6SWnW60G1nGLuugmsopza/eVIrDWB1y0vLkWN8gepOvnHz2Uf637kdzed/F1WoqR+dhv1SfsaJapzKA== +"@angular-devkit/architect@0.2003.25", "@angular-devkit/architect@>= 0.2000.0 < 0.2100.0", "@angular-devkit/architect@>=0.2000.0 < 0.2100.0": + version "0.2003.25" + resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.2003.25.tgz#5fef6c3b713c7db438464e7f352bc762eafedfa5" + integrity sha512-39pTqt4wSmpD1WeCee46oSGXRh6TR1PFd9GZEwyZoMvBTMs8mE2sXGxUgd4Qyi5CkQ1XnCM5XfgJcjUWUQRoGg== dependencies: - "@angular-devkit/core" "20.3.24" + "@angular-devkit/core" "20.3.25" rxjs "7.8.2" -"@angular-devkit/build-angular@20.3.24": - version "20.3.24" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-20.3.24.tgz#871b7c134d9c9f77b16cd68bca340801638e56a7" - integrity sha512-zBjETGqKtiojF8VHvjELW+UAMBRJ7ziqNc1xwUtqRuWZQVz8HH6x8m8Ncw4mSq9ecCFC4VB7OWTJ1VkAdYSBnQ== +"@angular-devkit/build-angular@20.3.25": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-20.3.25.tgz#1e3f973cd33a399ab6d05839cede6fe29b8e3219" + integrity sha512-jp2sbJhbVRT65RbGENY/lz3Z0W0D50Af3xzENmLDLBVp9uhlpIPSTC9LG4CEPV7T6H5Oq6ymHKM+OO5WwTlHHA== dependencies: "@ampproject/remapping" "2.3.0" - "@angular-devkit/architect" "0.2003.24" - "@angular-devkit/build-webpack" "0.2003.24" - "@angular-devkit/core" "20.3.24" - "@angular/build" "20.3.24" + "@angular-devkit/architect" "0.2003.25" + "@angular-devkit/build-webpack" "0.2003.25" + "@angular-devkit/core" "20.3.25" + "@angular/build" "20.3.25" "@babel/core" "7.28.3" "@babel/generator" "7.28.3" "@babel/helper-annotate-as-pure" "7.27.3" @@ -188,7 +188,7 @@ "@babel/preset-env" "7.28.3" "@babel/runtime" "7.28.3" "@discoveryjs/json-ext" "0.6.3" - "@ngtools/webpack" "20.3.24" + "@ngtools/webpack" "20.3.25" ansi-colors "4.1.3" autoprefixer "10.4.21" babel-loader "10.0.0" @@ -210,7 +210,7 @@ ora "8.2.0" picomatch "4.0.4" piscina "5.1.3" - postcss "8.5.6" + postcss "8.5.12" postcss-loader "8.1.1" resolve-url-loader "5.0.0" rxjs "7.8.2" @@ -230,18 +230,18 @@ optionalDependencies: esbuild "0.28.0" -"@angular-devkit/build-webpack@0.2003.24": - version "0.2003.24" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.2003.24.tgz#9d96b1d2c05faf3bcb696059c736d9498230ba36" - integrity sha512-gfadsLK3SxbGpZQMSJGr8RK65R2mZi/VmHXdztXQHATvMpy+00CX1Nu3n+lPTxOVbC73aIczUkUbIuRC1HBCqg== +"@angular-devkit/build-webpack@0.2003.25": + version "0.2003.25" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.2003.25.tgz#ec1e1fbc690edb319a80f31acdf8673199352d43" + integrity sha512-jJMpYBdWeRfvrCna7JWsyMBbvjMcPblyzh4/pSfWw5znla3hJGzDtmb84qKZ+IB8eZNEcky4iGR5LK2jdGFLAg== dependencies: - "@angular-devkit/architect" "0.2003.24" + "@angular-devkit/architect" "0.2003.25" rxjs "7.8.2" -"@angular-devkit/core@20.3.24", "@angular-devkit/core@>= 20.0.0 < 21.0.0", "@angular-devkit/core@^20.0.0": - version "20.3.24" - resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-20.3.24.tgz#e4108ad599d507d934b9ca990d98a4bb23bd0471" - integrity sha512-kmOjXJcbFxUI91nds9n6XZ6Y/DyQ7/TqRXbHHqvkz9RtlIpdbgWHlIZIq6mgsPOgPBzkxFjtncVARYZUI3yxaw== +"@angular-devkit/core@20.3.25", "@angular-devkit/core@>= 20.0.0 < 21.0.0", "@angular-devkit/core@^20.0.0": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-20.3.25.tgz#779b2eba00f688f0b9105e1562d9c14738cfabfa" + integrity sha512-pSfeWEoS1y9zxqTOw0Etj2NXkRmp/aU52wdnyq3KqVA9cJtgxtlOHlBBNswTil4dReojEy+0K1xJm+mLuWuLxA== dependencies: ajv "8.18.0" ajv-formats "3.0.1" @@ -250,12 +250,12 @@ rxjs "7.8.2" source-map "0.7.6" -"@angular-devkit/schematics@20.3.24", "@angular-devkit/schematics@>= 20.0.0 < 21.0.0": - version "20.3.24" - resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-20.3.24.tgz#4631ec0755363173db0e89eadc0d756985e0bae1" - integrity sha512-B5fBPi0xnEDI0wLLkCjsrYjazRPyf+rnHLAHi34thMdeY9dqljJGWYdNuyUUBak6HNPBLdEo1EUSNcOF9OWt4A== +"@angular-devkit/schematics@20.3.25", "@angular-devkit/schematics@>= 20.0.0 < 21.0.0": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-20.3.25.tgz#446618dbeb9b9756784e46b01190b71d10b6ffee" + integrity sha512-IB0IHf8ZRqr69hT/XIfHkYLPAYHWQ/WUc6+fKHBwq58jJlm/y5QUeTEuXvJ18IX/+hUIqw+E2Q3T0N9rLDLzXg== dependencies: - "@angular-devkit/core" "20.3.24" + "@angular-devkit/core" "20.3.25" jsonc-parser "3.3.1" magic-string "0.30.17" ora "8.2.0" @@ -321,20 +321,20 @@ dependencies: "@angular-eslint/bundled-angular-compiler" "20.7.0" -"@angular/animations@20.3.19": - version "20.3.19" - resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-20.3.19.tgz#636cf19528c548427f3f2ce6c4dd3b863b1475c4" - integrity sha512-/FjU9i7J58/yBURhgVSIiLDcuyOfJxAa0b7ZrOsx6P+FES+M2T2BKZl5V2NuiP2fDFtjsV7U+M/Z9UNUmeHCEw== +"@angular/animations@20.3.25": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-20.3.25.tgz#f672d78b9282a93f23b91739093dc1b40663e1ef" + integrity sha512-lQmti3tI85D525TjUVGqCNLzFxSUoZg+vgIyvuGJZPY0UU/o2S6KAxW6ObmcRotZZHNfenLHIxWgzamBDjIjuw== dependencies: tslib "^2.3.0" -"@angular/build@20.3.24", "@angular/build@^20.0.0": - version "20.3.24" - resolved "https://registry.yarnpkg.com/@angular/build/-/build-20.3.24.tgz#79e423a077a4177074b52e3a20c76f082fc08824" - integrity sha512-AMGXOr268y+kVutl4LpOXY2xv9P+RXLCyXUkzYwi8XwGyxAJZfyu/L5qtcO2llExp5CuvP0OxkWxk4JOGRi9TA== +"@angular/build@20.3.25", "@angular/build@^20.0.0": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@angular/build/-/build-20.3.25.tgz#cb21941c33b00758a2496d7ffc1e4d5b869d0867" + integrity sha512-ddWmPzYuzDWz9ql0262u9w3OJHXpSjHVNIDFIYOG2XYyMj/Xve0RkJ8/xvV+Hv7T3iqyyGKtsO+n/d0GCJQCtQ== dependencies: "@ampproject/remapping" "2.3.0" - "@angular-devkit/architect" "0.2003.24" + "@angular-devkit/architect" "0.2003.25" "@babel/core" "7.28.3" "@babel/helper-annotate-as-pure" "7.27.3" "@babel/helper-split-export-declaration" "7.24.7" @@ -370,18 +370,18 @@ parse5 "^8.0.0" tslib "^2.3.0" -"@angular/cli@20.3.24": - version "20.3.24" - resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-20.3.24.tgz#e6ab456b83eede47ccc3503dd6068bdf04be4e05" - integrity sha512-TT7LldRJPCi1VGBJMzcrYU+P3w2G6zgubwhFUdJthUiS77A+At6WJqaRY2BdIS3l7HZOXTEaU2Vj2Gkf2ol2Yw== +"@angular/cli@20.3.25": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-20.3.25.tgz#f16e78a689dfc546b55e0fc7da68545d1b307944" + integrity sha512-QOSxza45CZY11kqPVpqsU+WGYF99rR/r9A9GykZQWuAHb5SEAxlXHyaaGMu/BMtBHy5HNIlJH25UlzelrbaNyQ== dependencies: - "@angular-devkit/architect" "0.2003.24" - "@angular-devkit/core" "20.3.24" - "@angular-devkit/schematics" "20.3.24" + "@angular-devkit/architect" "0.2003.25" + "@angular-devkit/core" "20.3.25" + "@angular-devkit/schematics" "20.3.25" "@inquirer/prompts" "7.8.2" "@listr2/prompt-adapter-inquirer" "3.0.1" "@modelcontextprotocol/sdk" "1.26.0" - "@schematics/angular" "20.3.24" + "@schematics/angular" "20.3.25" "@yarnpkg/lockfile" "1.1.0" algoliasearch "5.35.0" ini "5.0.0" @@ -394,17 +394,17 @@ yargs "18.0.0" zod "4.1.13" -"@angular/common@20.3.19": - version "20.3.19" - resolved "https://registry.yarnpkg.com/@angular/common/-/common-20.3.19.tgz#fb19c335a5a5ea84cd6c90a4abda99c2de5c8b17" - integrity sha512-hcB1eUEN8LGcKGc4DlRJ+abS6AYfbEHDZKg8LnXNugkbwI6Ebyh2AUYTDhzZL2S4aH+C8biHKgSYHFCqieCRhA== +"@angular/common@20.3.25": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@angular/common/-/common-20.3.25.tgz#0f2bd058ec6595cfb4dd6a05601c41cb5cf3cb23" + integrity sha512-rnRGcXbjet0DHgkRL4Dqxk21G2T4UypVfiTV/fay58H8w9U89PJ1L6gRmk8B/uyfpii/9r23cBwnpcguQykxYw== dependencies: tslib "^2.3.0" -"@angular/compiler-cli@20.3.19": - version "20.3.19" - resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-20.3.19.tgz#baeb020e693a759b0b8190931611309678b1e011" - integrity sha512-ET/JjO8s62kAHfgIsGXlvW5VUwLqHm03q1y/2yD7aQW/WdDvssMsvZv7Knl440989vdOFemIGTMwVPakmWqRmA== +"@angular/compiler-cli@20.3.25": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-20.3.25.tgz#64c7cdcd4118cd24cb1905804754ccf3d90a7df4" + integrity sha512-iqxwVo5Pgzt3EfT49OZ6plxA6KKxwv7ixx1XNH7QRvaOJC9gmsPScWpx+LO7ZsVZdo/NkA+rnXDl0PauUgGciw== dependencies: "@babel/core" "7.28.3" "@jridgewell/sourcemap-codec" "^1.4.14" @@ -415,31 +415,31 @@ tslib "^2.3.0" yargs "^18.0.0" -"@angular/compiler@20.3.19": - version "20.3.19" - resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-20.3.19.tgz#cce87072c55c1bab3fda92d83048d0136cea83ad" - integrity sha512-ETkgDKm0l2PuaBubgPJe0ccy8kE75DFu6/zKcz7TUuk3KrKF2OZAopbbjftsUSZGeCNvCdqHzjmcL6hQ6oAOwA== +"@angular/compiler@20.3.25": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-20.3.25.tgz#674d07b2437e9a3c99e9bb1c10e4d13bbedb4086" + integrity sha512-TSh6gVoQqlLPqWwsYMK0lfVEQYENQO+USzS+BHFXEHFfgBRap6qDpIUGnRdj0Y2PlaVJUVFbeq1855EZUPUEoA== dependencies: tslib "^2.3.0" -"@angular/core@20.3.19": - version "20.3.19" - resolved "https://registry.yarnpkg.com/@angular/core/-/core-20.3.19.tgz#ce8b2a0df48612b3d9bfefe8098a1018a3186406" - integrity sha512-SYnwW+q51bQoPtGFoGovm1P5GK9fMEXsG0lGaEAUapjskblAYyX7hLlM/jgueSojv2SjhqNF8aXR+gjHLhZVNA== +"@angular/core@20.3.25": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@angular/core/-/core-20.3.25.tgz#4418a9625a8a152029b49158dbda01f1c83d19c0" + integrity sha512-B4XnnR5jzikZDvZ4PjwjAWZMT14dxrKrmJdwa/n0yp7rMPkIJTKF6ZJMg4d1pLWLLSsc2oWHioN3UrWlGqIKnA== dependencies: tslib "^2.3.0" -"@angular/forms@20.3.19": - version "20.3.19" - resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-20.3.19.tgz#179fa22f6e2daf3fe58c400d3e3a7c10647f0ebc" - integrity sha512-WJotd+Lhl4FG2b0K+aQNyQDHhR515zKCuphjiUqEW7sifWrOQxANLKzPBngGrH75ayANFgPaDf7U3ZRIoblcQA== +"@angular/forms@20.3.25": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-20.3.25.tgz#10a16a9ebe8e71c2a236b1bf69c3fe4bd44923c5" + integrity sha512-vGRo1LVPFo2Cu0k+QyDTlsBv5UbN0c3Et2YMS+43oyi1c4keocntBccOjLyM5C0kpMz4+pP81MqqYpAWu2k+TQ== dependencies: tslib "^2.3.0" -"@angular/language-service@20.3.19": - version "20.3.19" - resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-20.3.19.tgz#ba9e04a35717d948ff972476486eae2ea25a109d" - integrity sha512-9J0XrAKXInz11KKyNMrMZmn2NSjVbxzt/DsAumbrzzixeZwiY7vDy2Kqw/LLFLi7IlfMQ/gznz/mCVVgUWI5Gg== +"@angular/language-service@20.3.25": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-20.3.25.tgz#a319dec7d6369337908862461b45be96c6851a06" + integrity sha512-3PZUwbDUVQXk9BLSiNUpDxTnRXGR3szwK9QFQaGDt8lhmLYaQLh3VJsY0FHDRghHZYIGR2P2MbVxGQHytF+IPw== "@angular/material@20.2.14": version "20.2.14" @@ -448,24 +448,24 @@ dependencies: tslib "^2.3.0" -"@angular/platform-browser-dynamic@20.3.19": - version "20.3.19" - resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.19.tgz#b13cb94dc39be24e22d70712605ca879aa5e83b2" - integrity sha512-OgErw7wjcC+8yKF5h99hJq8x+tvc091wThfmdL5YC+U3HgRmUaNZFgB/jR7cb/NeeeC42QW5Vc0qoUTC9rMnLQ== +"@angular/platform-browser-dynamic@20.3.25": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.25.tgz#5051bea6135c0e9585956bc2ec0df4d9dabac182" + integrity sha512-3Ku+IsN4tQPVBsw75SoLbLf7TsXAGL0rGPHSsyNYFhG2ZZeQuYNIAi8mc4cwz/qMDnuassHFrCxuLDgN6Yab5w== dependencies: tslib "^2.3.0" -"@angular/platform-browser@20.3.19": - version "20.3.19" - resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-20.3.19.tgz#4bffa36591d9ba5adc89a1b48952fdfd982d2f30" - integrity sha512-TRZfatH1B/kreDwFRwtpLEurJQ6044qh6DWpvxzTbugaG5otLQJKTk+1z81/KsJwQqc1+24v+yuywc1LM7aq7w== +"@angular/platform-browser@20.3.25": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-20.3.25.tgz#f738f1895415c76932b5064d04b4493d17159cdf" + integrity sha512-0k06U/AJRQifGMLkcU3R9uEHWbuKEzkKMuKcGagXTrkeFvCG2Ub4JdsbcjFNWB2bspWgaxIMSceuj7c83U5wOA== dependencies: tslib "^2.3.0" -"@angular/router@20.3.19": - version "20.3.19" - resolved "https://registry.yarnpkg.com/@angular/router/-/router-20.3.19.tgz#2346ff945a8039194ba921a473a8f4565a92b949" - integrity sha512-qHrMniHOsCJ4neZmcQVodjutJilyXAXk7EhLa931QyL0qyVKVomv6E0I3UFzRaC3ZeHc+hzBdU6C6bvMFKTl1g== +"@angular/router@20.3.25": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@angular/router/-/router-20.3.25.tgz#0b79c6c0a0f4212002713c03bbb2709dbd79c546" + integrity sha512-YIjLHWAufTaukNj15hEoys29e7XNhnCRsS1/95h/OqR69R3adbB8hV7ut7gO6XdXokriYqb4gtoUjoESxR+xFQ== dependencies: tslib "^2.3.0" @@ -1382,135 +1382,135 @@ resolved "https://registry.yarnpkg.com/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz#fe541a68aa080255f798c8561714ac8fad72cdd5" integrity sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g== -"@esbuild/aix-ppc64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz#7a289c158e29cbf59ea0afc83cc80f06d1c89402" - integrity sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA== - -"@esbuild/android-arm64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz#b8828d9edfa3a92660644eb8de6e4f3c203d7b17" - integrity sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw== - -"@esbuild/android-arm@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.0.tgz#5ec1847605e05b5dbe5df90db9ff7e3e4c58dca7" - integrity sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ== - -"@esbuild/android-x64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.0.tgz#390642175b88ef82bad4cce03f8ab13fe9b1912e" - integrity sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA== - -"@esbuild/darwin-arm64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz#ae45325960d5950cd6951e4f97396f4e1ff7d8d3" - integrity sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q== - -"@esbuild/darwin-x64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz#c079247d589b6b99449659d94f06951b84bff2e4" - integrity sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ== - -"@esbuild/freebsd-arm64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz#45c456215a486593c94900297202dc11c880a37a" - integrity sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q== - -"@esbuild/freebsd-x64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz#0399494c1c85e4388e9b7040bd60d48f2a5b0d2c" - integrity sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw== - -"@esbuild/linux-arm64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz#d6d9f09ef0de54116bf459a4d53cac7e0952fe39" - integrity sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A== - -"@esbuild/linux-arm@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz#7b42ffa84c288ae94fdc431c1b28a89e3c3b9278" - integrity sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw== - -"@esbuild/linux-ia32@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz#deb15d112ed8dd605346b6b953d23a21ff81253f" - integrity sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ== - -"@esbuild/linux-loong64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz#81fb89d07eecc79b157dea61033757726fce0ca4" - integrity sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg== - -"@esbuild/linux-mips64el@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz#d0e42691b3ff7af9fb2217b70fc01f343bdb62bb" - integrity sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w== - -"@esbuild/linux-ppc64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz#389f3e5e98f17d477c467cc87136e1a076eead87" - integrity sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg== - -"@esbuild/linux-riscv64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz#763bd60d59b242be12da1e67d5729f3024c605fa" - integrity sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ== - -"@esbuild/linux-s390x@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz#aac6061634872e4677de693bce8030d73b1fd055" - integrity sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q== - -"@esbuild/linux-x64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz#4f2917747188fe77632bcec65b2d84b422419779" - integrity sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ== - -"@esbuild/netbsd-arm64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz#814df0ae57a0c386814491b8397eeba82094a947" - integrity sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw== - -"@esbuild/netbsd-x64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz#e01bdf7e60fa1a08e46d46d960b0d9bb8ac210af" - integrity sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw== - -"@esbuild/openbsd-arm64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz#4a15c36aacca68d2d5a4c90b710c06759f4c1ffa" - integrity sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g== - -"@esbuild/openbsd-x64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz#475e6101498a8ecce3008d7c388111d7a27c17bd" - integrity sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA== - -"@esbuild/openharmony-arm64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz#cfdc3957f0b7a69f1bde129aad17fcc2f6fa033e" - integrity sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w== - -"@esbuild/sunos-x64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz#a013c856fecacd1c3aec985c8afe1d1cb017497d" - integrity sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw== - -"@esbuild/win32-arm64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz#eae05e0f35271cad3898b43168d3e9a3bbaf47e5" - integrity sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA== - -"@esbuild/win32-ia32@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz#06161ebc5bf75c08d69feb3c6b22560515913998" - integrity sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA== - -"@esbuild/win32-x64@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz#04d90d5752b4ce65d2b6ac25eba08ff7624fe07c" - integrity sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw== +"@esbuild/aix-ppc64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" + integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ== + +"@esbuild/android-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a" + integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg== + +"@esbuild/android-arm@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" + integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ== + +"@esbuild/android-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" + integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng== + +"@esbuild/darwin-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" + integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q== + +"@esbuild/darwin-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" + integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ== + +"@esbuild/freebsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" + integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw== + +"@esbuild/freebsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" + integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ== + +"@esbuild/linux-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" + integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g== + +"@esbuild/linux-arm@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" + integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ== + +"@esbuild/linux-ia32@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3" + integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w== + +"@esbuild/linux-loong64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" + integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg== + +"@esbuild/linux-mips64el@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" + integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ== + +"@esbuild/linux-ppc64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" + integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ== + +"@esbuild/linux-riscv64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" + integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ== + +"@esbuild/linux-s390x@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" + integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag== + +"@esbuild/linux-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd" + integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA== + +"@esbuild/netbsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" + integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw== + +"@esbuild/netbsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347" + integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg== + +"@esbuild/openbsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" + integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q== + +"@esbuild/openbsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c" + integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw== + +"@esbuild/openharmony-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" + integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg== + +"@esbuild/sunos-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" + integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ== + +"@esbuild/win32-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" + integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA== + +"@esbuild/win32-ia32@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" + integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg== + +"@esbuild/win32-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" + integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" @@ -2386,10 +2386,10 @@ dependencies: tslib "^2.0.0" -"@ngtools/webpack@20.3.24": - version "20.3.24" - resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-20.3.24.tgz#8e344ebaea46d7e669220f02a33bb61826bb35f6" - integrity sha512-PIP9hFVF6OOmDxG0s6vX7cHm/6wwWK8jXd6e1I/CewR0zpVPtR1vxhhw9CrY4VEUCFSL6x2NuW/U3cI7LU+Z1Q== +"@ngtools/webpack@20.3.25": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-20.3.25.tgz#18e676da66c10b6810aac8b5aee9a10432e03e9a" + integrity sha512-p/YopAgukaIvezv3hsJzJevnNUBNTM8UQIkyDHPY5/aANdKraUTKDQUwlVxRiyD8U+PTSeD/BI56oOnuKrdFZQ== "@ngx-translate/core@^17.0.0": version "17.0.0" @@ -2740,13 +2740,13 @@ resolved "https://registry.yarnpkg.com/@sanity/diff-match-patch/-/diff-match-patch-3.2.0.tgz#7ce587273f7372a146308cb1075ba26177d42cdb" integrity sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww== -"@schematics/angular@20.3.24": - version "20.3.24" - resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-20.3.24.tgz#1cef967a08a55336b296dfd0e2c5207dc1c43226" - integrity sha512-GNB8zI8Lz0rJl4Q7FH4Y8ZmRpODkNDKGxWObfZ39POgiyr3CtT5sMRTQq1lWRWTlZeV8uD51DvW/EsAsbaS4HA== +"@schematics/angular@20.3.25": + version "20.3.25" + resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-20.3.25.tgz#bf22b66390eb8fb8cf125f07ba3718077642c721" + integrity sha512-ezoJpPKhhjXgE3LxuNcJO/ghSXs8f73xrqGvqouag7IbuliJYbHrt22WH3X75XSbD0+iOdiqA2bARvAO/ezyxQ== dependencies: - "@angular-devkit/core" "20.3.24" - "@angular-devkit/schematics" "20.3.24" + "@angular-devkit/core" "20.3.25" + "@angular-devkit/schematics" "20.3.25" jsonc-parser "3.3.1" "@sigstore/bundle@^4.0.0": @@ -5712,37 +5712,37 @@ esbuild-wasm@0.28.0: resolved "https://registry.yarnpkg.com/esbuild-wasm/-/esbuild-wasm-0.28.0.tgz#e2e60b86ed19e0f6f5a1e6c1c722f5a811d7d5f7" integrity sha512-5TRVKExcEmeMkccIZMzUq+Az6X2RoMAJyfl6SMMO1dMVhmvt0I2mx7gAb6zYi42n4d1ETcatFXazGKzA+aW7fg== -esbuild@0.28.0, esbuild@^0.27.0: - version "0.28.0" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" - integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== +esbuild@0.28.0, esbuild@0.28.1, esbuild@^0.27.0: + version "0.28.1" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" + integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw== optionalDependencies: - "@esbuild/aix-ppc64" "0.28.0" - "@esbuild/android-arm" "0.28.0" - "@esbuild/android-arm64" "0.28.0" - "@esbuild/android-x64" "0.28.0" - "@esbuild/darwin-arm64" "0.28.0" - "@esbuild/darwin-x64" "0.28.0" - "@esbuild/freebsd-arm64" "0.28.0" - "@esbuild/freebsd-x64" "0.28.0" - "@esbuild/linux-arm" "0.28.0" - "@esbuild/linux-arm64" "0.28.0" - "@esbuild/linux-ia32" "0.28.0" - "@esbuild/linux-loong64" "0.28.0" - "@esbuild/linux-mips64el" "0.28.0" - "@esbuild/linux-ppc64" "0.28.0" - "@esbuild/linux-riscv64" "0.28.0" - "@esbuild/linux-s390x" "0.28.0" - "@esbuild/linux-x64" "0.28.0" - "@esbuild/netbsd-arm64" "0.28.0" - "@esbuild/netbsd-x64" "0.28.0" - "@esbuild/openbsd-arm64" "0.28.0" - "@esbuild/openbsd-x64" "0.28.0" - "@esbuild/openharmony-arm64" "0.28.0" - "@esbuild/sunos-x64" "0.28.0" - "@esbuild/win32-arm64" "0.28.0" - "@esbuild/win32-ia32" "0.28.0" - "@esbuild/win32-x64" "0.28.0" + "@esbuild/aix-ppc64" "0.28.1" + "@esbuild/android-arm" "0.28.1" + "@esbuild/android-arm64" "0.28.1" + "@esbuild/android-x64" "0.28.1" + "@esbuild/darwin-arm64" "0.28.1" + "@esbuild/darwin-x64" "0.28.1" + "@esbuild/freebsd-arm64" "0.28.1" + "@esbuild/freebsd-x64" "0.28.1" + "@esbuild/linux-arm" "0.28.1" + "@esbuild/linux-arm64" "0.28.1" + "@esbuild/linux-ia32" "0.28.1" + "@esbuild/linux-loong64" "0.28.1" + "@esbuild/linux-mips64el" "0.28.1" + "@esbuild/linux-ppc64" "0.28.1" + "@esbuild/linux-riscv64" "0.28.1" + "@esbuild/linux-s390x" "0.28.1" + "@esbuild/linux-x64" "0.28.1" + "@esbuild/netbsd-arm64" "0.28.1" + "@esbuild/netbsd-x64" "0.28.1" + "@esbuild/openbsd-arm64" "0.28.1" + "@esbuild/openbsd-x64" "0.28.1" + "@esbuild/openharmony-arm64" "0.28.1" + "@esbuild/sunos-x64" "0.28.1" + "@esbuild/win32-arm64" "0.28.1" + "@esbuild/win32-ia32" "0.28.1" + "@esbuild/win32-x64" "0.28.1" escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" @@ -6265,15 +6265,15 @@ foreground-child@^3.1.0: signal-exit "^4.0.1" form-data@4.0.0, form-data@>=4.0.4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" - integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== + version "4.0.6" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" + integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" es-set-tostringtag "^2.1.0" - hasown "^2.0.2" - mime-types "^2.1.12" + hasown "^2.0.4" + mime-types "^2.1.35" formdata-polyfill@^4.0.10: version "4.0.10" @@ -6558,17 +6558,17 @@ has-tostringtag@^1.0.2: dependencies: has-symbols "^1.0.3" -hasown@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" - integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== +hasown@^2.0.2, hasown@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== dependencies: function-bind "^1.1.2" hono@^4.11.4: - version "4.12.8" - resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.8.tgz#5f3a9c0d5339ff460b2c652a4c64dd79059930ad" - integrity sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A== + version "4.12.25" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.25.tgz#f2d9996a54e8c9c0c5f5de1c8f3a962e43a98c4e" + integrity sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ== hosted-git-info@^9.0.0: version "9.0.2" @@ -7232,9 +7232,9 @@ js-beautify@1.15.4: nopt "^7.2.1" js-cookie@^3.0.5: - version "3.0.5" - resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" - integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== + version "3.0.8" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.8.tgz#444e6f4b27a5d844594fef61c9d6bca5f0787688" + integrity sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw== js-tokens@^4.0.0: version "4.0.0" @@ -7865,7 +7865,7 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== -mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: +mime-types@^2.1.27, mime-types@^2.1.31, mime-types@^2.1.35, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -8742,6 +8742,11 @@ pbf@^3.2.1, pbf@^3.3.0: ieee754 "^1.1.12" resolve-protobuf-schema "^2.1.0" +photoswipe@^5.4.4: + version "5.4.4" + resolved "https://registry.yarnpkg.com/photoswipe/-/photoswipe-5.4.4.tgz#e045dc036453493188d5c8665b0e8f1000ac4d6e" + integrity sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA== + picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" @@ -8916,10 +8921,10 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^ resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@8.5.6, postcss@^8.2.14, postcss@^8.4.33, postcss@^8.4.4, postcss@^8.4.47, postcss@^8.4.49, postcss@^8.5.6: - version "8.5.6" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" - integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== +postcss@8.5.12, postcss@^8.2.14, postcss@^8.4.33, postcss@^8.4.4, postcss@^8.4.47, postcss@^8.4.49, postcss@^8.5.6: + version "8.5.12" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.12.tgz#cd0c0f667f7cb0521e2313234ea6e707a9ec1ddb" + integrity sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA== dependencies: nanoid "^3.3.11" picocolors "^1.1.1" @@ -9643,9 +9648,9 @@ shebang-regex@^3.0.0: integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== shell-quote@^1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680" - integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA== + version "1.8.4" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.4.tgz#2edd9a4dcefc96649e2e2cb12f637b1f1d92a190" + integrity sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ== side-channel-list@^1.0.0: version "1.0.0" @@ -10232,9 +10237,9 @@ tinyqueue@^3.0.0: integrity sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g== tmp@^0.2.4: - version "0.2.5" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.5.tgz#b06bcd23f0f3c8357b426891726d16015abfd8f8" - integrity sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow== + version "0.2.7" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.7.tgz#26f4db11d1601ce8012dcb8a798ece1c06a99059" + integrity sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw== to-regex-range@^5.0.1: version "5.0.1" @@ -10584,10 +10589,10 @@ vary@^1, vary@^1.1.2, vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== -vite@7.3.2: - version "7.3.2" - resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.2.tgz#cb041794d4c1395e28baea98198fd6e8f4b96b5c" - integrity sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg== +vite@7.3.2, vite@7.3.5: + version "7.3.5" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.5.tgz#90c2d0b7b94a224e7e7dcf22d2912ff0b5291165" + integrity sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww== dependencies: esbuild "^0.27.0" fdir "^6.5.0" @@ -10945,9 +10950,9 @@ wrappy@1: integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== ws@^8.18.0: - version "8.19.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.19.0.tgz#ddc2bdfa5b9ad860204f5a72a4863a8895fd8c8b" - integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== + version "8.21.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.0.tgz#012e413fc07429945121b0c153158c4343086951" + integrity sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g== wsl-utils@^0.1.0: version "0.1.0"