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 2701abcb84..2168ab7595 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 @@ -14,6 +14,7 @@ "home_page_widgets.documentation_links", "home_page_widgets.dashboards", "home_page_widgets.usage_info", - "api_usage" + "api_usage", + "home_page_widgets.iot_hub" ] } \ No newline at end of file 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/upgrade/lts/schema_update.sql b/application/src/main/data/upgrade/lts/schema_update.sql index 790dc50b52..6b9d65750d 100644 --- a/application/src/main/data/upgrade/lts/schema_update.sql +++ b/application/src/main/data/upgrade/lts/schema_update.sql @@ -23,3 +23,19 @@ ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar; -- CALCULATED FIELD ADDITIONAL INFO ADDITION END + +-- 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 +); + +-- IOT HUB INSTALLED ITEM END 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..09a30f9266 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/IotHubController.java @@ -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. + */ +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.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')") + @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 e414efa6e7..e9c10261ad 100644 --- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -84,6 +84,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) @@ -185,6 +188,7 @@ public class SystemInfoController extends BaseController { systemParams.setIntermediateAggregationIntervalInSecForCF(tenantProfileConfiguration.getIntermediateAggregationIntervalInSecForCF()); 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/entitiy/cf/DefaultTbCalculatedFieldService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java index 4adb97f380..e5464505b0 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 @@ -32,6 +32,7 @@ import org.thingsboard.script.api.tbel.TbelCfTsDoubleVal; import org.thingsboard.script.api.tbel.TbelCfTsRollingArg; import org.thingsboard.script.api.tbel.TbelInvokeService; 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.cf.CalculatedFieldType; @@ -99,7 +100,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 9dbb20dc2f..a35eedbe02 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.entitiy.cf; import com.fasterxml.jackson.databind.JsonNode; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -34,7 +35,7 @@ public interface TbCalculatedFieldService { PageData findByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, CalculatedFieldType type, PageLink pageLink); - void delete(CalculatedField calculatedField, SecurityUser user); + void delete(CalculatedField calculatedField, User user); JsonNode executeTestScript(TenantId tenantId, JsonNode inputParams); 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..23e7b0e213 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java @@ -0,0 +1,715 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +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.AlarmRuleInstalledItemDescriptor; +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.exception.EntitiesLimitExceededException; +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.HexFormat; +import java.util.List; +import java.util.UUID; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +@Slf4j +public class DefaultIotHubService implements IotHubService { + + 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; + + @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); + + try { + JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId); + String itemType = versionInfo.get("type").asText(); + String itemName = versionInfo.get("name").asText(); + UUID itemId = UUID.fromString(versionInfo.get("itemId").asText()); + String version = versionInfo.get("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" -> installAlarmRule(user, tenantId, fileData, data); + 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); + iotHubInstalledItemService.save(tenantId, installedItem); + + iotHubRestClient.reportVersionInstalled(versionId); + log.info("[{}] Successfully installed IoT Hub item version: {} (type: {})", tenantId, itemName, itemType); + + return InstallItemVersionResult.success(descriptor); + } catch (Exception e) { + log.error("[{}] Failed to install IoT Hub item version: {}", tenantId, versionId, e); + if (e instanceof EntitiesLimitExceededException el) { + throw el; + } + return InstallItemVersionResult.error(e.getMessage()); + } + } + + 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 new Exception("Failed to parse widget data: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), 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 new Exception("Failed to parse dashboard data: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), 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 new Exception("Failed to parse calculated field data: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), 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 AlarmRuleInstalledItemDescriptor installAlarmRule(SecurityUser user, TenantId tenantId, byte[] fileData, JsonNode data) throws Exception { + CalculatedField alarmRule; + try { + alarmRule = JacksonUtil.fromString(new String(fileData), CalculatedField.class, true); + } catch (Exception e) { + throw new Exception("Failed to parse alarm rule data: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), e); + } + if (alarmRule.getType() != CalculatedFieldType.ALARM) { + throw new Exception("Expected ALARM-type calculated field for ALARM_RULE install; got: " + alarmRule.getType()); + } + alarmRule.setId(null); + alarmRule.setTenantId(tenantId); + if (data != null && data.has("entityId")) { + EntityId entityId = JacksonUtil.treeToValue(data.get("entityId"), EntityId.class); + alarmRule.setEntityId(entityId); + } + CalculatedField saved = tbCalculatedFieldService.save(alarmRule, user); + log.debug("[{}] Alarm rule installed: {}", tenantId, saved.getName()); + AlarmRuleInstalledItemDescriptor descriptor = new AlarmRuleInstalledItemDescriptor(); + 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 new Exception("Failed to parse rule chain: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), e); + } + + RuleChainMetaData metadata; + try { + metadata = JacksonUtil.fromString(json.get("metadata").toString(), RuleChainMetaData.class, true); + } catch (Exception e) { + throw new Exception("Failed to parse rule chain metadata: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), 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 new Exception("Failed to parse device profile data: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), 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()); + 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); + 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 "ALARM_RULE" -> updateAlarmRule(user, tenantId, (AlarmRuleInstalledItemDescriptor) 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.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 new Exception("Failed to parse widget data: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), 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 new Exception("Failed to parse dashboard data: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), 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 new Exception("Failed to parse calculated field data: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), 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 updateAlarmRule(SecurityUser user, TenantId tenantId, AlarmRuleInstalledItemDescriptor descriptor, byte[] fileData) throws Exception { + CalculatedField newCf; + try { + newCf = JacksonUtil.fromString(new String(fileData), CalculatedField.class, true); + } catch (Exception e) { + throw new Exception("Failed to parse alarm rule data: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), e); + } + if (newCf.getType() != CalculatedFieldType.ALARM) { + throw new Exception("Expected ALARM-type calculated field for ALARM_RULE update; got: " + newCf.getType()); + } + CalculatedField existing = calculatedFieldService.findById(tenantId, descriptor.getCalculatedFieldId()); + if (existing == null) { + throw new Exception("Alarm rule 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 new Exception("Failed to parse rule chain metadata: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), 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 new Exception("Failed to parse rule chain: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), 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 void updateDeviceProfile(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { + // TODO: implement device profile update + } + + 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.getCalculatedFieldId()); + } else if (descriptor instanceof AlarmRuleInstalledItemDescriptor ad) { + return calculateCalculatedFieldChecksum(tenantId, ad.getCalculatedFieldId()); + } else if (descriptor instanceof RuleChainInstalledItemDescriptor rd) { + return calculateRuleChainChecksum(tenantId, rd); + } + return null; + } + + private String calculateCalculatedFieldChecksum(TenantId tenantId, CalculatedFieldId calculatedFieldId) { + CalculatedField cf = calculatedFieldService.findById(tenantId, calculatedFieldId); + 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); + 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); + iotHubInstalledItemService.save(tenantId, installedItem); + + iotHubRestClient.reportVersionInstalled(versionId); + 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"); + } + + IotHubInstalledItemDescriptor descriptor = installedItem.getDescriptor(); + 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 AlarmRuleInstalledItemDescriptor ad) { + CalculatedField alarmRule = calculatedFieldService.findById(tenantId, ad.getCalculatedFieldId()); + if (alarmRule != null) { + tbCalculatedFieldService.delete(alarmRule, 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); + } + } + + iotHubInstalledItemService.deleteById(tenantId, installedItemId); + log.info("[{}] Deleted installed IoT Hub item: {}", tenantId, installedItem.getItemName()); + } +} 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/IotHubRestClient.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java new file mode 100644 index 0000000000..1155bcf0ad --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.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.iot_hub; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@TbCoreComponent +@Slf4j +public class IotHubRestClient { + + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${iot-hub.base-url:https://iot-hub.thingsboard.io}") + private String baseUrl; + + 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); + } + + public byte[] getVersionFileData(String versionId) { + String url = baseUrl + "/api/versions/" + versionId + "/fileData"; + log.debug("Fetching IoT Hub version file data: {}", url); + ResponseEntity response = restTemplate.getForEntity(url, byte[].class); + return response.getBody(); + } + + 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..e2f0ff0c39 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.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.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); +} 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/solutions/DefaultSolutionService.java b/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java new file mode 100644 index 0000000000..246e889ec3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java @@ -0,0 +1,1680 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; +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.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.exception.EntitiesLimitExceededException; +import org.thingsboard.server.exception.ThingsboardRuntimeException; +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.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; + + 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 (tsKeys != null && !tsKeys.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 = loadInstallTimeoutMs(ctx.getTempDir()); + 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); + if (e instanceof EntitiesLimitExceededException el) { + throw el; + } + 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 ArgumentsBasedCalculatedFieldConfiguration argBasedCfg) { + argBasedCfg.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(), ctx.getSolutionId(), cf.getName()); + throw new ThingsboardRuntimeException(); + } + } + } + }); + } + + 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 void extractZip(byte[] zipData, Path destDir) throws IOException { + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipData))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + 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()); + Files.write(entryPath, zis.readAllBytes()); + } + 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..e03b28588f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedAlarmRuleInfo.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; + +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.UUID; +import java.util.stream.Collectors; + +public record CreatedAlarmRuleInfo(EntityId entityId, String entityName, String alarmType, + String severities) implements HasAppliedToEntity { + + public static CreatedAlarmRuleInfo from(EntityId entityId, String entityName, CalculatedField calculatedField) { + if (calculatedField.getType() != CalculatedFieldType.ALARM) { + throw new UnsupportedOperationException("Only alarm calculated fields are supported"); + } + String severities = ((AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration()) + .getCreateRules().keySet().stream() + .map(AlarmSeverity::getDisplayName) + .sorted() + .collect(Collectors.joining(", ")); + return new CreatedAlarmRuleInfo(entityId, entityName, calculatedField.getName(), severities); + } + + @Override + public String getCfPageLink(UUID cfId) { + return "/alarms/alarm-rules/" + cfId; + } +} 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..7b19fbc0cf --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedCalculatedFieldInfo.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; + +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.UUID; + +public record CreatedCalculatedFieldInfo(EntityId entityId, String entityName, String type, + String name) implements HasAppliedToEntity { + + public static CreatedCalculatedFieldInfo from(EntityId entityId, String entityName, CalculatedField calculatedField) { + return new CreatedCalculatedFieldInfo(entityId, entityName, calculatedField.getType().getDisplayName(), calculatedField.getName()); + } + + @Override + public String getCfPageLink(UUID cfId) { + return "/calculatedFields/" + cfId; + } +} 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/HasAppliedToEntity.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/HasAppliedToEntity.java new file mode 100644 index 0000000000..9b029bfa78 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/HasAppliedToEntity.java @@ -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. + */ +package org.thingsboard.server.service.solutions.data; + +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.UUID; + +public interface HasAppliedToEntity { + + EntityId entityId(); + + String getCfPageLink(UUID cfId); + + default String getEntityPageLink() { + EntityId id = entityId(); + if (id == null) { + return null; + } + String idStr = id.getId().toString(); + return switch (id.getEntityType()) { + case DEVICE_PROFILE -> "/profiles/deviceProfiles/" + idStr; + case ASSET_PROFILE -> "/profiles/assetProfiles/" + idStr; + case DEVICE -> "/entities/devices/" + idStr; + case ASSET -> "/entities/assets/" + idStr; + default -> null; + }; + } + +} 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..4985ca1685 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/SolutionInstallContext.java @@ -0,0 +1,210 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.cf.CalculatedFieldType; +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 = calculatedField.getType() == CalculatedFieldType.ALARM; + 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(), CreatedAlarmRuleInfo.from(entityId, entityInfo.getName(), calculatedField)); + return; + } + createdCalculatedFields.put(calculatedField.getUuidId(), CreatedCalculatedFieldInfo.from(entityId, entityInfo.getName(), calculatedField)); + } + + 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 6ceef7bc2e..05e8ad5263 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -2239,3 +2239,5 @@ 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. 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/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java index 1abb6afe9e..e1eae63acc 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 @@ -48,4 +48,5 @@ public class SystemParams { boolean allowKeyFiltersOrConditions; String nullsOrderStrategy; boolean edqsEnabled; + String iotHubBaseUrl; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmSeverity.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmSeverity.java index e7776ce286..fbeac79982 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmSeverity.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmSeverity.java @@ -15,8 +15,14 @@ */ package org.thingsboard.server.common.data.alarm; +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; + public enum AlarmSeverity { CRITICAL, MAJOR, MINOR, WARNING, INDETERMINATE; + @Getter + private final String displayName = StringUtils.capitalize(name().toLowerCase()); + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java index fb944344e3..6dbf567003 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java @@ -15,20 +15,29 @@ */ package org.thingsboard.server.common.data.cf; +import lombok.Getter; + import java.util.Collections; import java.util.EnumSet; import java.util.Set; public enum CalculatedFieldType { - SIMPLE, - SCRIPT, - GEOFENCING, - ALARM, - PROPAGATION, - RELATED_ENTITIES_AGGREGATION, - ENTITY_AGGREGATION; + SIMPLE("Simple"), + SCRIPT("Script"), + GEOFENCING("Geofencing"), + ALARM("Alarm"), + PROPAGATION("Propagation"), + RELATED_ENTITIES_AGGREGATION("Related entities aggregation"), + ENTITY_AGGREGATION("Time series data aggregation"); public static final Set all = Collections.unmodifiableSet(EnumSet.allOf(CalculatedFieldType.class)); + @Getter + private final String displayName; + + CalculatedFieldType(String displayName) { + this.displayName = displayName; + } + } 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/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/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..2ad7d1d01a --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.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.dao.iot_hub; + +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.List; +import java.util.Map; +import java.util.UUID; + +public interface IotHubInstalledItemDao extends Dao { + + PageData findByTenantId(TenantId tenantId, List itemTypes, UUID itemId, PageLink pageLink); + + List findInstalledItemIdsByTenantId(TenantId tenantId); + + long countByTenantId(TenantId tenantId, String itemType); + + Map findInstalledItemCounts(TenantId tenantId, String itemType); + + void deleteByTenantId(TenantId tenantId); + +} 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..3fa15852d8 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.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.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.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); + + 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..c98f03d104 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java @@ -0,0 +1,79 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 java.util.List; +import java.util.Map; +import java.util.UUID; + +@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.findById(tenantId, id.getId()); + } + + @Override + public PageData findByTenantId(TenantId tenantId, List itemTypes, UUID itemId, PageLink pageLink) { + return iotHubInstalledItemDao.findByTenantId(tenantId, itemTypes, itemId, pageLink); + } + + @Override + public List findInstalledItemIdsByTenantId(TenantId tenantId) { + return iotHubInstalledItemDao.findInstalledItemIdsByTenantId(tenantId); + } + + @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.removeById(tenantId, id.getId()); + } + + @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 6f971e61f7..ed34a2e64c 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 @@ -764,6 +764,18 @@ public class ModelConstants { public static final String API_KEY_ENABLED_COLUMN_NAME = "enabled"; public static final String API_KEY_DESCRIPTION_COLUMN_NAME = "description"; + /** + * 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..e0cd2f1c17 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/IotHubInstalledItemEntity.java @@ -0,0 +1,110 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.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.Objects; +import java.util.UUID; + +@Getter +@Setter +@ToString +@Entity +@Table(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_TABLE_NAME) +public class IotHubInstalledItemEntity extends BaseSqlEntity { + + @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/sql/iot_hub/IotHubInstalledItemRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java new file mode 100644 index 0000000000..69f93cd5e7 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.dao.model.sql.IotHubInstalledItemEntity; + +import java.util.List; +import java.util.UUID; + +interface IotHubInstalledItemRepository extends JpaRepository { + + @Query("SELECT DISTINCT item.itemId FROM IotHubInstalledItemEntity item WHERE item.tenantId = :tenantId") + List findInstalledItemIdsByTenantId(@Param("tenantId") UUID tenantId); + + @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); + +} 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..b0db650ae0 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java @@ -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. + */ +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.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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@SqlDao +@Component +@RequiredArgsConstructor +class JpaIotHubInstalledItemDao extends JpaAbstractDao implements IotHubInstalledItemDao { + + private final IotHubInstalledItemRepository repository; + + @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 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()); + } + + 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 392c422792..bd3451b538 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 @@ -86,6 +86,8 @@ public class TenantServiceImpl extends AbstractCachedEntityService existsTenantCache; @@ -171,6 +173,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `/iot-hub/{itemId}` (latest published version of an item) and `/iot-hub/version/{itemVersionId}` (specific version snapshot, warning gate if unpublished) deep links that resolve a version, navigate to the type-specific browse page, and open the existing detail dialog. + +**Architecture:** A router-reachable `TbIotHubItemResolverComponent` owns resolution: fetch by itemId, (optionally) gate on a blocking warning dialog, then `router.navigate` to `/iot-hub/{typeSegment(type)}` carrying the version in `history.state`. The target type-page consumes the state once and opens the existing `TbIotHubItemDetailDialogComponent` via `IotHubActionsService`, with a new `preview` flag that adds an "Unpublished preview" badge. Zero ThingsBoard backend changes — install flows reuse existing versionId endpoints. + +**Tech Stack:** Angular 20, Angular Material dialogs, NgRx (for toast dispatch), RxJS. TypeScript strict mode. + +**Note on testing:** `ui-ngx` has no frontend test runner wired up (no `npm test` script, no karma/jest config). Each task is verified by `npm run lint` + `npm run build:prod` passing and, for flow-level tasks, a manual dev-server smoke test documented in the final task. + +**Prerequisite (external):** The two new IoT Hub endpoints (`GET /api/items/{itemId}/published` and `GET /api/items/{itemId}/latest`) must be deployed on the target IoT Hub instance for end-to-end testing. Until they land, local verification can be done against a mocked IoT Hub or against the published endpoint only. See the spec (`docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md`) for the full IoT Hub-side contract. + +--- + +## File structure + +**Create:** +- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` — UUID check, type→route-segment mapping, `isPublished` predicate +- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts` — route-reachable controller +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts` — warning dialog logic +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html` — warning dialog template +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss` — warning dialog styles + +**Modify:** +- `ui-ngx/src/app/core/http/iot-hub-api.service.ts` — add `getPublishedVersion`, `getLatestVersion` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` — add `preview` field on data + component +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` — preview badge markup +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss` — preview badge styles +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts` — propagate `preview` in `openItemDetail` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` — declare + export warning dialog +- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts` — two new child routes (placed last) +- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts` — declare resolver component +- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts` — `maybeOpenDeepLinkedItem` handoff +- `ui-ngx/src/assets/locale/locale.constant-en_US.json` — 9 new i18n keys under the existing `"iot-hub"` block + +--- + +### Task 1: Add i18n keys + +Add all new English strings up front so later tasks can reference them freely. Other locales follow the existing project convention (en_US only for new iot-hub keys; translators backfill later). + +**Files:** +- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` + +- [ ] **Step 1: Add keys inside the existing `"iot-hub"` block** + +Open `ui-ngx/src/assets/locale/locale.constant-en_US.json`, find the `"iot-hub"` block (starts at the line matching `"iot-hub": {`), and append the following nine keys to it (place them immediately before the closing `}` of the `"iot-hub"` object, keeping JSON valid — i.e. add a trailing comma to the previous key): + +```json + "item-detail": "IoT Hub item", + "item-preview": "IoT Hub item preview", + "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." +``` + +- [ ] **Step 2: Validate JSON** + +Run: +```bash +cd ui-ngx && node -e "JSON.parse(require('fs').readFileSync('src/assets/locale/locale.constant-en_US.json', 'utf8')); console.log('OK')" +``` +Expected output: `OK`. + +- [ ] **Step 3: Commit** + +```bash +git add ui-ngx/src/assets/locale/locale.constant-en_US.json +git commit -m "feat(iot-hub): add i18n keys for item deep-link and unpublished warning" +``` + +--- + +### Task 2: Deep-link utility module + +A tiny, pure-TS helper file. No Angular deps. Exported functions are used by the resolver and (for `isPublished`) by the detail dialog. + +**Files:** +- Create: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` + +- [ ] **Step 1: Create the file** + +Create `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` with: + +```ts +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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.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; +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: +```bash +cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -20 +``` +Expected: build succeeds. If `ng build` is too slow for iteration, use `npx tsc --noEmit -p tsconfig.json 2>&1 | grep iot-hub-deep-link || echo 'no type errors in utils file'`. + +- [ ] **Step 3: Commit** + +```bash +git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts +git commit -m "feat(iot-hub): add deep-link utility helpers (isUUID, typeSegment, isPublished)" +``` + +--- + +### Task 3: IoT Hub API service — two new methods + +Add `getPublishedVersion` and `getLatestVersion` to `IotHubApiService`. Both hit IoT Hub cross-origin endpoints (same `baseUrl` as existing `/api/versions/published`). + +**Files:** +- Modify: `ui-ngx/src/app/core/http/iot-hub-api.service.ts` + +- [ ] **Step 1: Add the two methods** + +Open `ui-ngx/src/app/core/http/iot-hub-api.service.ts`. Insert the following two methods immediately after `getVersionInfo` (which ends near line 100): + +```ts + public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable { + return this.http.get( + `${this.baseUrl}/api/items/${itemId}/published`, + { params: this.buildParams(config) } + ); + } + + public getLatestVersion(itemId: string, config?: IotHubRequestConfig): Observable { + return this.http.get( + `${this.baseUrl}/api/items/${itemId}/latest`, + { params: this.buildParams(config) } + ); + } +``` + +Use the exact `Edit` tool call: `old_string` should be the line ` public getVersionReadme(versionId: string, config?: IotHubRequestConfig): Observable {` (and enough context around it), and `new_string` should prepend the two new methods above it. + +- [ ] **Step 2: Verify build** + +Run: +```bash +cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 +``` +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add ui-ngx/src/app/core/http/iot-hub-api.service.ts +git commit -m "feat(iot-hub): add getPublishedVersion and getLatestVersion API methods" +``` + +--- + +### Task 4: Unpublished warning dialog component + +New dialog styled after `TbIotHubDeleteDialogComponent`: title, description, item summary, Cancel + danger-accented confirm button. Returns `boolean` from `afterClosed`. + +**Files:** +- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts` +- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html` +- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss` +- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` + +- [ ] **Step 1: Create the component .ts** + +Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts`: + +```ts +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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); + } +} +``` + +- [ ] **Step 2: Create the component .html** + +Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html`: + +```html + +
+
+ warning +

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

+
+

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

+
+ {{ data.item.name }} + v {{ data.item.version }} +
+
+
+ + +
+``` + +- [ ] **Step 3: Create the component .scss** + +Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss`: + +```scss +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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; +} +``` + +- [ ] **Step 4: Register in `IotHubComponentsModule`** + +Open `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts`. + +Add the import line after the existing `TbIotHubDeleteDialogComponent` import: + +```ts +import { TbIotHubUnpublishedWarningDialogComponent } from './iot-hub-unpublished-warning-dialog.component'; +``` + +In the `declarations` array, add `TbIotHubUnpublishedWarningDialogComponent` after `TbIotHubDeleteDialogComponent`. + +In the `exports` array, add `TbIotHubUnpublishedWarningDialogComponent` after `TbIotHubDeleteDialogComponent`. + +- [ ] **Step 5: Verify build** + +Run: +```bash +cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 +``` +Expected: build succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts \ + ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html \ + ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss \ + ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts +git commit -m "feat(iot-hub): add unpublished content warning dialog" +``` + +--- + +### Task 5: Detail dialog `preview` flag + badge, actions service signature + +Add `preview?: boolean` to `IotHubItemDetailDialogData`, store on the component, render an "Unpublished preview" badge in the meta-bar next to the version chip. Extend `IotHubActionsService.openItemDetail` to forward the flag. + +**Files:** +- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` +- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` +- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss` +- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts` + +- [ ] **Step 1: Extend `IotHubItemDetailDialogData` + component field** + +In `iot-hub-item-detail-dialog.component.ts`, find the `IotHubItemDetailDialogData` interface (around line 43) and add the optional `preview` field: + +```ts +export interface IotHubItemDetailDialogData { + item: MpItemVersionView; + installedItem?: IotHubInstalledItem; + installedItemsCount?: number; + mode?: IotHubItemDetailDialogMode; + showCreator?: boolean; + preview?: boolean; +} +``` + +In the component class, add a new public field below `showCreator`: + +```ts + preview: boolean; +``` + +In the constructor body (below `this.showCreator = data.showCreator !== false;`), add: + +```ts + this.preview = data.preview === true; +``` + +- [ ] **Step 2: Add badge markup in template** + +In `iot-hub-item-detail-dialog.component.html`, find the block starting with `` that contains the version icon (around line 39, the `update` mat-icon). Immediately **after** the closing `` of that version group (just before the `@if (item.publishedTime)` block), insert: + +```html + @if (preview) { + + + warning + {{ 'iot-hub.unpublished-preview' | translate }} + + } +``` + +- [ ] **Step 3: Style the badge** + +Append to `iot-hub-item-detail-dialog.component.scss`: + +```scss +.dlg-subtitle-group.tb-unpublished-preview-badge { + color: #d32f2f; + font-weight: 600; + + .dlg-subtitle-icon { + color: #d32f2f; + } +} +``` + +- [ ] **Step 4: Extend `IotHubActionsService.openItemDetail`** + +In `iot-hub-actions.service.ts`, replace the `openItemDetail` method signature and body with: + +```ts + 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'], + autoFocus: false, + data: { item, installedItem, installedItemsCount, mode, showCreator, preview } as IotHubItemDetailDialogData + }).afterClosed(); + } +``` + +Existing callers pass 5 or fewer args and remain valid — the new parameter is optional. + +- [ ] **Step 5: Verify build** + +Run: +```bash +cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 +``` +Expected: build succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts \ + ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html \ + ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss \ + ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts +git commit -m "feat(iot-hub): add unpublished preview badge to item detail dialog" +``` + +--- + +### Task 6: Resolver component + +The heart of the feature. Fetches the version, gates on warning for unpublished preview, redirects to the type-page with router state. + +**Files:** +- Create: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts` + +- [ ] **Step 1: Create the component** + +Create `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts`: + +```ts +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ActionNotificationShow } from '@core/notification/notification.actions'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { 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'; + +@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 itemId = this.route.snapshot.paramMap.get('itemId'); + const preview = this.route.snapshot.data['preview'] === true; + + if (!isUUID(itemId)) { + this.failTo('iot-hub.deep-link-invalid-id'); + return; + } + + const fetch$ = preview + ? this.iotHubApi.getLatestVersion(itemId, { ignoreErrors: true }) + : this.iotHubApi.getPublishedVersion(itemId, { ignoreErrors: true }); + + fetch$.subscribe({ + next: v => this.handleResolved(v, preview), + error: err => { + const key = err?.status === 404 + ? 'iot-hub.deep-link-not-found' + : 'iot-hub.deep-link-fetch-failed'; + this.failTo(key); + } + }); + } + + private handleResolved(version: MpItemVersionView, preview: boolean): void { + const segment = typeSegment(version.type); + if (!segment) { + this.failTo('iot-hub.deep-link-fetch-failed'); + return; + } + + const showWarning = preview && !isPublished(version); + + if (showWarning) { + 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 }); + } +} +``` + +- [ ] **Step 2: Verify TypeScript compiles (the module wiring comes in Task 7)** + +The component is not yet declared in any module, so a full `ng build` will fail. Run a type-check only on this file: + +```bash +cd ui-ngx && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "iot-hub-item-resolver|iot-hub-deep-link" || echo "no type errors in new files" +``` +Expected: `no type errors in new files`. + +- [ ] **Step 3: Commit** + +```bash +git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts +git commit -m "feat(iot-hub): add item resolver component for deep links" +``` + +--- + +### Task 7: Routing + module registration + +Register the resolver component in `IotHubModule` and add the two new child routes to `iot-hub-routing.module.ts`. Reserved words must be matched first, so the `:itemId` wildcard routes go **last** in the children array. + +**Files:** +- Modify: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts` +- Modify: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts` + +- [ ] **Step 1: Declare resolver in `IotHubModule`** + +In `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts`, add the import: + +```ts +import { TbIotHubItemResolverComponent } from './iot-hub-item-resolver.component'; +``` + +In the `declarations` array, add `TbIotHubItemResolverComponent` after `TbIotHubSearchPageComponent`. + +- [ ] **Step 2: Add routes** + +In `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts`, add the import: + +```ts +import { TbIotHubItemResolverComponent } from './iot-hub-item-resolver.component'; +``` + +In the `children` array of the `/iot-hub` route, **immediately before** the closing `]` (after the existing `creator/:creatorId` entry), insert: + +```ts + { + path: ':itemId', + component: TbIotHubItemResolverComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'iot-hub.item-detail' + } + }, + { + path: ':itemId/preview', + component: TbIotHubItemResolverComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'iot-hub.item-preview', + preview: true + } + } +``` + +Make sure there is a comma after the preceding `creator/:creatorId` entry. + +- [ ] **Step 3: Verify build** + +Run: +```bash +cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 +``` +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts \ + ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts +git commit -m "feat(iot-hub): wire resolver component into routing and module" +``` + +--- + +### Task 8: Type-page handoff + +`TbIotHubItemsPageComponent` consumes `history.state.openItem` on init: resolves installed state, opens the detail dialog with the `preview` flag, then clears the state entry so refresh does not re-open. + +**Files:** +- Modify: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts` + +- [ ] **Step 1: Extend imports + constructor** + +Open `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts`. + +Replace the import block (top of file, currently lines 17-20) with: + +```ts +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'; +``` + +Replace the constructor with: + +```ts + constructor( + private route: ActivatedRoute, + private router: Router, + private iotHubApiService: IotHubApiService, + private iotHubActions: IotHubActionsService + ) {} +``` + +- [ ] **Step 2: Extend `ngOnInit` and add handoff method** + +Replace the existing `ngOnInit` body with: + +```ts + ngOnInit(): void { + const itemType = this.route.snapshot.data['itemType'] as string; + this.config = PAGE_CONFIGS[itemType]; + this.loadInstalledCount(); + this.maybeOpenDeepLinkedItem(); + } +``` + +Add these two new methods anywhere in the class (e.g. after `loadInstalledCount`): + +```ts + 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 }, ''); + this.resolveInstalledItem(openItem.version).subscribe(installed => { + this.iotHubActions.openItemDetail( + openItem.version, + installed ?? undefined, + installed ? 1 : 0, + '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)); + } +``` + +- [ ] **Step 3: Verify build** + +Run: +```bash +cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 +``` +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts +git commit -m "feat(iot-hub): open deep-linked item from history state on type-page init" +``` + +--- + +### Task 9: Lint, build, and manual smoke test + +Final verification gate. Runs the lint and production build, then walks each user-facing flow in a dev server to confirm behavior. + +**Files:** none (verification only) + +- [ ] **Step 1: Lint** + +Run: +```bash +cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng lint 2>&1 | tail -30 +``` +Expected: no new errors introduced by files created/modified in tasks 1–8. Warnings in unrelated files are acceptable. + +- [ ] **Step 2: Production build** + +Run: +```bash +cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -20 +``` +Expected: build completes without errors. + +- [ ] **Step 3: Start dev server** + +Run: +```bash +cd ui-ngx && node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0 +``` +Dev server should be reachable at `http://localhost:4200`. Log in as a TENANT_ADMIN user. (A running ThingsBoard backend at the default proxy target is required per `proxy.conf.js`.) + +- [ ] **Step 4: Manual smoke test — published deep link** + +Pick an itemId from `/iot-hub/widgets` (the card's click handler logs it, or inspect the dialog URL via browser dev tools; alternatively, query `iotHubBaseUrl/api/versions/published` and copy an `itemId`). + +Navigate to `http://localhost:4200/iot-hub/{that-itemId}`. Verify: +- URL bar ends at `/iot-hub/widgets` (or the correct type page for the item) after resolution +- Detail dialog opens with the item's info, no "Unpublished preview" badge, and Install/Update actions behave normally +- Closing the dialog leaves the user on the type-page + +Navigate to `http://localhost:4200/iot-hub/00000000-0000-0000-0000-000000000000` (a valid-shaped but non-existent UUID). Verify: +- URL bar ends at `/iot-hub` +- Red toast: "This IoT Hub item doesn't exist or was removed." + +Navigate to `http://localhost:4200/iot-hub/not-a-uuid`. Verify: +- URL bar ends at `/iot-hub` +- Red toast: "Invalid IoT Hub item link." + +- [ ] **Step 5: Manual smoke test — preview deep link (requires IoT Hub `/latest` endpoint)** + +**If the IoT Hub-side endpoints are not yet deployed**, skip this step and record that end-to-end preview verification is deferred until the IoT Hub PR lands. The per-URL flow is exercised in Step 4; the preview URL hitting a non-existent endpoint will trigger the "fetch failed" toast, which is the correct fallback behavior. + +**If the endpoints are available**, navigate to `http://localhost:4200/iot-hub/{unpublished-itemId}/preview`. Verify: +- Warning dialog opens immediately with the item's name/version, red warning icon, and two buttons +- "Cancel" → closes dialog, lands on `/iot-hub` (home) +- Revisit the URL, click "I understand the risk, continue" → URL advances to `/iot-hub/{typePage}`, detail dialog opens with the red "Unpublished preview" badge in the meta bar +- Install button still works end-to-end (calls the TB install endpoint, which proxies to IoT Hub using the unpublished versionId) + +Navigate to `http://localhost:4200/iot-hub/{published-only-itemId}/preview`. Verify: +- No warning shown (preview fell back to the published version) +- Detail dialog opens with **no** preview badge +- Behavior matches the regular published URL + +- [ ] **Step 6: Commit the final verification (if any lint/build fixes were needed)** + +If the smoke test uncovered issues that required fixes, stage and commit them with a message like `fix(iot-hub): address smoke-test findings for deep-link flow`. Otherwise, this step is a no-op — all previous commits already capture the work. + +--- + +## IoT Hub-side changes required (recap) + +One new endpoint plus behavior + CORS contracts on the existing by-versionId family: + +1. **New endpoint** `GET /api/items/{itemId}/published` — latest PUBLISHED version as `MpItemVersionView`; `404` if none. Anonymous cross-origin. Powers the `/iot-hub/{itemId}` URL. +2. **`GET /api/versions/{versionId}`** must return the requested version regardless of state (PUBLISHED / DRAFT / PENDING_REVIEW / …). Anonymous cross-origin; versionId UUID is the soft-secret gate. Powers the `/iot-hub/version/{itemVersionId}` URL. +3. **`MpItemVersionView`** must allow the frontend to tell published from unpublished. Either `publishedTime` must be falsy (`0`/`null`) for non-published versions, or add an explicit `state` field. Frontend's `isPublished()` uses `publishedTime > 0` today. +4. **Related by-versionId endpoints** must also serve unpublished versions (required for install-from-deep-link): + - `GET /api/versions/{versionId}/readme` + - `GET /api/versions/{versionId}/fileData` + - `POST /api/versions/{versionId}/install` +5. **Install counter policy** for unpublished versions — recommended: skip counting. +6. **CORS** on `/api/items/{itemId}/published` and the `/api/versions/{versionId}/...` family must permit cross-origin GET from any origin. + +These live in the IoT Hub repository, not ThingsBoard CE. diff --git a/docs/superpowers/plans/2026-04-27-install-method-sync.md b/docs/superpowers/plans/2026-04-27-install-method-sync.md new file mode 100644 index 0000000000..717ae910bb --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-install-method-sync.md @@ -0,0 +1,685 @@ +# Install-Method Sync with IoT Hub Marketplace — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Sync CE's `InstallMethod` enum + labels with the IoT Hub marketplace's expanded 50-value allow-list, and add a "PE only" gate in the device install dialog so users selecting a PE-only integration get a clear message instead of a silently-broken wizard. + +**Architecture:** Frontend-only. CE has no backend install-method allow-list (`DeviceInstalledItemDescriptor.selectedInstallMethod` is a free-form `String`), so all work lives in `ui-ngx`. The `GATEWAY_CONNECTOR` step type already handles connector config generically (line 761 of `device-install-dialog.component.ts`), so the 12 new `GATEWAY_*` install methods need no new install logic — only enum + label entries. The 26 new `INTEGRATION_*` methods are PE-only on CE: today CE silently skips `CONVERTER`/`INTEGRATION` steps (line 553), running an empty-progress wizard — this plan replaces that silent skip with an explicit "PE only — upgrade required" panel shown right after connectivity selection. + +**Tech Stack:** Angular 20, TypeScript, Material Design (existing `TbDeviceInstallDialogComponent`). + +--- + +## Source of Truth + +The marketplace allow-list lives at: +`/home/ashvayka/git/iot-hub/dao/src/main/java/org/thingsboard/iothub/dao/service/impl/ItemDataServiceImpl.java`, constant `VALID_INSTALL_METHODS`. + +CE's enum must be a strict subset of the marketplace's allow-list, including every entry. Diverging causes "marketplace accepts the package, CE wizard rejects it / shows raw constant" failures. + +PE/CE applicability — extracted from `/home/ashvayka/git/iot-hub/device-library-contribution.md`: + +| Group | Constants | CE-supported install behavior | +|-------|-----------|-------------------------------| +| Direct (5) | `DIRECT_HTTP`, `DIRECT_MQTT`, `DIRECT_COAP`, `DIRECT_LWM2M`, `DIRECT_SNMP` | Yes — runs through `DEVICE` + standard steps | +| Gateway (15) | `GATEWAY_MQTT`, `GATEWAY_MODBUS`, `GATEWAY_OPCUA`, `GATEWAY_BACNET`, `GATEWAY_BLE`, `GATEWAY_CAN`, `GATEWAY_FTP`, `GATEWAY_KNX`, `GATEWAY_OCPP`, `GATEWAY_ODBC`, `GATEWAY_REQUEST`, `GATEWAY_REST`, `GATEWAY_SNMP`, `GATEWAY_SOCKET`, `GATEWAY_XMPP` | Yes — generic `GATEWAY_CONNECTOR` step writes connector config to gateway shared attributes regardless of connector type | +| ChirpStack CE (1) | `CHIRPSTACK` | Yes | +| Integrations (29) | `INTEGRATION_*` (all values listed in step 4 of `VALID_INSTALL_METHODS`) | **No — PE only**. Show "PE only" panel, do not start wizard | + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Modify | `ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts` | Extend `InstallMethod` enum to 50 values; extend `installMethodLabels` to 50 labels; add new `peOnlyInstallMethods` set | +| Modify | `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts` | Add `peOnlySelected` getter; route `confirmConnectivity()` to a new "PE only" view-state instead of `startWizard()` when user picks a PE-only method | +| Modify | `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html` | Add a new conditional panel that renders when `peOnlySelected` is true: title, body copy, "Learn about ThingsBoard PE" link, Close button | +| Modify | `ui-ngx/src/assets/locale/locale.constant-en_US.json` | Add three translation keys for the PE-only panel: `iot-hub.device-install-pe-only-title`, `iot-hub.device-install-pe-only-message`, `iot-hub.device-install-pe-only-learn-more` | + +--- + +## Task 1: Sync `InstallMethod` enum to all 50 values + +**Files:** +- Modify: `ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts` + +- [ ] **Step 1: Replace the `InstallMethod` enum block** + +Find (lines 17–30): +```typescript +export enum InstallMethod { + DIRECT_HTTP = 'DIRECT_HTTP', + DIRECT_MQTT = 'DIRECT_MQTT', + DIRECT_COAP = 'DIRECT_COAP', + DIRECT_LWM2M = 'DIRECT_LWM2M', + DIRECT_SNMP = 'DIRECT_SNMP', + GATEWAY_MQTT = 'GATEWAY_MQTT', + GATEWAY_MODBUS = 'GATEWAY_MODBUS', + GATEWAY_OPCUA = 'GATEWAY_OPCUA', + CHIRPSTACK = 'CHIRPSTACK', + INTEGRATION_CHIRPSTACK = 'INTEGRATION_CHIRPSTACK', + INTEGRATION_TTN = 'INTEGRATION_TTN', + INTEGRATION_LORIOT = 'INTEGRATION_LORIOT' +} +``` + +Replace with (preserve grouping comments — they mirror the marketplace validator's grouping and make drift visible at review time): +```typescript +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' +} +``` + +- [ ] **Step 2: Verify enum count is 50** + +Run: +```bash +grep -c '^ [A-Z][A-Z_]* = ' /home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts +``` +Expected: `50` + +- [ ] **Step 3: Verify CE values are a subset of marketplace `VALID_INSTALL_METHODS`** + +Run: +```bash +diff <(grep -oP "(?<=^ )[A-Z][A-Z_]+(?= = )" /home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts | sort -u) \ + <(grep -oP '"[A-Z][A-Z_]+"' /home/ashvayka/git/iot-hub/dao/src/main/java/org/thingsboard/iothub/dao/service/impl/ItemDataServiceImpl.java | sed 's/^"//; s/"$//' | head -50 | sort -u) +``` +Expected: no output (sets identical). + +- [ ] **Step 4: Commit** + +```bash +git add ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts +git commit -m "feat(iot-hub): expand InstallMethod enum to marketplace's 50-value allow-list" +``` + +--- + +## Task 2: Sync `installMethodLabels` map + +**Files:** +- Modify: `ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts` + +- [ ] **Step 1: Replace the `installMethodLabels` map** + +Find (lines 32–47, after Task 1 the line numbers shift — locate by the `export const installMethodLabels = new Map(` declaration): +```typescript +export const installMethodLabels = new Map( + [ + [InstallMethod.DIRECT_HTTP, 'HTTP'], + [InstallMethod.DIRECT_MQTT, 'MQTT'], + [InstallMethod.DIRECT_COAP, 'CoAP'], + [InstallMethod.DIRECT_LWM2M, 'LwM2M'], + [InstallMethod.DIRECT_SNMP, 'SNMP'], + [InstallMethod.GATEWAY_MQTT, 'MQTT Gateway'], + [InstallMethod.GATEWAY_MODBUS, 'Modbus Gateway'], + [InstallMethod.GATEWAY_OPCUA, 'OPC-UA Gateway'], + [InstallMethod.CHIRPSTACK, 'ChirpStack'], + [InstallMethod.INTEGRATION_CHIRPSTACK, 'ChirpStack (PE)'], + [InstallMethod.INTEGRATION_TTN, 'The Things Stack'], + [InstallMethod.INTEGRATION_LORIOT, 'LORIOT'] + ] +); +``` + +Replace with (labels mirror the brief's table and the "Description" column of the marketplace contributor doc; gateway labels keep the existing "X Gateway" pattern; integration labels keep the brand name without a "(PE)" suffix because the dialog's PE-only panel already conveys that): +```typescript +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'] + ] +); +``` + +- [ ] **Step 2: Verify label count matches enum count** + +Run: +```bash +node -e " +const src = require('fs').readFileSync('/home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts', 'utf8'); +const enumMatches = (src.match(/^ [A-Z][A-Z_]* = '/gm) || []).length; +const labelMatches = (src.match(/\[InstallMethod\./g) || []).length; +console.log('enum:', enumMatches, 'labels:', labelMatches); +process.exit(enumMatches === 50 && labelMatches === 50 ? 0 : 1); +" +``` +Expected: `enum: 50 labels: 50` and exit code 0. + +- [ ] **Step 3: Commit** + +```bash +git add ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts +git commit -m "feat(iot-hub): add human-readable labels for all 50 install methods" +``` + +--- + +## Task 3: Add `peOnlyInstallMethods` set + +**Files:** +- Modify: `ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts` + +- [ ] **Step 1: Append the PE-only set after `installMethodLabels`** + +After the `installMethodLabels` declaration (and the closing `);`), insert a new exported set. Use the actual enum members so a typo causes a compile error: + +```typescript + +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 +]); +``` + +Note: this set has 29 entries (every `INTEGRATION_*`). `CHIRPSTACK` (no `INTEGRATION_` prefix) is NOT in this set — it's the CE-compatible LoRaWAN install method. + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: +```bash +cd /home/ashvayka/git/ce/ui-ngx && npx tsc --noEmit -p tsconfig.app.json 2>&1 | tail -10 +``` +Expected: no errors mentioning `device-package.models.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts +git commit -m "feat(iot-hub): add peOnlyInstallMethods set covering 29 PE-only integrations" +``` + +--- + +## Task 4: Add `peOnlySelected` state to install dialog component + +**Files:** +- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts` + +- [ ] **Step 1: Import `peOnlyInstallMethods`** + +Locate the existing import block (lines 36–47) that destructures from `@shared/models/iot-hub/device-package.models`: + +```typescript +import { + installMethodLabels as INSTALL_METHOD_LABELS, + DeviceInstallStep, + DevicePackageInfo, + ENTITY_STEP_TYPES, + EntityStepOutput, + EntityStepProgress, + FormFieldDefinition, + FormFieldType, + InstallStepType, + stepTypeAliasMap +} from '@shared/models/iot-hub/device-package.models'; +``` + +Replace the destructure list to add `peOnlyInstallMethods`: + +```typescript +import { + installMethodLabels as INSTALL_METHOD_LABELS, + peOnlyInstallMethods, + DeviceInstallStep, + DevicePackageInfo, + ENTITY_STEP_TYPES, + EntityStepOutput, + EntityStepProgress, + FormFieldDefinition, + FormFieldType, + InstallStepType, + stepTypeAliasMap +} from '@shared/models/iot-hub/device-package.models'; +``` + +- [ ] **Step 2: Add `peOnlySelected` view-state flag and getter** + +Locate the connectivity-state block in the class (around lines 92–96): + +```typescript + // Connectivity + showConnectivitySelector = false; + availableInstallMethods: string[] = []; + selectedInstallMethod: string | null = null; + installMethodLabels = INSTALL_METHOD_LABELS; +``` + +Replace with: + +```typescript + // Connectivity + showConnectivitySelector = false; + showPeOnlyPanel = false; + availableInstallMethods: string[] = []; + selectedInstallMethod: string | null = null; + installMethodLabels = INSTALL_METHOD_LABELS; + + get isSelectedPeOnly(): boolean { + return this.selectedInstallMethod !== null && peOnlyInstallMethods.has(this.selectedInstallMethod); + } +``` + +- [ ] **Step 3: Route auto-selected single install method to PE-only panel when applicable** + +Locate the `ngOnInit` branching block (around lines 172–178): + +```typescript + } else if (this.availableInstallMethods.length === 1) { + this.selectedInstallMethod = this.availableInstallMethods[0]; + this.showConnectivitySelector = false; + this.startWizard(); + } else { + this.showConnectivitySelector = true; + } +``` + +Replace with: + +```typescript + } else if (this.availableInstallMethods.length === 1) { + this.selectedInstallMethod = this.availableInstallMethods[0]; + this.showConnectivitySelector = false; + if (this.isSelectedPeOnly) { + this.showPeOnlyPanel = true; + } else { + this.startWizard(); + } + } else { + this.showConnectivitySelector = true; + } +``` + +- [ ] **Step 4: Route `confirmConnectivity()` to PE-only panel when user picks a PE-only method** + +Locate `confirmConnectivity()` at line 200: + +```typescript + confirmConnectivity(): void { + if (!this.selectedInstallMethod) { + return; + } + this.startWizard(); + } +``` + +Replace with: + +```typescript + confirmConnectivity(): void { + if (!this.selectedInstallMethod) { + return; + } + if (this.isSelectedPeOnly) { + this.showPeOnlyPanel = true; + this.cdr.detectChanges(); + return; + } + this.startWizard(); + } +``` + +The connectivity selector branch is already hidden once `showPeOnlyPanel` flips true — see the template change in Task 5 — so no separate `showConnectivitySelector` flip is needed. + +- [ ] **Step 5: Verify build** + +Run: +```bash +cd /home/ashvayka/git/ce/ui-ngx && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E 'device-install-dialog|device-package' | head -10 +``` +Expected: no output (file compiles). + +- [ ] **Step 6: Commit** + +```bash +git add ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts +git commit -m "feat(iot-hub): gate PE-only install methods with dedicated panel state" +``` + +--- + +## Task 5: Render the PE-only panel in the install dialog template + +**Files:** +- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html` + +- [ ] **Step 1: Insert the PE-only panel after the connectivity selector block** + +Locate the connectivity selector block (lines 213–238 — starts with `@if (!wizardStarted) {` and contains ``). The structure is: + +```html + @if (!wizardStarted) { + +
+ ... +
+ + ... + + + } @else if (reviewMode) { +``` + +The `@if (!wizardStarted)` branch must be tightened so the connectivity selector only renders when `!showPeOnlyPanel`, and a new branch must render the PE-only panel. + +Replace the `@if (!wizardStarted) { ... }` outer block (the entire connectivity-selector branch including the `` content and the closing ``) with: + +```html + @if (!wizardStarted && !showPeOnlyPanel) { + +
+
+

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

+
+ @for (ct of availableInstallMethods; track ct) { + + } +
+
+
+ + + + + + } @else if (showPeOnlyPanel) { + +
+
+ workspace_premium +

+ {{ 'iot-hub.device-install-pe-only-title' | translate }} +

+

+ {{ 'iot-hub.device-install-pe-only-message' | translate:{ method: installMethodLabels.get(selectedInstallMethod) || selectedInstallMethod } }} +

+ + {{ 'iot-hub.device-install-pe-only-learn-more' | translate }} + +
+
+ + + + + } @else if (reviewMode) { +``` + +(The `@else if (reviewMode) {` line was already there — just leave it as the start of the next branch.) + +- [ ] **Step 2: Verify Angular template compiles** + +Run: +```bash +cd /home/ashvayka/git/ce/ui-ngx && npx ng build --configuration=production 2>&1 | tail -8 +``` +Expected: ends with `Application bundle generation complete.` and no template errors. + +- [ ] **Step 3: Commit** + +```bash +git add ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html +git commit -m "feat(iot-hub): render PE-only panel when user selects a PE-only install method" +``` + +--- + +## Task 6: Add translation keys for the PE-only panel + +**Files:** +- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` + +- [ ] **Step 1: Locate the `iot-hub` section's `device-install-*` keys** + +Run: +```bash +grep -n 'device-install-select-connectivity\|device-install-title' /home/ashvayka/git/ce/ui-ngx/src/assets/locale/locale.constant-en_US.json +``` +This identifies the lines where the existing `device-install-*` keys live so the new keys can be added alongside them. + +- [ ] **Step 2: Add the three new keys** + +Add these three key/value pairs immediately after the existing `device-install-select-connectivity` key, preserving the surrounding JSON structure (commas, indentation): + +```json +"device-install-pe-only-title": "ThingsBoard PE required", +"device-install-pe-only-message": "The {{method}} install method requires ThingsBoard Professional Edition. Upgrade your installation to use this device package.", +"device-install-pe-only-learn-more": "Learn about ThingsBoard PE", +``` + +`{{method}}` is the MessageFormat parameter passed from the template (`installMethodLabels.get(selectedInstallMethod)`). + +- [ ] **Step 3: Verify JSON is valid** + +Run: +```bash +node -e "JSON.parse(require('fs').readFileSync('/home/ashvayka/git/ce/ui-ngx/src/assets/locale/locale.constant-en_US.json','utf8')); console.log('OK');" +``` +Expected: `OK`. + +- [ ] **Step 4: Commit** + +```bash +git add ui-ngx/src/assets/locale/locale.constant-en_US.json +git commit -m "feat(iot-hub): add translation keys for PE-only install panel" +``` + +--- + +## Task 7: Manual smoke verification + +**Files:** none (verification only) + +- [ ] **Step 1: Production build sanity check** + +Run: +```bash +cd /home/ashvayka/git/ce/ui-ngx && npx ng build --configuration=production 2>&1 | tail -3 +``` +Expected: ends with `Application bundle generation complete.` + +- [ ] **Step 2: Verify CE enum is a strict superset of the previous 12-value enum** + +Run: +```bash +for c in DIRECT_HTTP DIRECT_MQTT DIRECT_COAP DIRECT_LWM2M DIRECT_SNMP \ + GATEWAY_MQTT GATEWAY_MODBUS GATEWAY_OPCUA \ + CHIRPSTACK INTEGRATION_CHIRPSTACK INTEGRATION_TTN INTEGRATION_LORIOT; do + grep -q "${c} = '${c}'" /home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts \ + && echo "OK: $c" || echo "MISSING: $c" +done +``` +Expected: 12 lines starting with `OK:` and zero `MISSING:` lines. + +- [ ] **Step 3: Verify all 50 marketplace constants are present** + +Run: +```bash +for c in DIRECT_HTTP DIRECT_MQTT DIRECT_COAP DIRECT_LWM2M DIRECT_SNMP \ + GATEWAY_MQTT GATEWAY_MODBUS GATEWAY_OPCUA \ + GATEWAY_BACNET GATEWAY_BLE GATEWAY_CAN GATEWAY_FTP GATEWAY_KNX \ + GATEWAY_OCPP GATEWAY_ODBC GATEWAY_REQUEST GATEWAY_REST \ + GATEWAY_SNMP GATEWAY_SOCKET GATEWAY_XMPP \ + CHIRPSTACK \ + INTEGRATION_APACHE_PULSAR INTEGRATION_AWS_IOT INTEGRATION_AWS_KINESIS \ + INTEGRATION_AWS_SQS INTEGRATION_AZURE_EVENT_HUB INTEGRATION_AZURE_IOT_HUB \ + INTEGRATION_AZURE_SERVICE_BUS INTEGRATION_CHIRPSTACK INTEGRATION_COAP \ + INTEGRATION_CUSTOM INTEGRATION_HTTP INTEGRATION_IOT_CREATORS \ + INTEGRATION_KAFKA INTEGRATION_KPN_THINGS INTEGRATION_LORIOT \ + INTEGRATION_MQTT INTEGRATION_OPC_UA INTEGRATION_PARTICLE \ + INTEGRATION_PUB_SUB INTEGRATION_RABBITMQ INTEGRATION_REMOTE \ + INTEGRATION_SIGFOX INTEGRATION_TCP INTEGRATION_THINGPARK \ + INTEGRATION_THINGPARK_ENTERPRISE INTEGRATION_TTI INTEGRATION_TTN \ + INTEGRATION_TUYA INTEGRATION_UDP; do + grep -q "${c} = '${c}'" /home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts \ + && grep -q "InstallMethod.${c}, '" /home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts \ + || echo "INCOMPLETE: $c" +done +``` +Expected: no output (every constant has both an enum entry and a label). + +- [ ] **Step 4: Browser smoke test (manual)** + +1. Start dev server: `cd /home/ashvayka/git/ce/ui-ngx && npm start` +2. Log in as a TENANT_ADMIN. Open IoT Hub → Browse. +3. Pick any device package that lists multiple install methods. Open install dialog. +4. **Direct/Gateway/CHIRPSTACK methods:** label renders correctly (no raw constant), Next button starts the wizard normally — no regression. +5. **PE-only method (e.g., `INTEGRATION_AWS_IOT` if the package supports it; otherwise temporarily edit any local test package's `device-info.json` to add a PE-only method):** Next button shows the PE-only panel, the title says "ThingsBoard PE required", the body interpolates the method label (e.g., "The AWS IoT install method requires…"), the "Learn about ThingsBoard PE" link opens `thingsboard.io/products/thingsboard-pe/` in a new tab, the Close button dismisses the dialog. +6. **Auto-selected single PE-only method:** if a package lists only one method and it's PE-only, the dialog should open directly on the PE-only panel (no connectivity selector). + +If any check fails, mark this task incomplete and address before merging. + +--- + +## Out of scope + +- **No backend allow-list to update.** CE's `DeviceInstalledItemDescriptor.selectedInstallMethod` is a free-form `String` field; the marketplace validator at `ItemDataServiceImpl.VALID_INSTALL_METHODS` is the single source of truth. +- **No new install behavior for the 12 new gateway connectors.** The existing `GATEWAY_CONNECTOR` step (`device-install-dialog.component.ts:761`) writes any connector config object to the gateway's `active_connectors` shared attribute regardless of connector type. Connector-specific schemas (BACnet, BLE, KNX, etc.) are creator concerns, not wizard concerns — the creator's connector JSON config is opaque to the wizard. +- **No connector-specific `${gateway.*}` variable additions.** Existing gateway outputs (`${gateway.id}`, `${gateway.name}`, `${gateway.token}`, `${gateway.dockerComposeUrl}`) are sufficient. Per-connector values like ports or hostnames are already supplied via `SHOW_FORM` fields and resolved through the standard variable mechanism. +- **No CE PE→install conversion.** PE integrations stay PE-only; CE shows the gate. +- **No automated unit test parity.** `ui-ngx` has no spec runner configured (no `*.spec.ts` files outside `node_modules`). The bash verifications in Task 7 act as the parity check. diff --git a/docs/superpowers/plans/2026-05-06-iot-hub-item-link.md b/docs/superpowers/plans/2026-05-06-iot-hub-item-link.md new file mode 100644 index 0000000000..b3fb150346 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-iot-hub-item-link.md @@ -0,0 +1,974 @@ +# IoT Hub `${item-link:uuid}` Markdown Component — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `${item-link:}` markdown placeholder that renders an IoT Hub marketplace item card (thumbnail + name + creator) inline in readmes and install instructions, opening the linked item in a new tab. + +**Architecture:** A pure-string utility rewrites `${item-link:}` to a `` Angular component tag before the markdown reaches `tb-markdown`. The component is declared in a small `IotHubItemLinkModule` that each `tb-markdown` instance receives as `additionalCompileModules`. The component owns its own fetch (via `IotHubApiService.getPublishedVersion`), state (`loading` / `loaded` / `unavailable`), and click target (`/iot-hub/{itemId}` with `target="_blank"`). + +**Tech Stack:** Angular 20 (TypeScript, Material), `tb-markdown` (NgModule-based dynamic compilation), `IotHubApiService` (existing), Tailwind for utility classes, component-scoped SCSS. + +**Spec:** `docs/superpowers/specs/2026-05-06-iot-hub-item-link.md` + +**File structure:** + +New files: +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts` — `replaceItemLinkPlaceholders` + regex +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts` + +Modified files: +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` — import `IotHubItemLinkModule` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` — call utility in `loadReadme()`, expose compile modules +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` — bind `[additionalCompileModules]` on readme `` +- `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts` — add `^item-link:(uuid)$` branch in `resolveVariables()`, expose compile modules +- `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html` — bind `[additionalCompileModules]` +- `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts` — pre-process `details`, expose compile modules +- `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html` — bind `[additionalCompileModules]` +- `ui-ngx/src/assets/locale/locale.constant-en_US.json` — add `iot-hub.item-link-unavailable` + +--- + +## Task 1: Create placeholder utility + +**Files:** +- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts` + +- [ ] **Step 1: Create the utility file** + +Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts`: + +```typescript +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 const ITEM_LINK_KEY_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})$/; + +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)); +} +``` + +- [ ] **Step 2: Sanity-check the regex** + +Write a temporary script `/tmp/check-item-link-regex.js` with this exact content: + +```javascript +const re = /\$\{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; +const cases = [ + ['${item-link:11111111-2222-3333-4444-555555555555}', 'valid'], + ['${item-link:not-a-uuid}', 'invalid'], + ['Two: ${item-link:11111111-2222-3333-4444-555555555555} and ${item-link:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee}', 'two valid'], + ['```\n${item-link:11111111-2222-3333-4444-555555555555}\n```', 'inside code fence (still replaced — documented behavior)'] +]; +for (const [input, label] of cases) { + console.log(label + ':', input.replace(re, (_m, u) => '')); +} +``` + +Run it: +```bash +node /tmp/check-item-link-regex.js +``` + +Expected output: +``` +valid: +invalid: ${item-link:not-a-uuid} +two valid: Two: and +inside code fence (still replaced — documented behavior): ``` + +``` +``` + +Then delete the script: `rm /tmp/check-item-link-regex.js`. + +- [ ] **Step 3: Commit** + +```bash +git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts +git commit -m "feat(iot-hub): add item-link placeholder utility for markdown" +``` + +--- + +## Task 2: Create item-link card component (skeleton + loaded states, image-thumbnail items) + +**Files:** +- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts` +- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html` +- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss` + +- [ ] **Step 1: Create the directory** + +```bash +mkdir -p ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card +``` + +- [ ] **Step 2: Create `iot-hub-item-link-card.component.ts`** + +```typescript +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { 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 { + return this.item?.image + ? this.iotHubApiService.resolveResourceUrl(this.item.image) + : null; + } + + getCompactIcon(): string { + if (!this.item) { + return 'category'; + } + if (this.item.icon) { + return this.item.icon; + } + switch (this.item.type) { + case ItemType.CALCULATED_FIELD: return 'functions'; + case ItemType.ALARM_RULE: return 'notification_important'; + case ItemType.RULE_CHAIN: return 'account_tree'; + default: return 'category'; + } + } + + getTypeIcon(): string { + if (!this.item) { + return 'category'; + } + switch (this.item.type) { + case ItemType.WIDGET: return 'widgets'; + case ItemType.DASHBOARD: return 'dashboard'; + case ItemType.SOLUTION_TEMPLATE: return 'integration_instructions'; + case ItemType.CALCULATED_FIELD: return 'functions'; + case ItemType.ALARM_RULE: return 'notification_important'; + case ItemType.RULE_CHAIN: return 'account_tree'; + case ItemType.DEVICE: return 'memory'; + default: return 'category'; + } + } + + getCompactColor(): string { + return this.item?.color || '#048ad3'; + } + + getHref(): string { + return `/iot-hub/${this.itemId}`; + } +} +``` + +- [ ] **Step 3: Create `iot-hub-item-link-card.component.html`** + +```html + +@switch (state) { + @case ('loading') { + + } + @case ('loaded') { + + @if (isCompact()) { + + } @else { + + } + + + } + @case ('unavailable') { + + } +} +``` + +- [ ] **Step 4: Create `iot-hub-item-link-card.component.scss`** + +```scss +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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: 320px; + 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); + } + + &--unavailable { + opacity: 0.6; + cursor: default; + } + + &--skeleton { + cursor: default; + } +} + +.tb-iot-hub-item-link-thumb { + flex: 0 0 48px; + width: 48px; + height: 48px; + border-radius: 6px; + background: #f4f6f8; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + + &--compact { + color: #fff; + } + + &--unavailable { + color: rgba(0, 0, 0, 0.45); + } +} + +.tb-iot-hub-item-link-thumb-icon { + font-size: 26px; + width: 26px; + height: 26px; + line-height: 26px; + color: #fff; +} + +.tb-iot-hub-item-link-thumb-fallback { + font-size: 26px; + width: 26px; + height: 26px; + 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; + } +} + +.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; } +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card +git commit -m "feat(iot-hub): add TbIotHubItemLinkCardComponent for markdown item links" +``` + +--- + +## Task 3: Create the wrapper module and register it + +**Files:** +- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts` +- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` + +- [ ] **Step 1: Create `iot-hub-item-link.module.ts`** + +```typescript +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 {} +``` + +- [ ] **Step 2: Register module in `IotHubComponentsModule`** + +Modify `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` — add the import and add `IotHubItemLinkModule` to the `imports` array. + +After the existing imports near line 32, add: + +```typescript +import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module'; +``` + +Then update the `imports` array of the `@NgModule` decorator from: + +```typescript + imports: [ + CommonModule, + SharedModule + ], +``` + +to: + +```typescript + imports: [ + CommonModule, + SharedModule, + IotHubItemLinkModule + ], +``` + +- [ ] **Step 3: Build to verify the module wires up** + +```bash +cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -40 +``` + +Expected: build succeeds (warnings allowed; no errors mentioning `TbIotHubItemLinkCardComponent` or `IotHubItemLinkModule`). + +- [ ] **Step 4: Commit** + +```bash +git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts \ + ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts +git commit -m "feat(iot-hub): wrap item-link card in IotHubItemLinkModule" +``` + +--- + +## Task 4: Add translation key + +**Files:** +- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` + +- [ ] **Step 1: Add the translation key** + +Open `ui-ngx/src/assets/locale/locale.constant-en_US.json`. Locate the `"iot-hub": { ... }` block that begins around line 3701 (the one that opens with `"iot-hub": "IoT Hub",`). Add a new entry next to similar one-liner keys (e.g., right after `"installed-from-iot-hub": "Installed from IoT Hub",` near line 3719): + +```json + "item-link-unavailable": "Item unavailable", +``` + +Make sure the surrounding commas are correct — the new line ends with a comma, and the line above also ends with a comma. + +- [ ] **Step 2: Verify JSON is valid** + +```bash +node -e "JSON.parse(require('fs').readFileSync('ui-ngx/src/assets/locale/locale.constant-en_US.json', 'utf8')); console.log('OK');" +``` +Expected output: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add ui-ngx/src/assets/locale/locale.constant-en_US.json +git commit -m "feat(iot-hub): add item-link-unavailable translation key" +``` + +--- + +## Task 5: Wire into item detail dialog (readme) + +**Files:** +- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` +- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` + +- [ ] **Step 1: Update the .ts file to pre-process readme and expose compile modules** + +In `iot-hub-item-detail-dialog.component.ts`: + +Add to the imports near the existing `resolveDocLinkPlaceholders` import: + +```typescript +import { replaceItemLinkPlaceholders } from './iot-hub-markdown.utils'; +import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module'; +import { Type } from '@angular/core'; +``` + +(`Type` may already be imported via Angular core; merge into the existing `@angular/core` import if so.) + +Inside the class, near the other readonly fields (after `readonly ItemType = ItemType;`), add: + +```typescript + readonly itemLinkCompileModules: Type[] = [IotHubItemLinkModule]; +``` + +Update the existing `loadReadme()` method body (currently at line 307-312): + +```typescript + private loadReadme(): void { + const versionId = this.item.id as string; + this.iotHubApiService.getVersionReadme(versionId, { ignoreLoading: true }).subscribe( + content => this.readmeContent = replaceItemLinkPlaceholders( + this.resolveDocLinks(this.prefixResourceUrls(content || '')) + ) + ); + } +``` + +- [ ] **Step 2: Update the template to pass compile modules** + +In `iot-hub-item-detail-dialog.component.html` at line 302, change: + +```html + +``` + +to: + +```html + +``` + +Leave the changelog `` (line 306) unchanged — changelog is out of scope per the spec. + +- [ ] **Step 3: Build** + +```bash +cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -20 +``` + +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts \ + ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html +git commit -m "feat(iot-hub): render \${item-link} cards in item readme" +``` + +--- + +## Task 6: Wire into device install dialog (instructions) + +**Files:** +- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts` +- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html` + +- [ ] **Step 1: Add imports and compile-modules field** + +In `device-install-dialog.component.ts`, add to the imports: + +```typescript +import { IotHubItemLinkModule } from '../iot-hub-item-link-card/iot-hub-item-link.module'; +import { ITEM_LINK_KEY_REGEX, itemLinkCardTag } from '../iot-hub-markdown.utils'; +import { Type } from '@angular/core'; +``` + +(Merge `Type` into the existing `@angular/core` import line.) + +Inside the class, alongside other readonly fields, add: + +```typescript + readonly itemLinkCompileModules: Type[] = [IotHubItemLinkModule]; +``` + +- [ ] **Step 2: Add `^item-link:(uuid)$` branch inside `resolveVariables`** + +In `resolveVariables()` (currently at line 427-477 in the same file), add a new clause **immediately after** the `gateway.downloadButton` clause (around line 444-446) and before the callout matcher. + +Change this section: + +```typescript + // Special action placeholders + if (key === 'gateway.downloadButton') { + return '⬇ Download docker-compose.yml'; + } + // Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} +``` + +to: + +```typescript + // Special action placeholders + if (key === 'gateway.downloadButton') { + return '⬇ Download docker-compose.yml'; + } + // IoT Hub item link card: ${item-link:} + const itemLinkMatch = key.match(ITEM_LINK_KEY_REGEX); + if (itemLinkMatch) { + return itemLinkCardTag(itemLinkMatch[1]); + } + // Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} +``` + +- [ ] **Step 3: Update the template** + +In `device-install-dialog.component.html` at line 26-28, change: + +```html + + +``` + +to: + +```html + + +``` + +- [ ] **Step 4: Build** + +```bash +cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -20 +``` + +Expected: build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts \ + ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html +git commit -m "feat(iot-hub): render \${item-link} cards in device install instructions" +``` + +--- + +## Task 7: Wire into solution install dialog (instructions) + +**Files:** +- Modify: `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts` +- Modify: `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html` + +- [ ] **Step 1: Update the .ts file** + +Replace the contents of `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts` with: + +```typescript +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { + IotHubItemLinkModule +} from '@home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module'; +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; + + readonly itemLinkCompileModules: Type[] = [IotHubItemLinkModule]; + + 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(); + } +} +``` + +- [ ] **Step 2: Update the template** + +In `solution-install-dialog.component.html` at line 29, change: + +```html + +``` + +to: + +```html + +``` + +- [ ] **Step 3: Build** + +```bash +cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -20 +``` + +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts \ + ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html +git commit -m "feat(iot-hub): render \${item-link} cards in solution install instructions" +``` + +--- + +## Task 8: Manual visual QA + +**No file changes — verify the feature end-to-end in a browser.** + +- [ ] **Step 1: Start the dev server** + +```bash +cd ui-ngx && npm start +``` + +Wait for `Compiled successfully` and `Application bundle generation complete`. + +Open `http://localhost:4200` in a browser and log in as a tenant administrator. + +- [ ] **Step 2: Verify item readme rendering** + +In a separate terminal, query the configured IoT Hub for an item ID you can use as a test target: + +```bash +# Use the same baseUrl that your TB instance uses (typically https://iot-hub.thingsboard.io). +# Pick any published item you can find via the marketplace UI's network requests in DevTools, +# e.g. by opening the IoT Hub home page and copying an itemId from a response. +echo "Pick a known itemId from network responses in the IoT Hub UI" +``` + +Then, for the TEST PASS: +1. Open IoT Hub in the running app, find an item with a non-empty readme (e.g., a Solution Template), open the detail dialog. +2. In DevTools, intercept the readme response by editing it (Network → block + replay, or temporarily edit `readmeContent` via the Angular DevTools), and inject `${item-link:}` somewhere in the readme markdown. + - Easier alternative: temporarily hardcode a test placeholder in `loadReadme()` by appending `'\n\n${item-link:}\n'` to the fetched content, just for this QA pass. Revert before committing. +3. Reload the dialog. Expected: + - Skeleton card appears briefly with shimmer. + - Card resolves to: 48-px square thumbnail (image or colored compact icon), item name, person icon + creator name. + - Hover shows light blue tint and subtle shadow. + - Click opens `/iot-hub/` in a **new tab**, which lands on the item type page with the detail dialog open. + - Middle-click also opens new tab; ctrl-click does the same. + +Revert any hardcoded test data before continuing. + +- [ ] **Step 3: Verify unavailable state** + +Repeat Step 2 but inject a placeholder with a UUID that does not exist (e.g., `${item-link:00000000-0000-0000-0000-000000000000}`). Expected: +- Card briefly shows skeleton. +- Resolves to disabled card: dim opacity, `link_off` icon in the thumbnail slot, "Item unavailable" label. +- Card is not clickable (no ``, no hover blue). + +- [ ] **Step 4: Verify invalid placeholder is left untouched** + +Inject `${item-link:not-a-uuid}` into the readme. Expected: the rendered markdown shows the literal text `${item-link:not-a-uuid}` (no card, no error). This proves the regex is strict. + +- [ ] **Step 5: Verify device install instructions** + +Open any IoT Hub Device item, click Install, and walk to a step whose markdown is dynamically rendered. Inject `${item-link:}` into one of the markdown templates served by the device package (or temporarily prepend it to `step.markdown` in `device-install-dialog.component.ts` near the existing `resolveVariables` call). Expected: same skeleton → loaded card behavior; click opens `/iot-hub/` in a new tab. + +Revert temporary edits. + +- [ ] **Step 6: Verify solution install instructions** + +Install (or reopen the install instructions for) a Solution Template that has a `details` markdown. Inject `${item-link:}` into `details` (temporarily, in the constructor or via a known solution template whose details you control). Expected: same skeleton → loaded behavior; click opens `/iot-hub/` in a new tab. + +Revert temporary edits. + +- [ ] **Step 7: Confirm no regressions in existing markdown** + +In each of the three render sites, verify after the QA edits are reverted: +- Existing `${gateway.downloadButton}` placeholder still renders correctly in the device install instructions (unchanged behavior). +- Existing callout boxes (`${note(...)}`, `${warn(...)}`, `${error(...)}`) still render in the device install instructions. +- Existing `prefixResourceUrls` and `resolveDocLinks` continue to resolve image URLs and doc-link placeholders in readmes. +- Changelog tab in the item detail dialog still renders without an item-link card (it does not bind `additionalCompileModules`). + +- [ ] **Step 8: Stop the dev server** + +`Ctrl-C` in the terminal running `npm start`. + +- [ ] **Step 9: Final commit (if any QA-driven fix-ups were made)** + +If QA surfaced any issues that required code changes, commit them with a focused message. Otherwise this step is a no-op. + +```bash +git status +# If clean, no commit needed. +``` + +--- + +## Self-review checklist (for the implementing agent) + +Before declaring complete, verify: + +1. **Spec coverage** — every decision in `docs/superpowers/specs/2026-05-06-iot-hub-item-link.md` has a corresponding task above. +2. **No leftover test scaffolding** — temporary hardcoded `${item-link:...}` placeholders used for QA are reverted. +3. **License headers** — every new `.ts` file uses `///` style, every new `.html` uses ``, every new `.scss` uses `/** */`. +4. **Build clean** — `npx ng build --configuration=development` finishes without errors. +5. **Three sites work, fourth doesn't** — readme, device install, solution install all render the card; changelog tab does **not** (by design — out of scope per spec). diff --git a/docs/superpowers/specs/2026-04-02-gateway-support-design.md b/docs/superpowers/specs/2026-04-02-gateway-support-design.md new file mode 100644 index 0000000000..db94c4b1af --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-gateway-support-design.md @@ -0,0 +1,267 @@ +# Gateway Support in Device Install Framework — Design Spec + +## Goal + +Extend the device install wizard to support gateway provisioning: creating a gateway device, configuring one or more connectors via shared attributes, and providing the gateway launch command (docker-compose download URL) in post-install instructions. + +## New Step Types + +### GATEWAY + +Creates a gateway device — a device with `additionalInfo: { gateway: true }`. + +- **Template:** device JSON (same format as DEVICE step), must include `"additionalInfo": {"gateway": true}` +- **After creation:** fetches device credentials (same as DEVICE) +- **Find-or-create:** no — always creates new (same as DEVICE) +- **Output variables:** + - `${gateway.id}` — device UUID + - `${gateway.name}` — device name + - `${gateway.token}` — access token + - `${gateway.dockerComposeUrl}` — `/api/device-connectivity/gateway-launch/${gateway.id}/docker-compose/download` + +### GATEWAY_CONNECTOR + +Configures a connector on a previously created gateway by saving connector config as shared attributes. + +- **Template:** connector config JSON — the object with `name`, `type`, `configurationJson`, `logLevel`, etc. +- **Behavior:** + 1. Fetch current `active_connectors` shared attribute from the gateway (may be empty/missing — default to `[]`) + 2. Append connector name to the array + 3. Save updated `active_connectors` as shared attribute on the gateway + 4. Save `{connectorName}: connectorConfig` as shared attribute on the gateway +- **Target entity:** uses `${gateway.id}` from the preceding GATEWAY step +- **Output variables:** `${gatewayConnector.name}` — the connector name from the template +- **Multiple steps:** each GATEWAY_CONNECTOR step appends to `active_connectors`. Two connectors → two steps → `active_connectors = ["Modbus Connector", "MQTT Connector"]` + +## Extension: Optional Attributes on Entity Steps + +All entity creation steps (DEVICE, GATEWAY, DEVICE_PROFILE, DASHBOARD, RULE_CHAIN) gain two optional fields in the step definition: + +```json +{ + "type": "GATEWAY", + "name": "${deviceName}", + "template": "gateway.json", + "serverAttributes": "server-attributes.json", + "sharedAttributes": "shared-attributes.json" +} +``` + +- `serverAttributes` — optional, path to JSON file in ZIP. After entity creation, file is read, variables resolved, and saved as `SERVER_SCOPE` attributes on the created entity. +- `sharedAttributes` — optional, path to JSON file in ZIP. Same, saved as `SHARED_SCOPE`. + +Both files contain a flat JSON object of key-value pairs: +```json +{ + "firmwareVersion": "1.2.3", + "configUrl": "${http.host}:${http.port}/config" +} +``` + +## Changes to Existing Models + +### DeviceInstallStep interface (device-package.models.ts) + +Add fields: +```typescript +export interface DeviceInstallStep { + type: InstallStepType; + name: string; + file?: string; + template?: string; + serverAttributes?: string; // NEW + sharedAttributes?: string; // NEW +} +``` + +### InstallStepType enum + +Add: +```typescript +GATEWAY = 'GATEWAY', +GATEWAY_CONNECTOR = 'GATEWAY_CONNECTOR' +``` + +### ENTITY_STEP_TYPES set + +Add `GATEWAY` and `GATEWAY_CONNECTOR`. + +### stepTypeAliasMap + +Add: +```typescript +GATEWAY: 'gateway', +GATEWAY_CONNECTOR: 'gatewayConnector' +``` + +## Variable Resolution Updates + +### New named entity outputs + +| Step type | Variables | +|-----------|-----------| +| GATEWAY | `${gateway.id}`, `${gateway.name}`, `${gateway.token}`, `${gateway.dockerComposeUrl}` | +| GATEWAY_CONNECTOR | `${gatewayConnector.name}` | + +### EntityStepOutput interface + +Add optional `dockerComposeUrl` field: +```typescript +export interface EntityStepOutput { + id: string; + name: string; + token?: string; + dockerComposeUrl?: string; // NEW +} +``` + +## Frontend Implementation (createEntity) + +### GATEWAY case + +Same as DEVICE: +1. Save device via `deviceService.saveDevice(template, {ignoreErrors: true})` +2. Fetch credentials via `deviceService.getDeviceCredentials(id)` +3. Return output with `dockerComposeUrl` computed from the device ID + +```typescript +case InstallStepType.GATEWAY: { + const result = await firstValueFrom(this.deviceService.saveDevice(template, {ignoreErrors: true})); + const creds = await firstValueFrom(this.deviceService.getDeviceCredentials(result.id.id, false, {ignoreErrors: true})); + return { + id: result.id.id, + name: result.name, + token: creds.credentialsId, + dockerComposeUrl: `/api/device-connectivity/gateway-launch/${result.id.id}/docker-compose/download` + }; +} +``` + +### GATEWAY_CONNECTOR case + +1. Read and resolve the connector template from ZIP +2. Extract `name` from the connector config +3. Fetch current `active_connectors` from gateway's shared attributes (or default to `[]`) +4. Append connector name +5. Save both attributes to gateway via `attributeService.saveEntityAttributes()` + +```typescript +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 }; + + // Fetch current active_connectors + const attrs = await firstValueFrom(this.attributeService.getEntityAttributes( + gatewayEntityId, AttributeScope.SHARED_SCOPE, ['active_connectors'], {ignoreErrors: true} + )); + const activeConnectors: string[] = attrs.find(a => a.key === 'active_connectors')?.value || []; + + // Add this connector + const connectorName = template.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 }; +} +``` + +### Attribute saving after any entity step + +After `createEntity()` returns, check if the step has `serverAttributes` or `sharedAttributes`. If so, read the file, resolve variables, and save: + +```typescript +if (step.serverAttributes) { + const attrsJson = JSON.parse(this.resolveVariables(this.zipFiles.get(step.serverAttributes))); + const attrs = Object.entries(attrsJson).map(([key, value]) => ({ key, value })); + await firstValueFrom(this.attributeService.saveEntityAttributes(entityId, AttributeScope.SERVER_SCOPE, attrs, {ignoreErrors: true})); +} +if (step.sharedAttributes) { + const attrsJson = JSON.parse(this.resolveVariables(this.zipFiles.get(step.sharedAttributes))); + const attrs = Object.entries(attrsJson).map(([key, value]) => ({ key, value })); + await firstValueFrom(this.attributeService.saveEntityAttributes(entityId, AttributeScope.SHARED_SCOPE, attrs, {ignoreErrors: true})); +} +``` + +## Translation Keys + +Add: +``` +"iot-hub.device-install-step-type-GATEWAY": "Gateway", +"iot-hub.device-install-step-type-GATEWAY_CONNECTOR": "Gateway Connector" +``` + +## Example Gateway Package + +``` +modbus-sensor.zip/ +├── device-info.json +├── prerequisites.md +├── form.json +├── gateway.json +├── modbus-connector.json +├── dashboard.json +└── post-install.md +``` + +`device-info.json`: +```json +{ + "name": "Modbus Sensor", + "vendor": "Example", + "hardwareType": "SENSOR", + "connectivityTypes": ["GATEWAY_MODBUS"], + "installSteps": { + "GATEWAY_MODBUS": [ + {"type": "SHOW_INSTRUCTION", "name": "Prerequisites", "file": "prerequisites.md"}, + {"type": "SHOW_FORM", "name": "Configuration", "file": "form.json"}, + {"type": "GATEWAY", "name": "${deviceName} Gateway", "template": "gateway.json"}, + {"type": "GATEWAY_CONNECTOR", "name": "Modbus Connector", "template": "modbus-connector.json"}, + {"type": "DASHBOARD", "name": "Modbus Monitor", "template": "dashboard.json"}, + {"type": "SHOW_INSTRUCTION", "name": "Launch Gateway", "file": "post-install.md"} + ] + } +} +``` + +`gateway.json`: +```json +{ + "name": "${deviceName} Gateway", + "type": "Gateway", + "additionalInfo": {"gateway": true} +} +``` + +`post-install.md`: +```markdown +## Launch Gateway + +1. [Download docker-compose.yml](${gateway.dockerComposeUrl}) +2. Place the file in a directory and run: + +\`\`\`bash +docker compose up +\`\`\` + +The gateway will connect to ThingsBoard at `${mqtt.host}:${mqtt.port}` using access token `${gateway.token}`. +``` + +## No Backend Changes + +All new logic is frontend-only: +- GATEWAY uses existing `saveDevice` API (same as DEVICE) +- GATEWAY_CONNECTOR uses existing `saveEntityAttributes` API +- Attribute saving uses existing `saveEntityAttributes` API +- `dockerComposeUrl` is a constructed URL string, not a new endpoint diff --git a/docs/superpowers/specs/2026-04-02-transport-variables-design.md b/docs/superpowers/specs/2026-04-02-transport-variables-design.md new file mode 100644 index 0000000000..e1cfe4bbc4 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-transport-variables-design.md @@ -0,0 +1,72 @@ +# Transport Variables in Device Install Templates — Design Spec + +## Goal + +Add transport host/port variables to the device install wizard's template resolution so that post-install instructions and entity templates can reference the platform's configured transport endpoints (e.g., `${mqtt.host}`, `${coap.port}`). + +## Variables + +All 6 transport protocols are supported: + +| Variable | Example value | Source | +|----------|--------------|--------| +| `${http.host}` | `demo.thingsboard.io` | Admin settings `connectivity.http.host` | +| `${http.port}` | `8080` | Admin settings `connectivity.http.port` | +| `${https.host}` | `demo.thingsboard.io` | Admin settings `connectivity.https.host` | +| `${https.port}` | `443` | Admin settings `connectivity.https.port` | +| `${mqtt.host}` | `demo.thingsboard.io` | Admin settings `connectivity.mqtt.host` | +| `${mqtt.port}` | `1883` | Admin settings `connectivity.mqtt.port` | +| `${mqtts.host}` | `demo.thingsboard.io` | Admin settings `connectivity.mqtts.host` | +| `${mqtts.port}` | `8883` | Admin settings `connectivity.mqtts.port` | +| `${coap.host}` | `demo.thingsboard.io` | Admin settings `connectivity.coap.host` | +| `${coap.port}` | `5683` | Admin settings `connectivity.coap.port` | +| `${coaps.host}` | `demo.thingsboard.io` | Admin settings `connectivity.coaps.host` | +| `${coaps.port}` | `5684` | Admin settings `connectivity.coaps.port` | + +These variables are available in all template and instruction files, alongside form values and entity outputs. + +## Data Source + +Frontend fetches from existing admin settings API: +``` +GET /api/admin/settings/connectivity +``` + +Returns `DeviceConnectivitySettings` — a `Record` where each entry has `{ enabled, host, port }`. + +The `AdminService.getAdminSettings('connectivity')` method and `DeviceConnectivitySettings` model already exist in the codebase. + +## Resolution Priority + +The `resolveVariables()` function checks sources in this order: +1. Form field values (`formValues[key]`) — e.g., `${deviceName}` +2. Transport connectivity (`transportVars[key]`) — e.g., `${mqtt.host}` +3. Named entity outputs (`entityOutputs[alias].prop`) — e.g., `${device.id}` + +Transport variables use dot notation (`mqtt.host`) which currently falls through to entity output resolution. By adding a transport lookup before entity outputs, we ensure `${mqtt.host}` resolves to the transport config rather than looking for a non-existent entity alias called `mqtt`. + +## Changes + +**Single file:** `ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.ts` + +1. Inject `AdminService` (from `@core/http/admin.service`) +2. In `ngOnInit`, after ZIP parsing, fetch connectivity settings and flatten to `Record`: + ``` + { 'http.host': '...', 'http.port': '8080', 'mqtt.host': '...', 'mqtt.port': '1883', ... } + ``` +3. In `resolveVariables()`, check the transport map for dot-notation keys before falling through to entity outputs + +**No backend changes. No new models. No new endpoints.** + +## Example Usage in Templates + +Post-install instruction (`post-install.md`): +```markdown +#define THINGSBOARD_SERVER "${mqtt.host}" +#define THINGSBOARD_PORT ${mqtt.port} +``` + +Integration template (`integration.json`): +```json +{"baseUrl": "${http.host}:${http.port}"} +``` diff --git a/docs/superpowers/specs/2026-04-09-provisioning-conflict-resolution.md b/docs/superpowers/specs/2026-04-09-provisioning-conflict-resolution.md new file mode 100644 index 0000000000..25251e7e3f --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-provisioning-conflict-resolution.md @@ -0,0 +1,102 @@ +# Provisioning Conflict Resolution — Design Spec + +## Goal + +When an entity with the same name already exists during device package provisioning, show a conflict resolution UI instead of an error. The user chooses how to proceed (use existing, overwrite, or create copy) and provisioning continues. + +## Conflict Detection + +Before creating each entity, pre-check by name. If an entity with the same name exists, show the conflict UI instead of attempting creation. + +| Entity type | Pre-check | Conflict options | +|-------------|-----------|-----------------| +| DEVICE_PROFILE | `findDeviceProfileByName` | Use existing / Overwrite | +| RULE_CHAIN | `findRuleChainByName` | Use existing / Overwrite | +| DEVICE | `findDeviceByName` (new) | Use existing / Overwrite | +| GATEWAY | `findDeviceByName` (new, same API) | Use existing / Overwrite | +| DASHBOARD | `findDashboardByName` (new) | Overwrite / Create copy | +| GATEWAY_CONNECTOR | no pre-check | — | + +## New Status: `conflict` + +```typescript +export type EntityStepStatus = 'pending' | 'running' | 'success' | 'error' | 'conflict'; +``` + +`EntityStepProgress` gains: +```typescript +existingEntity?: EntityStepOutput; // the found entity, for resolution +conflictType?: 'use-or-overwrite' | 'overwrite-or-copy'; // which buttons to show +``` + +## Resolution Actions + +**Use existing**: take the existing entity's ID/name/url, store as output, continue to next step. No API call. + +**Overwrite**: fetch existing entity by ID, merge template data (keeping the ID), save. For rule chains, also save metadata. Store result as output. + +**Create copy**: create new entity with the template as-is (TB allows duplicate dashboard names). Store result as output. + +## UI + +Conflict row styling: +- Amber/yellow background (not red — it's a decision, not an error) +- Warning icon (amber `⚠`) +- Description: "Device profile with this name already exists" +- Two buttons on the right + +For `use-or-overwrite`: +``` +⚠ Device Profile — Modbus TH Sensor + Entity with this name already exists + [Use existing] [Overwrite] +``` + +For `overwrite-or-copy`: +``` +⚠ Dashboard — Modbus TH Monitor + Dashboard with this name already exists + [Overwrite] [Create copy] +``` + +Button styling: +- Use existing / Create copy: outlined primary (safe option) +- Overwrite: outlined warning/amber (destructive) + +## Provisioning Step Flow + +1. For each entity step: + a. Set status = 'running' + b. Pre-check: search for existing entity by name + c. If found: set status = 'conflict', store existing entity, pause (return from loop) + d. If not found: create entity → success → continue + e. If creation fails: set status = 'error' → pause (existing behavior) +2. When user clicks a conflict resolution button: + a. Execute resolution (use existing / overwrite / create copy) + b. Set status = 'success' + c. Resume `runEntitySteps` from the next step + +## Read-Only Review Dialog + +No changes needed — provisioning step shows all entities as success with "Done" label. No conflict UI in review mode. + +## Changes + +### Models (`device-package.models.ts`) +- Add `'conflict'` to `EntityStepStatus` +- Add `existingEntity?: EntityStepOutput` and `conflictType?: string` to `EntityStepProgress` + +### Dialog Component (`device-install-dialog.component.ts`) +- Add `findDeviceByName` and `findDashboardByName` methods +- Refactor `createEntity` to pre-check before creating +- Add `resolveConflict(ep, resolution)` method +- Resume loop after conflict resolution + +### Dialog Template (`device-install-dialog.component.html`) +- Add conflict row rendering with amber styling and resolution buttons + +### Dialog Styles (`device-install-dialog.component.scss`) +- Add `.tb-progress-conflict` amber styles + +### Translations +- Add conflict-related translation keys diff --git a/docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md b/docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md new file mode 100644 index 0000000000..fda5f3c451 --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md @@ -0,0 +1,289 @@ +# IoT Hub item deep link — design + +**Status:** approved +**Date:** 2026-04-22 +**Author:** Andrii Shvaika +**Scope:** ThingsBoard CE frontend + IoT Hub backend contract (external) + +## Summary + +Allow two shareable deep-link URL shapes: +- `http:///iot-hub/{itemId}` — opens the detail view for the latest published version of the item. +- `http:///iot-hub/version/{itemVersionId}` — opens a specific version snapshot. Published versions open directly; unpublished versions are gated behind a security warning. + +The first URL is the canonical "share this marketplace item" link. The second targets creator review workflows — stable snapshots of an exact draft. + +## Goals + +- Two shareable, bookmarkable deep link shapes with distinct semantics: + - `/iot-hub/{itemId}` — always latest published version of the item (404 if none published). + - `/iot-hub/version/{itemVersionId}` — specific version; warning gate when unpublished, then "Unpublished preview" badge in the detail dialog. +- Zero backend changes in ThingsBoard. Install/update flows reuse the existing versionId-based pipeline. + +## Non-goals + +- Authenticated access to unpublished content. Authorization is "public-by-link": whoever has the UUID can fetch the content. +- Full standalone page for item detail. The detail view stays as an Angular Material dialog; the deep link navigates to the type-specific browse page (`/iot-hub/widgets`, `/iot-hub/dashboards`, etc.) and opens the dialog over it. +- Install-specific semantics for unpublished versions. Install reuses the normal flow; only the IoT Hub-side install counter policy may differ (see IoT Hub-side changes). +- A "latest including drafts" shorthand shape (`/iot-hub/{itemId}/preview`). If creators want to preview a specific draft, they use the version URL with the exact versionId. + +## User flows + +### Published link: `/iot-hub/{itemId}` + +1. User pastes or clicks `/iot-hub/{itemId}`. +2. Angular mounts `TbIotHubItemResolverComponent`. +3. Resolver calls `iotHubApiService.getPublishedVersion(itemId)` → IoT Hub `GET /api/items/{itemId}/published` (new endpoint). +4. On success, resolver navigates to `/iot-hub/{typeSegment(item.type)}` with router state `{ openItem: { version, preview: false } }` and `replaceUrl: true`. Detail dialog opens with no badge. +5. `TbIotHubItemsPageComponent.ngOnInit` consumes `history.state.openItem`, resolves installed state, and calls `IotHubActionsService.openItemDetail(...)`. +6. On 404 (no published version exists) or other error, resolver shows a toast and redirects to `/iot-hub`. + +### Version link: `/iot-hub/version/{itemVersionId}` + +1. User pastes or clicks `/iot-hub/version/{itemVersionId}`. +2. Angular mounts `TbIotHubItemResolverComponent`. +3. Resolver calls `iotHubApiService.getVersionInfo(itemVersionId)` → IoT Hub `GET /api/versions/{versionId}` (existing endpoint). +4. If the returned version is published → resolver navigates directly to `/iot-hub/{typeSegment(item.type)}` with router state `{ openItem: { version, preview: false } }` and `replaceUrl: true`. Detail dialog opens with no badge. +5. If the returned version is unpublished → resolver opens `TbIotHubUnpublishedWarningDialogComponent` (`disableClose: true`). + - Cancel → `router.navigate(['/iot-hub'])`. + - "I understand the risk, continue" → navigates to `/iot-hub/{typeSegment(item.type)}` with `{ openItem: { version, preview: true } }`; detail dialog opens with the "Unpublished preview" badge. +6. Install / Update / Remove / Open-entity actions behave as usual — all operate on the versionId already fetched, so install-from-version works end-to-end. +7. On 404 or other error, resolver shows a toast and redirects to `/iot-hub`. + +## Angular routing + +Two routes added to `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts`, placed after the existing named child routes (`widgets`, `dashboards`, `solution-templates`, `calculated-fields`, `rule-chains`, `devices`, `search`, `installed`, `creator/:creatorId`). The literal `version/:itemVersionId` must come **before** the `:itemId` wildcard so the router matches the literal first: + +```ts +{ 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' } }, +``` + +The resolver branches on which param is present — `paramMap.get('itemVersionId')` signals the version-URL flow; `paramMap.get('itemId')` signals the published-URL flow. A UUID-shape check runs inside the resolver (not as a `UrlMatcher`) so an invalid id produces a friendly toast instead of a generic not-found page. + +## Components + +### `TbIotHubItemResolverComponent` (new) + +Location: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts`. + +- Standalone: `false`. Declared in `IotHubModule`. +- Template: empty (`template: ''`). The component renders nothing; it is a router-reachable controller. +- `ngOnInit`: + 1. Read `itemVersionId` and `itemId` from route params. Set `byVersion = itemVersionId != null`; pick the relevant id accordingly. + 2. Reject non-UUID id → `iot-hub.deep-link-invalid-id` toast + redirect to `/iot-hub`. + 3. Dispatch to `getVersionInfo(id)` (byVersion) or `getPublishedVersion(id)` (published), both with `{ ignoreErrors: true }`. + 4. On error, map HTTP status to `iot-hub.deep-link-not-found` (404) or `iot-hub.deep-link-fetch-failed` (other) and redirect. + 5. On success, call `handleResolved(version, byVersion)`: + - `byVersion` + version unpublished → open warning dialog; confirm routes to type-page with state (`preview: true`); cancel routes to `/iot-hub`. + - Otherwise → route directly to type-page with state (`preview: false`). +- All navigations use `replaceUrl: true` so the resolver URL does not pollute browser history. +- The published URL (`/iot-hub/{itemId}`) cannot surface unpublished content — `getPublishedVersion` only returns PUBLISHED versions — so the warning branch is unreachable for that flow. The `byVersion` gate in `handleResolved` makes this explicit. + +### `iot-hub-deep-link.utils.ts` (new) + +Shared helpers: + +```ts +export const isUUID = (s: string | null): s is string => !!s && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.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.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; +} +``` + +A `typeSegment` returning `undefined` (future `ItemType` values) surfaces as `iot-hub.deep-link-fetch-failed` in the resolver. + +### `TbIotHubUnpublishedWarningDialogComponent` (new) + +Location: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.{ts,html,scss}`. Declared in `IotHubComponentsModule`. + +- Title: translated `iot-hub.unpublished-warning-title` ("Unpublished content") with a red `warning` Material icon. +- Body: `iot-hub.unpublished-warning-text` paragraph ("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."). +- Secondary line: `{item.name} • v {item.version}` so the user sees what they are acknowledging. +- Buttons: + - `Cancel` — returns `false`. + - `iot-hub.unpublished-warning-confirm` ("I understand the risk, continue") — returns `true`, styled with the project's danger accent. +- Dialog config: `panelClass: ['tb-dialog']`, `disableClose: true`, `autoFocus: false`. +- `MAT_DIALOG_DATA` payload: `{ item: MpItemVersionView }`. + +Structural sibling of `TbIotHubDeleteDialogComponent`. + +### `TbIotHubItemDetailDialogComponent` (modified) + +- `IotHubItemDetailDialogData` gains optional `preview?: boolean`. +- Component stores `this.preview = data.preview === true` and exposes it to the template. +- Template adds a preview badge next to the existing version chip in the sticky meta bar: + + ```html +
+ warning + {{ 'iot-hub.unpublished-preview' | translate }} +
+ ``` + +- No other template or behavior changes. Install / Update / Remove / Open-entity actions are untouched. +- SCSS adds `.tb-unpublished-preview-badge` (red-on-light-red, matching the warning dialog accent). + +### `IotHubActionsService` (modified) + +```ts +openItemDetail( + item: MpItemVersionView, + installedItem?: IotHubInstalledItem, + installedItemsCount?: number, + mode?: IotHubItemDetailDialogMode, + showCreator?: boolean, + preview?: boolean +): Observable +``` + +`preview` is forwarded into `IotHubItemDetailDialogData`. All existing callers ignore the new parameter (undefined → non-preview). + +### `TbIotHubItemsPageComponent` (modified) + +`ngOnInit` is extended to consume `history.state.openItem` exactly once per navigation: + +```ts +private maybeOpenDeepLinkedItem(): void { + const openItem = history.state?.openItem as + { version: MpItemVersionView; preview?: boolean } | undefined; + if (!openItem || openItem.version.type !== this.config.type) return; + + history.replaceState({ ...history.state, openItem: undefined }, ''); + + this.resolveInstalledItem(openItem.version).subscribe(installed => { + this.iotHubActions.openItemDetail( + openItem.version, + installed, + installed ? 1 : 0, + 'default', + true, + openItem.preview + ).subscribe(result => this.handleDetailResult(result)); + }); +} + +private resolveInstalledItem(v: MpItemVersionView): Observable { + return this.iotHubApiService + .getInstalledItems(new PageLink(1), undefined, v.itemId) + .pipe(map(page => page.data[0] ?? null)); +} +``` + +`handleDetailResult` delegates to the same installed-count refresh logic already used by card clicks. The `history.replaceState` call clears `openItem` so a page refresh does not re-open the dialog from stale state. + +## API contract + +### `IotHubApiService` — one new method + +```ts +public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable { + return this.http.get( + `${this.baseUrl}/api/items/${itemId}/published`, + { params: this.buildParams(config) } + ); +} +``` + +The version URL reuses the existing `getVersionInfo(versionId, config)` which calls `GET /api/versions/{versionId}`. Both accept `{ ignoreErrors: true }` so the resolver can handle failures inline rather than surfacing the global interceptor toast. + +### ThingsBoard backend + +Unchanged. `IotHubController.installVersion` and `IotHubController.updateInstalledItem` already operate on versionIds; both deep-link flows funnel into them without modification. + +## i18n + +Add to `ui-ngx/src/assets/locale/locale.constant-en_US.json` (and mirror into other locales): + +- `iot-hub.item-detail` — "IoT Hub item" +- `iot-hub.unpublished-warning-title` — "Unpublished content" +- `iot-hub.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." +- `iot-hub.unpublished-warning-confirm` — "I understand the risk, continue" +- `iot-hub.unpublished-preview` — "Unpublished preview" +- `iot-hub.deep-link-invalid-id` — "Invalid IoT Hub item link." +- `iot-hub.deep-link-not-found` — "This IoT Hub item doesn't exist or was removed." +- `iot-hub.deep-link-fetch-failed` — "Couldn't load IoT Hub item. Please try again." + +## Edge cases + +- **Invalid UUID shape** for either `itemId` or `itemVersionId` → `iot-hub.deep-link-invalid-id` toast + redirect to `/iot-hub`. +- **404 from IoT Hub**: + - Published URL: item has no published version, or item doesn't exist. Toast `iot-hub.deep-link-not-found` + redirect. + - Version URL: version doesn't exist or was removed. Same toast + redirect. +- **Network / 5xx error** → `iot-hub.deep-link-fetch-failed` toast + redirect. +- **Version URL resolves to a published version** → no warning, no badge. Serves as a stable snapshot link to that version. +- **Unsupported `ItemType`** (future value not in `typeSegment`) → treated as `iot-hub.deep-link-fetch-failed`. +- **User hits Browser Back from the warning dialog** → dialog destroys with the resolver component; no zombie dialog. +- **User lacks `TENANT_ADMIN`** → the `/iot-hub` parent route guard blocks; no additional guard needed. +- **Deep link for an already-installed item** → detail dialog shows its usual "Installed / Update / Open entity" actions against the resolved versionId. Both URL shapes produce identical behavior once the dialog is open. +- **Refresh after deep link has been resolved** → URL is now `/iot-hub/{typePage}`; `history.state.openItem` is cleared; user sees the type-page with no dialog (expected). + +## Testing + +- `TbIotHubItemResolverComponent` unit tests with mocked `IotHubApiService` and `Router`: + - published URL happy path (no warning, no badge) + - version URL happy path, version is published (no warning, no badge) + - version URL happy path, version is unpublished (warning → confirm → dialog with badge) + - version URL, warning cancel → redirect to `/iot-hub` + - invalid UUID (either param) + - 404 (both URL shapes) + - 5xx / network error + - unsupported `ItemType` +- `isPublished()` and `typeSegment()` unit tests. +- `TbIotHubUnpublishedWarningDialogComponent` component tests: renders item name/version; Cancel returns `false`; Confirm returns `true`. +- `TbIotHubItemsPageComponent.maybeOpenDeepLinkedItem` integration test with seeded `history.state`: asserts dialog opens, state is cleared, type mismatch is ignored. +- No ThingsBoard backend tests (no backend changes). + +## Files touched + +New: +- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts` +- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss` + +Modified: +- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts` +- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts` +- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts` +- `ui-ngx/src/app/core/http/iot-hub-api.service.ts` +- `ui-ngx/src/assets/locale/locale.constant-*.json` + +## IoT Hub-side changes required + +These live in the IoT Hub repository, not ThingsBoard CE. One new endpoint plus behavior/CORS contracts on the existing by-versionId family. + +1. **New endpoint** `GET /api/items/{itemId}/published` + - Returns `MpItemVersionView` for the latest version of the item that is in the PUBLISHED state. + - `404` when the item has no published version or doesn't exist. + - Anonymous cross-origin access (same CORS policy as `/api/versions/published`). + - Powers the `/iot-hub/{itemId}` deep link. +2. **Behavior contract on existing `GET /api/versions/{versionId}`**: must return the requested version regardless of its state (PUBLISHED, DRAFT, PENDING_REVIEW, …). This powers the `/iot-hub/version/{itemVersionId}` deep link. Anonymous cross-origin; the versionId UUID itself is the soft-secret gate. +3. **`MpItemVersionView` response for unpublished versions** must allow the frontend to tell published from unpublished. Either `publishedTime` must be falsy (`null` / `0`) for non-published versions, or an explicit `state` field must be added. Pick one; the frontend uses `isPublished(v)` based on `publishedTime` today. +4. **Related by-versionId endpoints must also serve unpublished versions** (required by the install flow proxied through TB): + - `GET /api/versions/{versionId}/readme` + - `GET /api/versions/{versionId}/fileData` + - `POST /api/versions/{versionId}/install` +5. **Install counter policy**: decide whether `POST /api/versions/{versionId}/install` against an unpublished version increments counters. Recommended: skip, to avoid inflating published install metrics with creator self-tests. +6. **CORS**: ensure `/api/items/{itemId}/published` and the full `/api/versions/{versionId}/...` family permit cross-origin GET from any origin. diff --git a/docs/superpowers/specs/2026-05-06-iot-hub-item-link.md b/docs/superpowers/specs/2026-05-06-iot-hub-item-link.md new file mode 100644 index 0000000000..38f7fe7895 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-iot-hub-item-link.md @@ -0,0 +1,145 @@ +# IoT Hub `${item-link:uuid}` Markdown Component — Design + +**Date:** 2026-05-06 +**Status:** Approved +**Branch:** `feature/iot-hub` + +## Summary + +Add an `${item-link:}` markdown placeholder that renders a small card +(thumbnail + name + creator) for any IoT Hub marketplace item, modeled on +the cards already shown in the home-page search popup. Used by IoT Hub +content authors to cross-link items from readme and install instructions. + +## Decisions + +| Question | Decision | +|---|---| +| UUID identifies | Marketplace **item ID** (stable across versions) | +| Render scope | Item readme + device install instructions + solution install instructions | +| Click behavior | Plain anchor → `/iot-hub/{itemId}` with `target="_blank"` | +| Syntax | `${item-link:}` — ID only, type derived from API response | +| Unavailable item handling | Render disabled card labeled "Item unavailable" | +| Resolution strategy | Render skeleton, fetch async, swap when ready (per card) | + +## Architecture + +Three building blocks, scoped tightly: + +1. **`replaceItemLinkPlaceholders(markdown: string): string`** — pure + string transform. Rewrites `${item-link:}` to + ``. +2. **`TbIotHubItemLinkCardComponent`** — Angular component that owns + fetch, state (`loading` / `loaded` / `unavailable`), and visual. +3. **`IotHubItemLinkModule`** — declares the component; passed as + `additionalCompileModules` on every `tb-markdown` instance that needs it. + +`tb-markdown` already supports compile-time injection of additional +modules; placeholder rewriting + module registration is all that is +needed to make the component render inside markdown. + +## Placeholder syntax + +- Format: `${item-link:}` +- Regex: strict 36-char UUID match + (`/\$\{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`) +- Non-UUID payloads (typos) are left as-is in rendered output, so the + author sees the issue during preview. +- Placeholders inside fenced code blocks are still replaced — matches + the existing `prefixResourceUrls` / `resolveDocLinks` precedent in the + same files. + +## Integration points + +| File | Hook | +|---|---| +| `iot-hub-item-detail-dialog.component.ts` `loadReadme()` | Add as a step in the existing pipeline next to `prefixResourceUrls` and `resolveDocLinks` | +| `device-install-dialog.component.ts` `resolveVariables()` | Add an `^item-link:(uuid)$` branch alongside `gateway.downloadButton` and the callout matchers | +| `solution-install-dialog.component.ts` constructor | New step before assigning `this.details` | + +All three call the same backing helper. Each `tb-markdown` instance +in those templates gets `[additionalCompileModules]="[IotHubItemLinkModule]"`. + +## Component spec + +**Location:** +`ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/` + +**Inputs:** +- `@Input() itemId: string` + +**Lifecycle:** +- `ngOnInit()` calls + `iotHubApiService.getPublishedVersion(itemId, { ignoreErrors: true, ignoreLoading: true })`. +- Success → `state = 'loaded'`, store item. +- Error / 404 / network failure → `state = 'unavailable'`. + +**Template — three states:** + +- **loading:** skeleton card matching the loaded layout (gray thumb + + two text shimmer lines). +- **loaded:** anchor to `/iot-hub/{itemId}` (`target="_blank"`, + `rel="noopener noreferrer"`), thumbnail slot + name + creator row. +- **unavailable:** non-clickable card, 60 % opacity, `link_off` icon + in the thumbnail slot, label "Item unavailable" (translated). + +**Thumbnail rules** (mirror the search popup at +`iot-hub-home.component.html:103-115`): +- Compact types (`CALCULATED_FIELD`, `ALARM_RULE`, `RULE_CHAIN`): + colored square + `getCompactIcon()` + item color. +- Other types: image via `iotHubApiService.resolveResourceUrl(item.image)`, + fallback to a `mat-icon` of the type when no image is present. +- The same branching exists in `iot-hub-home.component.ts` (search popup) + and `iot-hub-item-detail-dialog.component.ts`. The new card duplicates + it locally — extracting a shared helper is out of scope for this PR. + +**Styling:** +- Component-scoped SCSS. +- Fixed width ~320 px, block-level (each card on its own line in markdown). +- Subtle CSS-keyframe shimmer for the skeleton state. +- Hover: matches search-popup card hover. + +**Click:** +- Plain anchor — middle-click / ctrl-click / "open in new tab" all work + natively. Existing route `/iot-hub/:itemId` + (`TbIotHubItemResolverComponent`) handles resolution, the + unpublished-version warning, and opening the detail dialog on the + type page. + +## Translations + +Add the following keys (and propagate to other locale files): + +| Key | English | +|---|---| +| `iot-hub.item-link-unavailable` | "Item unavailable" | + +## Out of scope + +- Changelog rendering (explicitly excluded). +- IoT Hub creator-side authoring helpers (placeholder is plain text in + raw markdown; nothing required on the IoT Hub backend). +- Batch endpoint for resolving multiple items in one request — N parallel + requests is fine for the expected 0–5 references per page. +- A non-block (inline) variant of the card. +- Hover preview / tooltip / type chip on the card itself. + +## Testing + +- Component unit tests: state transitions (loading → loaded, loading + → unavailable), thumbnail logic for compact vs. non-compact types, + fallback when image missing. +- Regex unit test for `replaceItemLinkPlaceholders` covering: valid + UUID, invalid UUID (left untouched), placeholder inside fenced code + (still replaced — documented behavior), multiple placeholders in one + document. +- Manual visual QA in all three render sites. + +## Risks / open items + +- `tb-markdown` recompiles on every `data` change; if a parent toggles + the markdown rapidly, the card re-fetches. Acceptable: readmes / + instructions don't churn during normal viewing. +- `getPublishedVersion` returns the **current** published version. If a + newer published version changes the name/thumbnail, links update + automatically — that is the documented behavior of the ID-only syntax. 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 3dea406f1a..0bfc97fe53 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -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", diff --git a/ui-ngx/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts index 2d905492fa..482c3a6aa5 100644 --- a/ui-ngx/src/app/core/auth/auth.models.ts +++ b/ui-ngx/src/app/core/auth/auth.models.ts @@ -44,6 +44,7 @@ export interface SysParamsState { allowKeyFiltersOrConditions: boolean; 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 dc8757508c..ce3107a4ef 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -45,7 +45,8 @@ const emptyUserAuthState: AuthPayload = { trendzSettings: initialTrendzSettings, allowKeyFiltersOrConditions: true, 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..b0f90ddeef --- /dev/null +++ b/ui-ngx/src/app/core/http/iot-hub-api.service.ts @@ -0,0 +1,275 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, 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 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 ab2160ec0f..443debdc43 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; } @@ -109,7 +110,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; @@ -740,6 +742,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 + } ] ]); @@ -834,6 +847,7 @@ const defaultUserMenuMap = new Map([ ] }, {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 8f721b87dd..8e8c699fe4 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -179,7 +179,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 WidgetButtonComponent from '@shared/components/button/widget-button.component'; @@ -326,7 +325,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'; @@ -530,7 +528,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/button/widget-button.component': WidgetButtonComponent, @@ -679,7 +676,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 aac49a3f37..ad5c166fe6 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 @@ -72,6 +72,8 @@ import { EntityAction } from '@home/models/entity/entity-component.models'; import { CalculatedFieldComponent } from '@home/components/calculated-fields/calculated-field.component'; import { Router } from '@angular/router'; import { CalculatedFieldsTabsComponent } from '@home/pages/calculated-fields/calculated-fields-tabs.component'; +import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; export type CalculatedFieldsTableEntity = CalculatedField | CalculatedFieldInfo; @@ -101,6 +103,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig true, onAction: () => this.importCalculatedField() + }, + { + name: this.translate.instant('iot-hub.add-from-iot-hub'), + icon: 'store', + isEnabled: () => true, + onAction: () => this.addCalculatedFieldFromIotHub() } ]; @@ -330,6 +339,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 feba407f52..beb02f96e2 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'; import { UtilsService } from "@core/services/utils.service"; import { ActivatedRoute, Router } from '@angular/router'; @@ -71,7 +72,8 @@ export class CalculatedFieldsTableComponent { private utilsService: UtilsService, private destroyRef: DestroyRef, private route: ActivatedRoute, - private router: Router + private router: Router, + private iotHubActions: IotHubActionsService ) { this.pageMode = !!this.route.snapshot.data.isPage; effect(() => { @@ -91,6 +93,7 @@ export class CalculatedFieldsTableComponent { this.entityDebugSettingsService, this.utilsService, this.router, + this.iotHubActions, this.pageMode, ); 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 46b2bbb9ae..ab28aa8c47 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 @@ -278,230 +278,166 @@ - -
- - - @if (!widgetEditMode) { -
-

{{ translatedDashboardTitle }}

- - dashboard.title - - -
- } - - @if (layouts.right.show) { - - - - - } - - - - @if (isEdit) { -
- {{ 'dashboard.breakpoint-value' | translate : {value: currentBreakpointValue } }} -
- } -
-
- @if (!readonly && (hideToolbar || widgetEditMode)) { -
- - - -
- } - @if (!embedded) { -
- } - - - @if (!isEditingWidgetClosed) { - -
- @if (tbEditWidget.widgetConfig?.hasBasicMode) { - - {{ 'widget.basic-mode' | translate }} - {{ 'widget.advanced-mode' | translate }} - - } -
-
- - -
- } - @if (!isAddingWidgetClosed && !widgetEditMode) { - +
+ + + @if (!widgetEditMode) { +
+

{{ translatedDashboardTitle }}

+ + dashboard.title + + +
+ } + + @if (layouts.right.show) { + + + + + } + + + + @if (isEdit) { +
+ {{ 'dashboard.breakpoint-value' | translate : {value: currentBreakpointValue } }} +
+ } +
+
+ @if (!readonly && (hideToolbar || widgetEditMode)) { +
+ + + +
+ } + @if (!embedded) { + + } +
+ + @if (!isEditingWidgetClosed) { + -
- -
- @if (isAddingWidget && dashboardWidgetSelectComponent) { -
- - -
- } - @if (isAddingWidget) { -
- @if (dashboardWidgetSelectComponent?.selectWidgetMode === 'bundles' && !dashboardWidgetSelectComponent?.widgetsBundle) { - - } - @if (dashboardWidgetSelectComponent?.selectWidgetMode === 'bundles' && !dashboardWidgetSelectComponent?.widgetsBundle) { - - {{ 'widgets-bundle.widgets-bundles' | translate }} - {{ 'widget.all-widgets' | translate }} - - } - @if (dashboardWidgetSelectComponent?.widgetTypes.size > 1) { - - } - @if (dashboardWidgetSelectComponent?.hasDeprecated) { - - {{ 'widget.all' | translate }} - {{ 'widget.actual' | translate }} - {{ 'widget.deprecated' | translate }} - - } -
- } - @if (isAddingWidget) { - - - } -
- } -
-
-
- + (closeDetails)="onEditWidgetClosed()"> +
+ @if (tbEditWidget.widgetConfig?.hasBasicMode) { + + {{ 'widget.basic-mode' | translate }} + {{ 'widget.advanced-mode' | translate }} + + } +
+
+ + +
+ } + @if (!isAddingWidgetClosed && !widgetEditMode && isAddingWidget) { + + + } +
+ +
+ 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..b1383603e0 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 @@ -195,3 +195,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 3948af744f..cb714700c3 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; @@ -381,7 +371,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, @@ -1689,59 +1678,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 adc4e2ca0c..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,55 +16,232 @@ -->
- @if (selectWidgetMode === 'bundles') { - @if (widgetsBundle) { - - } @else { - - - - -
-
- {{item.title}} -
-
- @if (isSystem(item)) { -
sys
- } -
i
-
+ +
+ @if (showTopLevelWidgetModeToggle()) { + + + + + +
+ now_widgets +
{{ 'widget.installed' | translate }}
-
-
- {{ item.title }} + + +
+ hub +
{{ 'iot-hub.iot-hub' | translate }}
- -
- -
- - {{ 'widgets-bundle.loading-widgets-bundles' | translate }} - - -
-
- - widgets-bundle.no-widgets-bundles-text - + + } @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 { + + } + } } - } @else { - } +
+ + [noData]="noData"> - +
{{item.name}} @@ -85,7 +262,7 @@
{{ 'widget.' + item.widgetType + '-short' | translate }}
-
i
+
i
@if (item.deprecated) {
widget.deprecated
@@ -106,9 +283,6 @@
- - {{(selectWidgetMode === 'bundles' ? 'widgets-bundle.empty' : 'widget.no-widgets-text') | translate}} - @@ -120,4 +294,293 @@
+ + + + + + + + +
+ + {{ 'widget.loading-widgets' | translate }} + + +
+
+
+ + + + + + + + +
+ + {{ '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/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index a726e4baf7..aefb08bd34 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 @@ -113,6 +113,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 { @@ -125,7 +126,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 { @@ -326,7 +326,6 @@ import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; DashboardStateDialogComponent, DashboardImageDialogComponent, EmbedDashboardDialogComponent, - DisplayWidgetTypesPanelComponent, TenantProfileQueuesComponent, QueueFormComponent, RepositorySettingsComponent, @@ -359,7 +358,7 @@ import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; ApiKeyGeneratedDialogComponent, AuditLogHeaderComponent, AuditLogFilterComponent, - EventsDialogComponent + EventsDialogComponent, ], imports: [ CommonModule, @@ -367,6 +366,7 @@ import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; SharedHomeComponentsModule, CalculatedFieldsModule, AlarmRuleModule, + IotHubComponentsModule, WidgetConfigComponentsModule, BasicWidgetConfigModule, Lwm2mProfileComponentsModule, @@ -471,6 +471,7 @@ import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; SelectDashboardBreakpointComponent, EditWidgetComponent, DashboardWidgetSelectComponent, + IotHubComponentsModule, AddWidgetDialogComponent, MoveWidgetsDialogComponent, ManageDashboardLayoutsDialogComponent, @@ -480,7 +481,6 @@ import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; DashboardStateDialogComponent, DashboardImageDialogComponent, EmbedDashboardDialogComponent, - DisplayWidgetTypesPanelComponent, TenantProfileQueuesComponent, QueueFormComponent, RepositorySettingsComponent, @@ -508,7 +508,7 @@ import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; ResourcesLibraryComponent, ApiKeysTableComponent, ApiKeysTableDialogComponent, - EventsDialogComponent + EventsDialogComponent, ], providers: [ WidgetComponentService, 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..733ed0a906 --- /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..e784b11898 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts @@ -0,0 +1,1125 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, OnInit, Type, 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 { + installMethodIcons as INSTALL_METHOD_ICONS, + installMethodLabels as INSTALL_METHOD_LABELS, + peOnlyInstallMethods, + DeviceInstallStep, + DevicePackageInfo, + ENTITY_STEP_TYPES, + EntityStepOutput, + EntityStepProgress, + FormFieldDefinition, + FormFieldType, + InstallStepType, + stepTypeAliasMap +} from '@shared/models/iot-hub/device-package.models'; + +export interface DeviceInstallDialogData { + item: MpItemVersionView; + zipData: ArrayBuffer; + 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 { + + @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 JSZip = (await import('jszip')).default; + const zip = await JSZip.loadAsync(this.data.zipData); + const imageExtensions = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg']); + 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 base64 = await entry.async('base64'); + const mimeType = ext === 'jpg' ? 'image/jpeg' : ext === 'svg' ? 'image/svg+xml' : `image/${ext}`; + this.zipImages.set(path, `data:${mimeType};base64,${base64}`); + } 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(); + } + + // --- 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) { + const raw = this.zipFiles.get(ws.rawSteps[0].file) || ''; + ws.markdown = raw; + } 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') { + const raw = this.zipFiles.get(ws.rawSteps[0].file) || ''; + ws.markdown = raw; + } 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 > 1 && !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') { + const raw = this.zipFiles.get(step.rawSteps[0].file) || ''; + step.markdown = raw; + } 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: false})); + 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: false})); + 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: false})); + 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: false})); + 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..ee32245eb1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts @@ -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 { Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Observable, of, EMPTY } from 'rxjs'; +import { filter, mergeMap } from 'rxjs/operators'; +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 + ) {} + + 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.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.fetchZipData(item).pipe( + mergeMap((zipData: ArrayBuffer) => this.openDeviceInstallDialog(item, zipData)) + ); + } + + reviewDevice(item: MpItemVersionView, deviceDescriptor: DeviceInstalledItemDescriptor): Observable { + return this.fetchZipData(item).pipe( + mergeMap((zipData: ArrayBuffer) => this.openDeviceInstallDialog(item, zipData, { + reviewMode: true, + selectedInstallMethod: deviceDescriptor.selectedInstallMethod, + installState: deviceDescriptor.installState + })) + ); + } + + private fetchZipData(item: MpItemVersionView): Observable { + return this.iotHubApiService.getVersionFileData(item.id as string, { ignoreLoading: true }).pipe( + mergeMap((blob: Blob) => blob.arrayBuffer()) + ); + } + + private openDeviceInstallDialog(item: MpItemVersionView, zipData: ArrayBuffer, + 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, zipData, ...options } as DeviceInstallDialogData + }).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..3920a19f1b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.ts @@ -0,0 +1,142 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { 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'; +import { + IotHubSelectCfEntityDialogData, + TbIotHubSelectCfEntityDialogComponent +} from './iot-hub-select-cf-entity-dialog.component'; + +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 dialog: MatDialog, + 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; + } + if (this.itemType === ItemType.CALCULATED_FIELD && !this.data.entityId) { + this.dialog.open( + TbIotHubSelectCfEntityDialogComponent, { + panelClass: ['tb-dialog'], + disableClose: true, + autoFocus: false, + data: { itemName: item.name } + } + ).afterClosed().subscribe(entityId => { + if (entityId) { + this.installItem(item, { entityId }); + } + }); + return; + } + const installData = this.data.entityId ? { entityId: this.data.entityId } : undefined; + this.installItem(item, installData); + } + + private installItem(item: MpItemVersionView, installData?: { entityId: EntityId }): void { + this.isInstalling = true; + const versionId = item.id as string; + 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.models.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.models.ts new file mode 100644 index 0000000000..f5e23cc6a0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.models.ts @@ -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. +/// + +import { EntityType } from '@shared/models/entity-type.models'; +import { IotHubInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { getEntityDetailsPageURL } from '@core/utils'; + +export const ITEM_TYPE_TO_ENTITY_TYPE: Record = { + 'WIDGET': EntityType.WIDGET_TYPE, + 'DASHBOARD': EntityType.DASHBOARD, + 'CALCULATED_FIELD': EntityType.CALCULATED_FIELD, + 'RULE_CHAIN': EntityType.RULE_CHAIN, + 'DEVICE': EntityType.DEVICE_PROFILE +}; + +export function resolveEntityDetailsUrl(descriptor: IotHubInstalledItemDescriptor, itemType: string): string | null { + if (!descriptor) { + return null; + } + // Alarm rules share the CalculatedField data structure but live at a different + // route, so resolve their URL directly instead of going through the entity-type map. + if (itemType === 'ALARM_RULE' && descriptor.type === 'ALARM_RULE') { + const id = descriptor.calculatedFieldId?.id; + return id ? `/alarms/alarm-rules/${id}` : null; + } + const entityType = ITEM_TYPE_TO_ENTITY_TYPE[itemType]; + if (!entityType) { + return null; + } + let entityId: string | null = null; + switch (descriptor.type) { + case 'WIDGET': entityId = descriptor.widgetTypeId?.id; break; + case 'DASHBOARD': entityId = descriptor.dashboardId?.id; break; + case 'CALCULATED_FIELD': entityId = descriptor.calculatedFieldId?.id; break; + case 'RULE_CHAIN': entityId = descriptor.ruleChainId?.id; break; + } + if (!entityId) { + return null; + } + return getEntityDetailsPageURL(entityId, entityType) || null; +} 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..9e935aad40 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts @@ -0,0 +1,93 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { TbIotHubSelectCfEntityDialogComponent } from './iot-hub-select-cf-entity-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, + TbIotHubSelectCfEntityDialogComponent, + 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, + TbIotHubSelectCfEntityDialogComponent, + 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 270284dc11..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,17 +15,11 @@ limitations under the License. --> -
- search - - @if (searchText) { - - } +
+

{{ '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..102a55ad5e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html @@ -0,0 +1,148 @@ + +
+ @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 ('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 ('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 ('installing') { + + + } + @case ('success') { + + @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..6fe3d0bfa2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.scss @@ -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 "../../../../../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; +} 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..4696f663b8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts @@ -0,0 +1,294 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, 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 { 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 { resolveEntityDetailsUrl } from './iot-hub-components.models'; +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' + | 'installing' + | 'success' + | '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; + 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; + }>; + + 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', + }, + [ItemType.ALARM_RULE]: { + allowed: [EntityType.DEVICE, EntityType.ASSET, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE], + defaultType: EntityType.DEVICE_PROFILE, + required: true, + promptKey: 'iot-hub.select-entity-for-alarm-rule', + } + }; + + 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.state = 'error'; + this.errorMessage = err?.error?.message || err?.message || + this.translate.instant('iot-hub.install-error', { name: this.item.name }); + } + }); + } + + 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 { + this.state = 'installing'; + const versionId = this.item.id as string; + const data = this.selectedEntityId ? { entityId: this.selectedEntityId } : undefined; + this.iotHubApiService.installItemVersion(versionId, { ignoreLoading: true }, data).subscribe({ + next: (result) => { + if (result.success) { + if (result.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: result.descriptor as SolutionTemplateInstalledItemDescriptor } + }); + }; + if (timeout > 0) { + setTimeout(openSolutionDialog, timeout); + } else { + openSolutionDialog(); + } + } else { + this.state = 'success'; + this.entityDetailsUrl = resolveEntityDetailsUrl(result.descriptor, this.item.type); + } + } else { + this.state = 'error'; + this.errorMessage = result.errorMessage || this.translate.instant('iot-hub.install-error', { name: this.item.name }); + } + }, + error: (err) => { + this.state = 'error'; + this.errorMessage = err?.error?.message || err?.message || this.translate.instant('iot-hub.install-error', { name: this.item.name }); + } + }); + } + + openEntityDetails(): void { + if (this.entityDetailsUrl) { + this.dialogRef.close('installed'); + void this.router.navigateByUrl(this.entityDetailsUrl); + } + } + + close(): void { + this.dialogRef.close(this.state === 'success' ? '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..7bbff19a85 --- /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:'mediumDate' }}@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..d83bb3e192 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss @@ -0,0 +1,261 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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: 100px; + min-width: 100px; + max-width: 100px; +} + +.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; +} + +.tb-type-widget { color: rgb(32, 115, 61); background: rgba(46, 166, 88, 0.06); } +.tb-type-dashboard { color: rgb(61, 76, 166); background: rgba(61, 76, 166, 0.06); } +.tb-type-solution-template { color: rgb(0, 137, 186); background: rgba(0, 137, 186, 0.06); } +.tb-type-calc-field { color: rgb(40, 120, 148); background: rgba(40, 120, 148, 0.06); } +.tb-type-rule-chain { color: rgb(178, 121, 29); background: rgba(179, 121, 29, 0.06); } +.tb-type-device { color: rgb(24, 146, 110); background: rgba(24, 146, 110, 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..cc0f760ac5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts @@ -0,0 +1,320 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, + IotHubInstalledItem, + ItemPublishedVersionInfo +} from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { getEntityDetailsPageURL } from '@core/utils'; +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 { + switch (itemType) { + case 'WIDGET': return 'widgets'; + case 'DASHBOARD': return 'dashboard'; + case 'SOLUTION_TEMPLATE': return 'integration_instructions'; + case 'CALCULATED_FIELD': return 'functions'; + case 'ALARM_RULE': return 'notification_important'; + case 'RULE_CHAIN': return 'settings_ethernet'; + case 'DEVICE': return 'memory'; + default: return 'category'; + } + } + + 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(); + }); + } + + getEntityId(item: IotHubInstalledItem): string | null { + const descriptor = item.descriptor; + switch (descriptor.type) { + case 'WIDGET': return descriptor.widgetTypeId?.id; + case 'DASHBOARD': return descriptor.dashboardId?.id; + case 'CALCULATED_FIELD': return descriptor.calculatedFieldId?.id; + case 'ALARM_RULE': return descriptor.calculatedFieldId?.id; + case 'RULE_CHAIN': return descriptor.ruleChainId?.id; + case 'DEVICE': return descriptor.dashboardId?.id ?? null; + case 'SOLUTION_TEMPLATE': return descriptor.dashboardId?.id; + default: return null; + } + } + + getEntityType(item: IotHubInstalledItem): EntityType | null { + const descriptor = item.descriptor; + switch (descriptor.type) { + case 'WIDGET': return EntityType.WIDGET_TYPE; + case 'DASHBOARD': return EntityType.DASHBOARD; + case 'CALCULATED_FIELD': return EntityType.CALCULATED_FIELD; + case 'ALARM_RULE': return EntityType.CALCULATED_FIELD; + case 'RULE_CHAIN': return EntityType.RULE_CHAIN; + case 'DEVICE': return descriptor.dashboardId ? EntityType.DASHBOARD : null; + case 'SOLUTION_TEMPLATE': return EntityType.DASHBOARD; + default: return null; + } + } + + openEntity(item: IotHubInstalledItem): void { + const entityType = this.getEntityType(item); + const entityId = this.getEntityId(item); + if (entityType && entityId) { + const url = getEntityDetailsPageURL(entityId, entityType); + if (url) { + window.open(this.router.serializeUrl(this.router.parseUrl(url)), '_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..d46124d91e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.scss @@ -0,0 +1,402 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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; + + 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..38c1e26dcf --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts @@ -0,0 +1,155 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { 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 original = path.endsWith('/preview') ? path.slice(0, -'/preview'.length) : path; + const resolved = query ? `${original}?${query}` : original; + return this.iotHubApiService.resolveResourceUrl(resolved); + } + + getPlaceholderIcon(): string { + switch (this.item.type) { + case ItemType.WIDGET: return 'widgets'; + case ItemType.DASHBOARD: return 'dashboard'; + case ItemType.SOLUTION_TEMPLATE: return 'integration_instructions'; + case ItemType.CALCULATED_FIELD: + return this.item.icon || cfTypeIcons.get(this.item.dataDescriptor?.cfType) || 'functions'; + case ItemType.ALARM_RULE: + return this.item.icon || 'notification_important'; + case ItemType.RULE_CHAIN: + return this.item.icon || (this.item.dataDescriptor?.ruleChainType === 'EDGE' ? 'router' : 'device_hub'); + case ItemType.DEVICE: return 'memory'; + default: return 'extension'; + } + } + + 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..6c0318abbc --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html @@ -0,0 +1,398 @@ + +
+ +
+ @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..0622ce4af5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss @@ -0,0 +1,671 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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; + +} + +// 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: max-content minmax(0, max-content) minmax(auto, max-content) minmax(auto, 1fr) max-content; + + &.dlg-meta-4-col { + grid-template-columns: max-content minmax(auto, max-content) minmax(auto, 1fr) max-content; + } + + .dlg-meta-details { + display: none; + } + + @media #{$mat-lt-md} { + grid-template-columns: max-content max-content max-content; + + &.dlg-meta-4-col { + grid-template-columns: max-content 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..1579bc763c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts @@ -0,0 +1,313 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { 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 { 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 { EntityType } from '@shared/models/entity-type.models'; +import { getEntityDetailsPageURL } from '@core/utils'; +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 { + switch (this.item.type) { + case ItemType.WIDGET: return 'widgets'; + case ItemType.DASHBOARD: return 'dashboard'; + case ItemType.SOLUTION_TEMPLATE: return 'integration_instructions'; + case ItemType.CALCULATED_FIELD: return 'functions'; + case ItemType.ALARM_RULE: return 'notification_important'; + case ItemType.RULE_CHAIN: return 'account_tree'; + case ItemType.DEVICE: return 'memory'; + default: return 'category'; + } + } + + 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) || 'functions'; + } + if (this.item.type === ItemType.ALARM_RULE) { + return 'notification_important'; + } + switch (this.item.dataDescriptor?.ruleChainType) { + case 'CORE': return 'device_hub'; + case 'EDGE': return 'router'; + default: return 'account_tree'; + } + } + + 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 descriptor = this.installedItem?.descriptor; + if (!descriptor) { + return; + } + let entityId: string | null = null; + let entityType: EntityType | null = null; + switch (descriptor.type) { + 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.calculatedFieldId?.id; + entityType = EntityType.CALCULATED_FIELD; + 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) { + const url = getEntityDetailsPageURL(entityId, entityType); + 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..6ac5883cd4 --- /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,66 @@ + +@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..2713303d6c --- /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,160 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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); + } + + &--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; + } +} + +.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..c2ca0ba1ea --- /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,106 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { 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!; + if (item.icon) { + return item.icon; + } + switch (item.type) { + case ItemType.CALCULATED_FIELD: return 'functions'; + case ItemType.ALARM_RULE: return 'notification_important'; + case ItemType.RULE_CHAIN: return 'account_tree'; + default: return 'category'; + } + } + + getTypeIcon(): string { + const item = this.item!; + switch (item.type) { + case ItemType.WIDGET: return 'widgets'; + case ItemType.DASHBOARD: return 'dashboard'; + case ItemType.SOLUTION_TEMPLATE: return 'integration_instructions'; + case ItemType.CALCULATED_FIELD: return 'functions'; + case ItemType.ALARM_RULE: return 'notification_important'; + case ItemType.RULE_CHAIN: return 'account_tree'; + case ItemType.DEVICE: return 'memory'; + default: return 'category'; + } + } + + 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 f26be70987..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,11 +15,11 @@ limitations under the License. --> -
- @for (type of types; track type) { - - {{ '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..57e3d25101 --- /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) { + 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..57df5a8074 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts @@ -0,0 +1,212 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + 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); + return parsed; + } + + 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..6685a898f5 --- /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..a2b22455c3 --- /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-select-cf-entity-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-select-cf-entity-dialog.component.html new file mode 100644 index 0000000000..f271325abb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-select-cf-entity-dialog.component.html @@ -0,0 +1,35 @@ + +
+

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

+

{{ 'iot-hub.select-entity-for-cf' | translate:{ name: data.itemName } }}

+ + +
+ + + + diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-select-cf-entity-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-select-cf-entity-dialog.component.ts new file mode 100644 index 0000000000..a4570ea52b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-select-cf-entity-dialog.component.ts @@ -0,0 +1,61 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, 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 { EntityType } from '@shared/models/entity-type.models'; +import { EntityId } from '@shared/models/id/entity-id'; + +export interface IotHubSelectCfEntityDialogData { + itemName: string; +} + +@Component({ + selector: 'tb-iot-hub-select-cf-entity-dialog', + standalone: false, + templateUrl: './iot-hub-select-cf-entity-dialog.component.html', + styleUrls: ['./iot-hub-install-dialog.component.scss'] +}) +export class TbIotHubSelectCfEntityDialogComponent + extends DialogComponent { + + selectedEntityId: EntityId | null = null; + cfEntityTypes: EntityType[] = [EntityType.DEVICE, EntityType.ASSET, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE]; + defaultCfEntityType = EntityType.DEVICE_PROFILE; + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: IotHubSelectCfEntityDialogData + ) { + super(store, router, dialogRef); + } + + cancel(): void { + this.dialogRef.close(null); + } + + confirm(): void { + if (this.selectedEntityId) { + this.dialogRef.close(this.selectedEntityId); + } + } +} 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..e11528aa6b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.ts @@ -0,0 +1,130 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { 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 { resolveEntityDetailsUrl } from './iot-hub-components.models'; +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 = resolveEntityDetailsUrl(result.descriptor, this.data.itemType); + } + } 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..5ebe136aec --- /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/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/menu/menu-link.component.html b/ui-ngx/src/app/modules/home/menu/menu-link.component.html index 70ea95a05f..a62ca48f24 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. --> - + @if (section.icon !== null) { {{section.icon}} } 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 ce475363df..2223a99062 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. --> - + @if (section.icon !== null) { {{section.icon}} } diff --git a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.scss b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.scss index 6da9b1f221..faa0d01ff2 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.scss +++ b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.scss @@ -13,6 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +@import '../../../../scss/constants'; + :host ::ng-deep { .tb-button-toggle { .tb-toggle-icon { @@ -26,6 +29,23 @@ transform: rotateZ(180deg); } } + &.is-new::after { + position: absolute; + right: 44px; + top: 4px; + content: "NEW"; + font-size: 12px; + letter-spacing: .03125em; + 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; + } } .tb-menu-toggle-list { diff --git a/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-routing.module.ts b/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-routing.module.ts index 20fe941cb1..f07e75aba1 100644 --- a/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-routing.module.ts @@ -33,6 +33,7 @@ import { DatePipe } from '@angular/common'; import { ImportExportService } from '@shared/import-export/import-export.service'; import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; import { UtilsService } from '@core/services/utils.service'; +import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; export const CalculatedFieldsTableConfigResolver: ResolveFn = (_route: ActivatedRouteSnapshot, @@ -47,6 +48,7 @@ export const CalculatedFieldsTableConfigResolver: ResolveFn { return new CalculatedFieldsTableConfig( calculatedFieldsService, @@ -63,6 +65,7 @@ export const CalculatedFieldsTableConfigResolver: ResolveFn true, onAction: ($event) => this.importDevices($event) }, + { + name: this.translate.instant('iot-hub.add-from-iot-hub'), + icon: 'store', + 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 a4b7df8739..133e4419d1 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 @@ -48,6 +48,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: [ @@ -83,6 +84,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-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..e36dd5210c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html @@ -0,0 +1,108 @@ + + 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..885ef74812 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss @@ -0,0 +1,347 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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; + 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; +} + +// 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..682411d884 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html @@ -0,0 +1,338 @@ + +
+ +
+
+ + + @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..72a5513056 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts @@ -0,0 +1,579 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { MatAutocompleteSelectedEvent } 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 { 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 || ''; + }; + + onSearchOptionSelected(event: MatAutocompleteSelectedEvent): void { + const item = event.option.value as MpItemVersionView; + this.searchText = ''; + this.searchAutoTrigger?.closePanel(); + this.openItemDetail(item); + } + + 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 { + if (item.icon) { + return item.icon; + } + switch (item.type) { + case ItemType.CALCULATED_FIELD: return 'functions'; + case ItemType.ALARM_RULE: return 'notification_important'; + case ItemType.RULE_CHAIN: return 'settings_ethernet'; + default: return 'category'; + } + } + + getItemImage(item: MpItemVersionView): string | null { + return resolveIotHubItemImageUrl(item, this.iotHubApiService); + } + + getItemTypeIcon(type: ItemType): string { + switch (type) { + case ItemType.WIDGET: return 'widgets'; + case ItemType.DASHBOARD: return 'dashboard'; + case ItemType.SOLUTION_TEMPLATE: return 'integration_instructions'; + case ItemType.ALARM_RULE: return 'notification_important'; + case ItemType.DEVICE: return 'memory'; + default: return 'category'; + } + } + + 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..5f68c586f3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts @@ -0,0 +1,164 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 }, ''); + + const isDevice = this.config.type === ItemType.DEVICE; + const resolution$: Observable<{ installed?: IotHubInstalledItem; count?: number }> = isDevice + ? this.iotHubApiService + .getInstalledItemCounts(ItemType.DEVICE, { 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..382bd7a797 --- /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 { 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: TbIotHubItemsPageComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'item.type-alarm-rule-plural', + itemType: 'ALARM_RULE', + 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..b586fc613c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.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 { 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 { 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 + ], + 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..43a7c91b53 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: 'store', + 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/widget/widget-types-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-types-table-config.resolver.ts index 811d3e56e8..dfca3ed83c 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: 'store', + 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-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts index 0b7271966e..e2b1fb28bc 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts @@ -102,8 +102,7 @@ export class EntitySelectComponent implements ControlValueAccessor, OnInit { const entityTypes = this.entityService.prepareAllowedEntityTypesList(this.allowedEntityTypes, this.useAliasEntityTypes); - let defaultEntityType: EntityType | AliasEntityType = null - + let defaultEntityType: EntityType | AliasEntityType = null; if (entityTypes.length === 1) { this.displayEntityTypeSelect = false; defaultEntityType = entityTypes[0]; @@ -148,7 +147,7 @@ export class EntitySelectComponent implements ControlValueAccessor, OnInit { if (this.filterAllowedEntityTypes === false) { if (this.allowedEntityTypes?.length === 1) { this.displayEntityTypeSelect = false; - this.entitySelectFormGroup.get('entityType').setValue(this.allowedEntityTypes[0]) + this.entitySelectFormGroup.get('entityType').setValue(this.allowedEntityTypes[0]); } else { this.displayEntityTypeSelect = true; } 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 e9063b3be2..6c41d365f5 100644 --- a/ui-ngx/src/app/shared/components/toggle-header.component.html +++ b/ui-ngx/src/app/shared/components/toggle-header.component.html @@ -44,14 +44,18 @@ (ngModelChange)="valueChange.emit(value)"> @for (option of options; track trackByHeaderOption($index, option)) { - {{ option.name }} - @if ((option.error$ | async) && value !== option.value) { - - warning - + @if (option.template) { + + } @else { + {{ option.name }} + @if ((option.error$ | async) && value !== option.value) { + + 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/chip-overflow.directive.ts b/ui-ngx/src/app/shared/directives/chip-overflow.directive.ts new file mode 100644 index 0000000000..7ed84f10e7 --- /dev/null +++ b/ui-ngx/src/app/shared/directives/chip-overflow.directive.ts @@ -0,0 +1,174 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { AfterViewInit, Directive, ElementRef, Input, NgZone, OnDestroy, Renderer2 } from '@angular/core'; + +@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; + + 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 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++; + } + } + + if (hiddenCount > 0) { + this.renderer.setProperty(this.overflowEl, 'textContent', this.overflowTemplate.replace('{n}', String(hiddenCount))); + this.renderer.setStyle(this.overflowEl, 'display', 'inline-flex'); + } + } +} 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..f4bd39e72b --- /dev/null +++ b/ui-ngx/src/app/shared/directives/photoswipe-gallery.directive.ts @@ -0,0 +1,219 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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' + + ' max-width: 90vw;\n' + + ' max-height: 78vh;\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 {\n' + + ' width: 40px;\n' + + ' height: 40px;\n' + + ' margin-top: 16px;\n' + + ' margin-right: 16px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--close .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/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index afc68fdfcd..273fa9ed4b 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -71,11 +71,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/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index 4ee4674c75..614dd04878 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -694,7 +694,8 @@ export const baseDetailsPageByEntityType = new Map([ [EntityType.WIDGET_TYPE, '/resources/widgets-library/widget-types/details'], [EntityType.OAUTH2_CLIENT, '/security-settings/oauth2/clients/details'], [EntityType.DOMAIN, '/security-settings/oauth2/clients/details'], - [EntityType.MOBILE_APP, '/mobile-center/applications'] + [EntityType.MOBILE_APP, '/mobile-center/applications'], + [EntityType.CALCULATED_FIELD, '/calculatedFields'] ]); export interface EntitySubtype { 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..0f9c791f3d --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-creator.models.ts @@ -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. +/// + +export interface CreatorView { + id: string; + createdTime: number; + displayName: string; + contactEmail: string; + website: string; + description: string; + avatarUrl: string; + githubUrl: string; + linkedinUrl: string; + twitterUrl: 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..25ac09d161 --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts @@ -0,0 +1,99 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData } from '@shared/models/base-data'; + +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 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; +} 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..6451bc5d52 --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts @@ -0,0 +1,72 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +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'] + ] +); + +/** + * 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..bd6f29cdb4 --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts @@ -0,0 +1,187 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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[]; +} + +// 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/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index cefc1c8824..d6da51eb85 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'; @@ -242,6 +242,7 @@ import { MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS } from '@angular/material/button-togg import { RgbaInputComponent } from '@shared/components/color-picker/rgba-input.component'; import { HslaInputComponent } from '@shared/components/color-picker/hsla-input.component'; import { InputChangeDirective } from '@shared/components/color-picker/input-change.directive'; +import { PhotoSwipeGalleryDirective } from '@shared/directives/photoswipe-gallery.directive'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -401,6 +402,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) MarkdownEditorComponent, TruncateWithTooltipDirective, ContextMenuDirective, + ChipOverflowDirective, + PhotoSwipeGalleryDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -422,7 +425,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) EntityGatewaySelectComponent, ContactComponent, OtaPackageAutocompleteComponent, - WidgetsBundleSearchComponent, CopyButtonComponent, TogglePasswordComponent, WidgetButtonToggleComponent, @@ -675,6 +677,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) MarkdownEditorComponent, TruncateWithTooltipDirective, ContextMenuDirective, + ChipOverflowDirective, + PhotoSwipeGalleryDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -697,7 +701,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 9fb2d25cf8..d30b06cf70 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, @@ -1143,55 +1168,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 }, @@ -1283,15 +1316,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/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-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index b1a3996e3c..605ff5ecc0 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -83,7 +83,8 @@ "upload": "Upload", "delete-anyway": "Delete anyway", "delete-selected": "Delete selected", - "set": "Set" + "set": "Set", + "retry": "Retry" }, "aggregation": { "aggregation": "Aggregation", @@ -4175,7 +4176,261 @@ } }, "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", + "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.", + "upgrade-instance": "Upgrade instance", + "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}}'", + "select-entity-for-alarm-rule": "Select entity for alarm rule '{{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}}'.", + "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!", @@ -7331,6 +7586,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", @@ -7361,6 +7617,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", @@ -10027,6 +10288,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/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 bb5a76b6e9..10e8d94661 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -741,6 +741,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 922e7b3290..914e20e4ee 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 93422abfde..acd141bcb1 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -1115,6 +1115,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 6b349cfd2f..eef5bc1c4d 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -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"