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..3ba7c9825a --- /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/dashboards_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}" + }, + "scada": false, + "tags": [ + "iot hub", + "widgets", + "solution templates", + "device library", + "marketplace" + ], + "resources": [ + { + "link": "/api/images/system/dashboards_system_widget_image.png", + "title": "\"Dashboards\" system widget image", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "dashboards_system_widget_image.png", + "publicResourceKey": "GoKYeghXwrpNNyy3oli0Z9wrApC0EeSY", + "mediaType": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAByFBMVEXj4+Pj4+Pk5OTh4eHh4eHg4ODg4OAAAAD////w8PD7+/vk5OT9/v4wVoD3+vr5+vry8vL19fXz8/MuVX/8/Pzq6urp7vGdr8LY2Nji4uLu8/VHaI7g5OjV1dXn5+jd3d3b29vGxsbf39/4+PiXl5f39/fv7+/u7u7MzMzIyMilpaXt7e3Q0NCgoKDm5ua6urqcnJzs7Oy1tbWenp7p6enh4eHU1NTBwcGUlJSSkpLX19fLy8u4uLjOzs6tra3T09OampqNjY2ioqLKysrPz8/Dw8OQkJDExMSvr6+9vb2np6fa2tqxsbG3t7eZmZmJiYmrq6upqanFxcW+vr764gTS0tKGhoa/v7+zs7OLi4v0+Pi4xtOmtsd9fX3AwMCRkZHy9fe0tLStvc2brsFhfp75/PuYq794eHjJ1N28ydWDg4OJoLfc5OnN1t7G0NuqusvR2+Oywc+EnLR9la9rhqRmgqHCztmitMaTp711j6twiqft8fPl7O/Z4Ob//vn//e6No7k9YYgzWIJpaWn75iT98o398YHh6Oz++9n99aZXdphRcZT873JwcHD++95Ja49HaY776Tz64w7++c387WD861Lh4uL++MX7dhrIAAAACHRSTlP289i6skRBAFblPJYAABQbSURBVHja7NZNb9owGMDx9P1xbMnYSiQ4AA0hBQwGN4EVt4xCtlKKBGyreOlliO//JeZOSCBV6mGHKdD+D48UyTn8pOSRrYuzEwvveUen5xfW+TGi9n7nUHR8Zp0iOIDQiXVE4QCiloUdOIBsbGEbDqBPSNJ6D+JTM6gPbyNB4vC7kMI69HZWsRN6ZnqhA2+iw9rOA7p6jRP4f70P8ebjWJWAIgrgp2wnDlkAY4mYQXEziBmEsYCw6uQXBKnNe75rwoVcgiDtAA0emyM1qtaF7LqiHcnrxlSqPG1MVNZeqKiWkmqR7cm5h4Uc8s2HRgir42aCIJo78bLwlF55C51uFIRorSoNjUfi6yC/KF4/z4aT3HydUet83wt76Y4Lm4J66SpREEZVJzMaTm9yQoVV0SHRTUOAp5aSZF5a4x+yjft5vsraPa8VqzHfOghKEmSA7/qXyyijn7/cpfsV0XEMpFhaC0/nPD2bpjsav1z6k6fc4LH2/X6a3TogSZDyVPe+kVk7bj/cKiFx97fzs/Kgo+It6xaLZRQrpXCvAGUd6fK9iqW7dQBK0M9u9igDIKUqDyDVZMACYL7Pq1cEqGsGczm3uW1OlDgFZE78Db06wE/Q+v3HCIEE9CGuKHvVJ+QPu+X2kzYUBvDnKnRKpQVUbsaikWA0cyY458MkxswneFjSnhDWW9q09E5vQAK8kMCD/skrOJeg2NEuLnvwl/bradqH75fvnO+c/413kX9GCw2iFSASe3tCeMjMdhCM/IpIbG3/ZGdVsnhiIwqbe1sr14NBgmFaS0W2Em1AY6vCYUIWisb+ilVBH5B4EMg2ukxkLQtovtfNrAjap7h2RJPN1UzQ7SgiWx85sgWHos/xkU3eTCS2AUg4LJLTj2jyIawIgjxFPwSJrLWxWT3QMQH/hmg9JSy9cBiTGT9aAFqkVMwnIRx6QfFs8T0RCynysD3LfNqJIyPzHgkQ2QRtPzFWG8xEMn5otWDV7s5kCIIkWSLjP/2rRTx+5T3U/7HL7UALXJ8XvlfK6xB+AkF4CceP5iOodFCGFtkKJzIk7VE8PjLAaDTUOgEisRO6NxOhdD9RRadRXhR5cWqxGVviKVJAMZ1sUxI2tiVB51BVN0wU9lHkjYWCNHdLqd0vhexFvZws3ORvCpWD2m6pfH76XORDKBGEka37uAkEMHRAO1Bkh+4+iaC01hFEEVikN4YJETgKhikeZZIGNSFduUN5QMeURxGSXBCBco1aPtnIV8rV0+Lpt/SPdLmZ+pxqpGvXz1twuIo4ImYMLY8GrsbIwSLYLxGY7YkOQ/KiYVmuvw4E15UwzGEGomoYojuwJqJOuarqLhMprSfzx2e1dLp2eFc8zlaaeP30sF6uJy7LkUXmGfOUoE3loWs73OgPU4t7nFqmJggdhaFsxeDkjgrDPcYgaEzzKKXrMO0pJZlAUQcDeunUOqpd5Y5zterVbeoud5xdb5Q+NXPV4m3+9nlF9kKJuILpyaYgKoonGAFTy2cf8LCPStN2n6JtVcVsCcVkGCZInuX5jOYIhCpkNAnmOdCTMFvLLFvs6xeFKp6/zl5+vazsniRT+NHVYapULVwU/2qxdxgk7k0mjm4OjYF+jwS2X3rebFmWnUX4FdjFkQWgEERvvwji33Pmj8ANMRFlQ+Qib4jvR5QVDo0/2bWzF6eBOIDjr79JBiYJGUweJpOrTdpm06b3ld62unXXs+4uuoooCIKvPuqD66Kg4oH675quV1VwXWGlrv22CW1TAp9Ck5mQ8vVT90/AoBFAlY4yjL99+5oh/fEw/lghgAX5REyslm+q++DOq19Pdf+Diw//ZivIsrWCLFsryLL170Kwgo8CwXj+/Jth/OsPhM9vZSt1FEh5CqKCVcAq/ksmuQIAAoCKCcWQPBBKXuO5CNRkxaPP36sdCaJfplokTiOxWdW4UzElOO6UKgBthkizRUsBUQnDsqGFIbKRYWhGWsvQuSlNEoiDfh8iNtmI2gFrhoHR5ZnAhuNOYYD1bhwG3GMCiNq0YvqzgGsWs4fdoEqbB5Cqj2qepR0BUjHa4ajJNI8hxkdiGo47ZaQoMzOmdkwZAdkKRTOwabpmn/aCCtXELoUk4k82tgLh9yESwhXF1oxIpg6Vda7AcUdMTkVTlPh7okuADSIhz6RCmApVz8zJpinDPNJaj4UTcfglIjkZ55ETc0JcQZa8FWTZWkGWrRVk2TrBkEcP4V/sJ8jD/Q9/6UZZfLyQp3vP4YfsJmANwTxnuMExfGnWFXU1BQfhmAN436afmCmEwOc0DxaS/fkGtYbgILWqwGKUNSl8KWXgP4Q8ubL3FL4Lu1dTOMjJXgrw9uB0nREHK5hQBOe2dR6vSzI4DpB7fQHOPc7hnIcFJZJxQM+0MaHJxjQKohSKVJCpIgCA/1gDkMRGKKdzDpXVGMkoWvh5Ah0lvBQiKQk7hupIMgBgRVJBkGQBUkg4DPL2yf7zvStX3u3vP3sEXwvdHsMdXhhPAfUpbF2w8mTNaBc29WLRv5y9m1lTbo2A9Hdteecurbmuz3YGbvpMfO887RQKRv5erWNn18Y3vN5anwOo64MzYDdKd7m7tuMWeumLeq80zsDncGvEHQDxUjAZWSaqpk8HVgKLfH8qBPFkijJ+QA6DvL7yqTdPFv7x+c7WOimwmwEG2pdgOGh3nL4+nNzcKha3NrprTs9yDXDc0rlRr282DM2tuVKdlXgnHxRI8daZW6Sg9ap+4XI2tdkFEBusj84VpfH0vNjelMaay8+bly5+hei2zwDsrnCJRi0aRxkhsAEQ16xyS9UY43gUHgKBl+8OHHvPFhypnb67a5RYnQOgvpG48h3SNws3NhPIjTbLQu1eFoNTbzUa1pg1ZNq4PFB7o5LdOTvMwqSzPcEFrWSzQvEcDBLIxm7pbuZCTXW1vldbV+tsk/dp6ytE9SBqYahoOJNODxNIDN0EYnZtK/TBZDMdZpVDIIlkbw7Zh4W0Qk5an2S1ng0A7V7xKg/GG7vmePt8Pt8+nbd3wuhFBsBxKxfP00aYPZPdbmWF0mygbxT0et6dFS08MAeV5ppZz+9WIdVjij/w68k+6t7kgrCplXid+p2vkJkWTwF0DbcQ8qMgF2BWAWA+m0iZ6YQZQzOjHAp5egB5DgvRMFlEXbFlACDB2fEMdy3mVGqBUS5HZSEOqZsDwFzOiapJlGHspHVsI132hrIxaWIjBxVFlyU7ZbWvmkBMNVmcqsUUTiIR82TP3EmL8CVZD4VkrWCPkDRBBAGSAYhoUMHglgm0guBQyMf2zmy3YSIKoOzcsUcae2RL9oN3J/ESx4mdPWn2fU8rujy0VZ8q9SPgASGBBEggPhgnQEsKKjtqqx5HXuIbe46sOJOrm8lX33/3xQ/ff/fZk3kVOIRsphj+DNykN7HgHyBo2SP6526/X3z9JXz61Q/fwl8AcwL8OVJpBv4RmGfgaZHD/slnn8GL4xV3Gl8obyLPjTeR58abyHPjTeS58YpFrJc5cM1vRHCRg0NwBmH4HXCGwtMw4sELBQIJRMUHNRgpAo8QBACLCnAPYf6GCM8enh1IlLeP4ZB5EYDcxHkXnsSPedijyZAw9HZnEmMCe9B0v9LQ9xv6w3d2Q4ZC38k+NEv1/6IIFgQiIpEygvAgI97lcgvWaB73z3P4aNEmR1Nl3SPA3hXlc1PHip+dNjPJHt06DrV9HPtT3MkndsHh5MjIVG0KQOy7pIXLSa1gNCvOuUZdNIzOrLLsL0M12t63SB1oILPWmALOSYos6YyIfF0pAoCv6ITRJJPwc4l/QoTP5UQf86LKFh4ubCqIXeScupeb86C4mF7Ipy3WXmDgg7jPV2aMXe6G2227Nq1Jl5XxPi4utsIL83Si13obsRbax55HAKReswneJBpJp85pEK7ka+1yXP1kM2zF0djYwD26BhiKDgHcr0vTnKtKstIoGcm2LvdLssNeoUFJy+InrghSeQzA+EXyqwjUjr3jAHeUUlVttS61rgqbBgBB2U5rOhOqbTst2rO7zbVjk8bPcZvWrTIqZr75htVvt9VheAYAs9FmXRz54kqJYdtP29pIs4WFsa2Ug61pTg5EgDo+AD5CyMWSvJuIkQJsKsa8LGOn2GcsN/XUe4RXCQASBXiA9fx5dxowK8WsjmvSnZ6IbK8AdA+V19O4eFquSY18aJtZ0071f4o7TuLKXbVw0fLMdbmeWUYA6W50te7bbaWm5PG2X7D1rm4zE2M2dcotT/uVyFwD2pAJTkTSaRfXdyIl65iH1FgcSJJCx77ri33ylIhQJERACH4FWQZ5VzoRmnIxYoPYNhdpcILkqFHQMTJBYJsXQV7iNsF5cUnm032cGgQrbZHmltyNXom3qhL7YIYAWaOe703kEI91bqku1KXg1oflbBAPMlm4J6cCa5QVBFijVMKsuJtInQCWnL5MB0chVbMDEZ4SSakFVRQLGH4NpYAZIBgTIJQIBP9856Q87J6gKzZZEbgk5CBuvyIwmKYA80KyBoAJ8DyTBDHJvt0EjMAIwFMAfFjsRgjBuzUsAN4Dwm4PETDV6oYFhMCTIohFTKqYE+AvwCgp+B9hZD39x58jvLXzpi+w0/KK+1ovlDeR58abyHPjTeS58Sby3HjFIs9jJLZ/LoKRBX+ZZzCI3W9ECJuBAwoa5GQoifud5OHsigmA+ntr5hd3LHFwD+denRnp3x2zlvQrRzz8AjvEwNCHgBwCplQX4R6f+xsiSCwS+DVmh/RsEiiMyMO0KWCfQgrxIFysGYg+98FHYLQEnCwIop9kLM7HwBeoAHSwGg04vsBg3ucApzOY4X1eOK8ApE+nmxnBfhoDJ+K2XaSkcN8i6UwDtiEbKQDCU4amEhHhpyFgBY5PRCnBmPL4CRGLQxnVyogHwxhwVblaK1Vz50Es1y7EMM6Xtt0sCPallupeikYnX87fqW6cN5frRmR6Qc3wV3FX21exgOjFTbkbj3LZvO1Io95KO7UtyHQRqUqNwP5EtvMn7dvYzvkPv9+eJyJHopsCnHVd9xODM1XtuJHFgPWs6yN3cMyXjhryEyJEZVEKM1SU0YMI7i1mk0VMK+P1YBqJl2f2xB5iYOLeYmCP9LtmsHW33GkYb7zFrvam3fCmHr9q/yQyqVXWjQsUj6VKL+jnadWcVCARyUDgKoY9c0aNdnaE8kbmIIuCKobDAHbEwpiUZUmu6/xxIpYrubqkE7fgUuQ+lXxgCmkMYIkUwwMnt0b2NkqvjGvnrMlehqHUKSehnXH1ejiSLm9Cpb9Bl1GozCo4r1RNxYsi6P0s4tWupuWqMDuZnG8CdwN5c3G8E0Gpa8Vbxh469rZHHSEYHorMdabh77Mov6SDLIMH4piDXTqoIfZJ6ul0EKMSgHQafo32TS5zWy5ehKeOYefi5YkZKwBCXN+e7otopnq5xnqLUN8McU/qlOrb+ujkMnGF6QIce3g8XzGbK2/WiRstHMjNngXodOltqb2wZ40gsp2Y8Yz0r0RMkBumSwFnEWpgiZ2zc3mXx7IMaSzlXD1E7bqi4KdESJH3KToUsepE0FNYGX9SoG6mMDziSxwANjlfZnRC3T61nCLn9nm2ACVq8hzLn0R3+k/1OIIyZKmGWT9TcTSfhRKfcXkgWVexQDweyFa2onImln0C91AecLGEADAiJIMpv5uwLwAgU+WIKV3xRGYJPCVC2WK6kFMx/CNQK5hY8B9By05d+MPbLyoQwFRk4J+RyjDwn0F4DH8oIuD9/AX2Ul5xp/GF8iby3HgTeW68iTw33kSeG69Y5PUU1dBHEQQEBgjze9kQ/LgjmhLgAYIo3INVAoB8eEwawSMESgCAp3DAPy+q0SKoNGFahwRVfmiYtwBQLtDPGaOfwEYBHgirvci6P26cATi7gcdEZ3AIKTsOApDncMDfLKrBDzJqDdXW6ao2jxxqX6dzTSNVDnUQurcIOp/7fjhEwXq3SOlNJSuWjWURN5rDDIDXTOcNYjRLOHktd70MUThqfoILzRMkHN2UiXOSk8JRCIekTKGsA5RcScYAVJ/zmJVM3tKk9J8W4dVfimp84T7CWh118o4tnldGZS9ftEN7aHdYYOILV71Ys/FNsGxdF5LFmdc1g/poMdt8MnJPFYBZBQxPay2r6uhkq11EXjRdu9d6EC626mS6lteT9rV70YRHMPq4kIiM2QoH2DSzimjkjlmlXjJSf/qKoByPAQRfteCByXV0NdqmosWpcxXmbmfVMF8GYPJRELWupVuvc+O00K1n3yQNzyvVkuLdROC19yJDL7eZnRYWcajavrOZTmB2XuUzNbM1udRs3/CgFcIhmFEbMux/mpQBLLfdgS6BYroU99N//j3CF5MN7vCrrvN5vfT5MNfVas5JS1235+ouacWsjuxLqat1GxLb8NIXTp31rvbpoLJX6aWriasXFVb95mywlsf1rmMX+pswj2y3qrU7w6p0p9sFyc7km3AIrVvSPBGp70QEV54P5CPrSB6wnEHhgL9YVAOFPMcHLN+LV5Jus5XOVg5LSWRTbpzTCXICT1M7Zj/wSsMyDuWoKF+lg96tAnBi96aM1ol7hVZ+pi453fikE7QsJwikYie/YpeITPKrLBwiKE6WAogslihgs+FIVjt7lksPBjL+8yIp1VfFwmFRDSYYkofFEQFTBjh+twXJDAu7NUoBeAKUB0YABu+eTkfRdRFgl7YF4JOdAiK7eEYgSADgf07gEgxCclB4BLaE3TzZuT8Ng1NzzUDAEDjgj4tq+F1RzT+CZEMd/j2wqj/6SHgrqnkBvIk8N95EnhuvRkRg33nndfxB8LvvvP86/rL5g3c+fA8RgXnZEPTeR+98/NEH77IvnHfe//DjHwHFYi6vFg5rGAAAAABJRU5ErkJggg==", + "public": true + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 66243461e2..c9e982df6d 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -80,3 +80,19 @@ DROP TABLE IF EXISTS calculated_field_link; ANALYZE calculated_field; -- REMOVAL OF CALCULATED FIELD LINKS PERSISTENCE 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 ee2e871d6e..591228e341 100644 --- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -76,6 +76,9 @@ public class SystemInfoController extends BaseController { @Value("${debug.settings.default_duration:15}") private int defaultDebugDurationMinutes; + @Value("${iot-hub.base-url:https://iot-hub.thingsboard.io}") + private String iotHubBaseUrl; + @Autowired(required = false) private BuildProperties buildProperties; @@ -170,6 +173,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..40c7a824a5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java @@ -0,0 +1,607 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.Dashboard; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.iot_hub.CalculatedFieldInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.DashboardInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.DeviceInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.RuleChainInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.SolutionTemplateInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.WidgetInstalledItemDescriptor; +import org.thingsboard.server.service.solutions.SolutionService; +import org.thingsboard.server.service.solutions.data.solution.SolutionInstallResponse; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.NodeConnectionInfo; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.dao.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.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 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 "RULE_CHAIN" -> installRuleChain(tenantId, fileData); + 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); + 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 RuleChainInstalledItemDescriptor installRuleChain(TenantId tenantId, byte[] fileData) 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()); + return descriptor; + } + + 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 "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 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); + } else if (descriptor instanceof RuleChainInstalledItemDescriptor rd) { + return calculateRuleChainChecksum(tenantId, rd); + } + return null; + } + + private String calculateCalculatedFieldChecksum(TenantId tenantId, CalculatedFieldInstalledItemDescriptor descriptor) { + CalculatedField cf = calculatedFieldService.findById(tenantId, descriptor.getCalculatedFieldId()); + if (cf == null) { + return null; + } + String content = (cf.getName() != null ? cf.getName() : "") + + (cf.getType() != null ? cf.getType().name() : "") + + (cf.getConfiguration() != null ? JacksonUtil.valueToTree(cf.getConfiguration()).toString() : ""); + return sha256(content); + } + + private String calculateDashboardChecksum(TenantId tenantId, DashboardInstalledItemDescriptor descriptor) { + Dashboard dashboard = dashboardService.findDashboardById(tenantId, descriptor.getDashboardId()); + if (dashboard == null) { + return null; + } + String content = (dashboard.getTitle() != null ? dashboard.getTitle() : "") + + (dashboard.getConfiguration() != null ? dashboard.getConfiguration().toString() : ""); + return sha256(content); + } + + private String calculateWidgetChecksum(TenantId tenantId, WidgetInstalledItemDescriptor descriptor) { + WidgetTypeDetails widgetType = widgetTypeService.findWidgetTypeDetailsById(tenantId, descriptor.getWidgetTypeId()); + if (widgetType == null) { + return null; + } + String content = (widgetType.getFqn() != null ? widgetType.getFqn() : "") + + (widgetType.getName() != null ? widgetType.getName() : "") + + (widgetType.getDescriptor() != null ? widgetType.getDescriptor().toString() : ""); + return sha256(content); + } + + private String calculateRuleChainChecksum(TenantId tenantId, RuleChainInstalledItemDescriptor descriptor) { + RuleChain ruleChain = ruleChainService.findRuleChainById(tenantId, descriptor.getRuleChainId()); + if (ruleChain == null) { + return null; + } + RuleChainMetaData metadata = ruleChainService.loadRuleChainMetaData(tenantId, descriptor.getRuleChainId()); + StringBuilder content = new StringBuilder(); + content.append(ruleChain.getName() != null ? ruleChain.getName() : ""); + content.append(ruleChain.getType() != null ? ruleChain.getType().name() : ""); + if (metadata.getNodes() != null) { + for (RuleNode node : metadata.getNodes()) { + content.append(node.getType() != null ? node.getType() : ""); + content.append(node.getName() != null ? node.getName() : ""); + content.append(node.getConfiguration() != null ? node.getConfiguration().toString() : ""); + } + } + if (metadata.getConnections() != null) { + for (NodeConnectionInfo conn : metadata.getConnections()) { + content.append(conn.getFromIndex()); + content.append(conn.getToIndex()); + content.append(conn.getType() != null ? conn.getType() : ""); + } + } + return sha256(content.toString()); + } + + private static String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (Exception e) { + throw new RuntimeException("Failed to calculate SHA-256", e); + } + } + + @Override + public InstallItemVersionResult registerDeviceInstall(SecurityUser user, String versionId, DeviceInstalledItemDescriptor descriptor) { + TenantId tenantId = user.getTenantId(); + log.info("[{}] Registering device package install for version: {}", tenantId, versionId); + + try { + JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId); + 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 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..b68fe5515c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java @@ -0,0 +1,1675 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.gson.JsonParser; +import jakarta.annotation.PreDestroy; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.adaptor.JsonConverter; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DashboardInfo; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.HasName; +import org.thingsboard.server.common.data.alarm.AlarmInfo; +import org.thingsboard.server.common.data.alarm.AlarmQuery; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.iot_hub.SolutionTemplateInstalledItemDescriptor; +import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery; +import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.configuration.BaseCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.debug.DebugSettings; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.AlarmId; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.cf.CalculatedFieldService; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.device.DeviceConnectivityService; +import org.thingsboard.server.dao.device.DockerComposeParams; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.action.EntityActionService; +import org.thingsboard.server.service.entitiy.asset.TbAssetService; +import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService; +import org.thingsboard.server.service.entitiy.device.TbDeviceService; +import org.thingsboard.server.service.entitiy.edge.TbEdgeService; +import org.thingsboard.server.service.entitiy.entity.relation.TbEntityRelationService; +import org.thingsboard.server.service.rule.TbRuleChainService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.system.SystemSecurityService; +import org.thingsboard.server.service.solutions.data.CreatedAlarmRuleInfo; +import org.thingsboard.server.service.solutions.data.CreatedCalculatedFieldInfo; +import org.thingsboard.server.service.solutions.data.CreatedEntityInfo; +import org.thingsboard.server.service.solutions.data.DashboardLinkInfo; +import org.thingsboard.server.service.solutions.data.DeviceCredentialsInfo; +import org.thingsboard.server.service.solutions.data.EdgeLinkInfo; +import org.thingsboard.server.service.solutions.data.SolutionInstallContext; +import org.thingsboard.server.service.solutions.data.UserCredentialsInfo; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.thingsboard.server.service.solutions.data.definition.AssetDefinition; +import org.thingsboard.server.service.solutions.data.definition.AssetProfileDefinition; +import org.thingsboard.server.service.solutions.data.definition.DeviceDefinition; +import org.thingsboard.server.service.solutions.data.definition.CustomerDefinition; +import org.thingsboard.server.service.solutions.data.definition.DashboardUserDetailsDefinition; +import org.thingsboard.server.service.solutions.data.definition.CalculatedFieldDefinition; +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; +import org.thingsboard.server.service.solutions.data.definition.UserDefinition; +import org.thingsboard.server.service.solutions.data.definition.DashboardDefinition; +import org.thingsboard.server.service.solutions.data.definition.DeviceProfileDefinition; +import org.thingsboard.server.service.solutions.data.definition.EdgeDefinition; +import org.thingsboard.server.service.solutions.data.definition.RelationDefinition; +import org.thingsboard.server.service.solutions.data.definition.ReferenceableEntityDefinition; +import org.thingsboard.server.service.solutions.data.definition.TenantDefinition; +import org.thingsboard.server.service.solutions.data.emulator.AssetEmulatorLauncher; +import org.thingsboard.server.service.solutions.data.emulator.DeviceEmulatorLauncher; +import org.thingsboard.server.service.solutions.data.names.RandomNameData; +import org.thingsboard.server.service.solutions.data.names.RandomNameUtil; +import org.thingsboard.server.service.solutions.data.solution.SolutionInstallResponse; +import org.thingsboard.server.service.solutions.data.solution.TenantSolutionTemplateInstructions; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.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); + return new SolutionInstallResponse( + new TenantSolutionTemplateInstructions(ctx.getSolutionInstructions()), + false, + ctx.getCreatedEntitiesList() + ); + } + } + + private void waitForTelemetryCompletion(Set> futures) throws InterruptedException { + CompletableFuture all = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); + try { + all.get(); + Thread.sleep(futures.size() * 100L); + } catch (ExecutionException e) { + throw new RuntimeException("Telemetry processing failed", e.getCause()); + } + } + + private void rollback(TenantId tenantId, String solutionId, SolutionInstallContext ctx, Throwable e) { + List createdEntities = new ArrayList<>(ctx.getCreatedEntitiesList()); + Collections.reverse(createdEntities); + for (EntityId entityId : createdEntities) { + try { + deleteEntity(tenantId, entityId, ctx.getUser()); + } catch (RuntimeException re) { + log.error("[{}][{}] Failed to delete the entity: {}", tenantId, solutionId, entityId, re); + } + } + ctx.getCreatedEntitiesList().clear(); + ctx.getSolutionInstructions().setDetails(e.getMessage()); + } + + private String prepareInstructions(SolutionInstallContext ctx, HttpServletRequest request) { + + Path instructionsFile = ctx.getTempDir().resolve("instructions.md"); + if (!Files.exists(instructionsFile)) { + return null; + } + String template; + try { + template = Files.readString(instructionsFile); + } catch (IOException e) { + log.warn("[{}] Failed to read instructions.md", ctx.getTenantId(), e); + return null; + } + + String baseUrl = systemSecurityService.getBaseUrl(ctx.getTenantId(), null, request); + + // Inject edge instructions first, then run the full replacement logic on the combined string + if (template.contains("${edge_instructions}")) { + if (ctx.getCreatedEdges().isEmpty()) { + template = template.replace("${edge_instructions}", ""); + } else { + Path edgeFile = ctx.getTempDir().resolve("edge_instructions.md"); + String edgeTemplate = Files.exists(edgeFile) ? readFileContent(edgeFile) : ""; + template = template.replace("${edge_instructions}", edgeTemplate); + } + } + + template = template.replace("${DOCS_BASE_URL}", docsBaseUrl); + template = template.replace("${BASE_URL}", baseUrl); + + TenantSolutionTemplateInstructions solutionInstructions = ctx.getSolutionInstructions(); + + if (solutionInstructions.getDashboardId() != null) { + template = template.replace("${MAIN_DASHBOARD_URL}", + getDashboardLink(solutionInstructions, solutionInstructions.getDashboardId(), false)); + if (solutionInstructions.isMainDashboardPublic()) { + template = template.replace("${MAIN_DASHBOARD_PUBLIC_URL}", + getDashboardLink(solutionInstructions, solutionInstructions.getDashboardId(), true)); + } + } + + for (DashboardLinkInfo dashboardLinkInfo : ctx.getDashboardLinks()) { + template = template.replace("${" + dashboardLinkInfo.getName() + "DASHBOARD_URL}", + getDashboardLink(solutionInstructions, dashboardLinkInfo.getDashboardId(), false)); + if (dashboardLinkInfo.isPublic()) { + template = template.replace("${" + dashboardLinkInfo.getName() + "DASHBOARD_PUBLIC_URL}", + getDashboardLink(solutionInstructions, dashboardLinkInfo.getDashboardId(), true)); + } + } + + if (template.contains("${GATEWAYS_URL}")) { + template = template.replace("${GATEWAYS_URL}", "/gateways"); + } + + // Device list and credentials + StringBuilder devList = new StringBuilder(); + devList.append("| Device name | Access token | Owner |"); + devList.append(System.lineSeparator()); + devList.append("| :--- | :--- | :--- |"); + devList.append(System.lineSeparator()); + + for (DeviceCredentialsInfo credentialsInfo : ctx.getCreatedDevices().values()) { + devList.append("|").append(credentialsInfo.getName()) + .append("|").append(credentialsInfo.getCredentials().getCredentialsId()).append("{:copy-code}") + .append("|").append(credentialsInfo.getCustomerName() != null ? credentialsInfo.getCustomerName() : "Tenant"); + devList.append(System.lineSeparator()); + + template = template.replace("${" + credentialsInfo.getName() + "ACCESS_TOKEN}", credentialsInfo.getCredentials().getCredentialsId()); + + if (credentialsInfo.isGateway()) { + template = template.replace("${DOCKER_CONFIG}", + prepareDockerComposeFile(ctx.getTenantId(), ctx.getSolutionId(), baseUrl, credentialsInfo.getCredentials().getDeviceId())); + } + } + + template = template.replace("${device_list_and_credentials}", devList.toString()); + + // User list (without user group column) + StringBuilder userList = new StringBuilder(); + userList.append("| Name | Login | Password | Customer name |"); + userList.append(System.lineSeparator()); + userList.append("| :--- | :--- | :--- | :--- |"); + userList.append(System.lineSeparator()); + + for (UserCredentialsInfo credentialsInfo : ctx.getCreatedUsers().values()) { + userList.append("|").append(credentialsInfo.getName()) + .append("|").append(credentialsInfo.getLogin()).append("{:copy-code}") + .append("|").append(credentialsInfo.getPassword()).append("{:copy-code}") + .append("|").append(credentialsInfo.getCustomerName() != null ? credentialsInfo.getCustomerName() : ""); + userList.append(System.lineSeparator()); + } + + template = template.replace("${user_list}", userList.toString()); + + // Edge detail URLs + for (Map.Entry edgeLinkInfoEntry : ctx.getCreatedEdges().entrySet()) { + EdgeLinkInfo edgeLinkInfo = edgeLinkInfoEntry.getValue(); + StringBuilder edgeDetailsUrl = new StringBuilder(); + if (EntityType.CUSTOMER.equals(edgeLinkInfo.getOwnerId().getEntityType())) { + edgeDetailsUrl.append("/customers/").append(edgeLinkInfo.getOwnerId().getId()); + edgeDetailsUrl.append("/edgeInstances/").append(edgeLinkInfo.getEdgeId().getId()); + } else { + edgeDetailsUrl.append("/edgeManagement/instances/").append(edgeLinkInfo.getEdgeId().getId()); + } + String edgeName = edgeLinkInfoEntry.getKey(); + String edgeDetailsPlaceholder = "${" + edgeName + "EDGE_DETAILS_URL}"; + template = template.replace(edgeDetailsPlaceholder, edgeDetailsUrl.toString()); + } + + template = replaceAlarmRules(ctx, template); + template = replaceCalculatedFields(ctx, template); + template = replaceCreatedEntities(ctx, template); + + return template; + } + + private static String replaceAlarmRules(SolutionInstallContext ctx, String template) { + StringBuilder alarmRules = new StringBuilder(); + + alarmRules.append("| Entity Profile Name | Alarm Type | Severities |").append(System.lineSeparator()); + alarmRules.append("| :--- | :--- | :--- |").append(System.lineSeparator()); + + ctx.getCreatedAlarmRules().entrySet().stream() + .sorted(Map.Entry.comparingByValue(Comparator.comparing(CreatedAlarmRuleInfo::entityName, String.CASE_INSENSITIVE_ORDER) + .thenComparing(CreatedAlarmRuleInfo::alarmType, String.CASE_INSENSITIVE_ORDER))) + .forEach(entry -> { + UUID key = entry.getKey(); + var alarmRuleInfo = entry.getValue(); + + String alarmType = alarmRuleInfo.alarmType(); + String link = alarmRuleInfo.getCfPageLink(key); + + String alarmTypeWithLink = "" + alarmType + ""; + + String profileName = alarmRuleInfo.entityId() != null ? + "" + alarmRuleInfo.entityName() + "" + : alarmRuleInfo.entityName(); + + alarmRules.append("|") + .append(profileName).append("|") + .append(alarmTypeWithLink).append("|") + .append(alarmRuleInfo.severities()).append("|") + .append(System.lineSeparator()); + }); + + return template.replace("${alarm_rules}", alarmRules.toString()); + } + + private static String replaceCalculatedFields(SolutionInstallContext ctx, String template) { + StringBuilder calculatedFields = new StringBuilder(); + + calculatedFields.append("| Entity Profile Name | Field Name | Field Type |").append(System.lineSeparator()); + calculatedFields.append("| :--- | :--- | :--- |").append(System.lineSeparator()); + + ctx.getCreatedCalculatedFields().entrySet().stream() + .sorted(Map.Entry.comparingByValue( + Comparator.comparing(CreatedCalculatedFieldInfo::entityName, String.CASE_INSENSITIVE_ORDER) + .thenComparing(CreatedCalculatedFieldInfo::name, String.CASE_INSENSITIVE_ORDER) + )) + .forEach(entry -> { + UUID key = entry.getKey(); + var cfInfo = entry.getValue(); + + String cfTitle = cfInfo.name(); + String link = cfInfo.getCfPageLink(key); + + String cfTitleWithLink = "" + cfTitle + ""; + + String profileName = cfInfo.entityId() != null ? + "" + cfInfo.entityName() + "" + : cfInfo.entityName(); + + calculatedFields.append("|") + .append(profileName).append("|") + .append(cfTitleWithLink).append("|") + .append(cfInfo.type()).append("|") + .append(System.lineSeparator()); + }); + + return template.replace("${calculated_fields}", calculatedFields.toString()); + } + + private static String replaceCreatedEntities(SolutionInstallContext ctx, String template) { + StringBuilder entityList = new StringBuilder(); + + entityList.append("| Name | Type | Owner |").append(System.lineSeparator()); + entityList.append("| :--- | :--- | :--- |").append(System.lineSeparator()); + + for (Map.Entry entry : ctx.getCreatedEntities().entrySet()) { + UUID key = entry.getKey(); + var entityInfo = entry.getValue(); + String link = entityInfo.getEntityPageLink(key); + String entityName = entityInfo.getName(); + + String name = link != null ? + "" + entityName + "" + : entityName; + + entityList.append("|") + .append(name).append("|") + .append(entityInfo.getType().getNormalName()).append("|") + .append(entityInfo.getOwner()).append("|") + .append(System.lineSeparator()); + } + return template.replace("${all_entities}", entityList.toString()); + } + + private String getDashboardLink(TenantSolutionTemplateInstructions solutionInstructions, DashboardId dashboardId, boolean isPublic) { + if (isPublic && solutionInstructions.getPublicId() != null) { + return "/dashboard/" + dashboardId.getId() + "?publicId=" + solutionInstructions.getPublicId(); + } + return "/dashboards/" + dashboardId.getId(); + } + + private String prepareDockerComposeFile(TenantId tenantId, String solutionId, String baseUrl, DeviceId deviceId) { + Device device = new Device(deviceId); + device.setTenantId(tenantId); + String containerName = "tb-gateway-" + solutionId.replace('_', '-'); + DockerComposeParams params = new DockerComposeParams(false, containerName, false, true, false, false); + try (InputStream inputStream = deviceConnectivityService.createGatewayDockerComposeFile(baseUrl, device, params).getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)) + ) { + return reader.lines().collect(Collectors.joining("\n")); + } catch (Exception e) { + throw new RuntimeException("Failed to read or process the docker-compose.yml file.", e); + } + } + + private void provisionRuleChains(SolutionInstallContext ctx) { + List ruleChainDefs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "rule_chains.json", new TypeReference<>() {}); + for (ReferenceableEntityDefinition entityDef : ruleChainDefs) { + Path ruleChainPath = ctx.getTempDir().resolve("rule_chains").resolve(entityDef.getFile()); + if (!Files.exists(ruleChainPath)) { + log.warn("[{}] Rule chain file not found: {}", ctx.getTenantId(), entityDef.getFile()); + continue; + } + JsonNode ruleChainJson = replaceIds(ctx, JacksonUtil.toJsonNode(ruleChainPath)); + + RuleChain ruleChain = JacksonUtil.treeToValue(ruleChainJson.get("ruleChain"), RuleChain.class); + ruleChain.setId(null); + ruleChain.setTenantId(ctx.getTenantId()); + String metadataStr = JacksonUtil.toString(ruleChainJson.get("metadata")); + RuleChainMetaData metadata = JacksonUtil.treeToValue(JacksonUtil.toJsonNode(metadataStr), RuleChainMetaData.class); + + RuleChain savedRuleChain = ruleChainService.saveRuleChain(ruleChain); + metadata.setRuleChainId(savedRuleChain.getId()); + metadata.setVersion(savedRuleChain.getVersion()); + ruleChainService.saveRuleChainMetaData(ctx.getTenantId(), metadata, tbRuleChainService::updateRuleNodeConfiguration); + if (ruleChain.isRoot()) { + ruleChainService.setRootRuleChain(ctx.getTenantId(), savedRuleChain.getId()); + } + + ctx.register(entityDef.getJsonId(), savedRuleChain); + log.debug("[{}] Rule chain provisioned: {}", ctx.getTenantId(), savedRuleChain.getName()); + } + } + + private void updateRuleChains(SolutionInstallContext ctx) { + List ruleChainDefs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "rule_chains.json", new TypeReference<>() {}); + for (ReferenceableEntityDefinition entityDef : ruleChainDefs) { + Path ruleChainPath = ctx.getTempDir().resolve("rule_chains").resolve(entityDef.getFile()); + if (!Files.exists(ruleChainPath)) { + continue; + } + String realId = ctx.getRealIds().get(entityDef.getJsonId()); + if (realId == null) { + continue; + } + RuleChainId ruleChainId = new RuleChainId(UUID.fromString(realId)); + RuleChain savedRuleChain = ruleChainService.findRuleChainById(ctx.getTenantId(), ruleChainId); + if (savedRuleChain == null) { + continue; + } + JsonNode ruleChainJson = JacksonUtil.toJsonNode(ruleChainPath); + String metadataStr = JacksonUtil.toString(ruleChainJson.get("metadata")); + String oldMetadataStr = metadataStr; + for (var entry : ctx.getRealIds().entrySet()) { + metadataStr = metadataStr.replace(entry.getKey(), entry.getValue()); + } + if (metadataStr.equals(oldMetadataStr)) { + continue; + } + RuleChainMetaData metadata = JacksonUtil.treeToValue(JacksonUtil.toJsonNode(metadataStr), RuleChainMetaData.class); + metadata.setRuleChainId(ruleChainId); + metadata.setVersion(savedRuleChain.getVersion()); + ruleChainService.saveRuleChainMetaData(ctx.getTenantId(), metadata, tbRuleChainService::updateRuleNodeConfiguration); + } + } + + private void finalUpdateRuleChains(SolutionInstallContext ctx) { + List ruleChains = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "rule_chains.json", new TypeReference<>() {}); + for (ReferenceableEntityDefinition entityDefinition : ruleChains) { + if (StringUtils.isEmpty(entityDefinition.getUpdate())) { + continue; + } + Path ruleChainPath = ctx.getTempDir().resolve("rule_chains").resolve(entityDefinition.getUpdate()); + JsonNode ruleChainJson = JacksonUtil.toJsonNode(ruleChainPath); + RuleChain ruleChain = JacksonUtil.treeToValue(ruleChainJson.get("ruleChain"), RuleChain.class); + ruleChain.setTenantId(ctx.getTenantId()); + String metadataStr = JacksonUtil.toString(ruleChainJson.get("metadata")); + for (var entry : ctx.getRealIds().entrySet()) { + metadataStr = metadataStr.replace(entry.getKey(), entry.getValue()); + } + RuleChainMetaData ruleChainMetaData = JacksonUtil.treeToValue(JacksonUtil.toJsonNode(metadataStr), RuleChainMetaData.class); + + String realRuleChainId = ctx.getRealIds().get(entityDefinition.getJsonId()); + if (StringUtils.isEmpty(realRuleChainId)) { + continue; + } + RuleChainId ruleChainId = new RuleChainId(UUID.fromString(realRuleChainId)); + RuleChain savedRuleChain = ruleChainService.findRuleChainById(ctx.getTenantId(), ruleChainId); + ruleChainMetaData.setRuleChainId(savedRuleChain.getId()); + ruleChainMetaData.setVersion(savedRuleChain.getVersion()); + ruleChainService.saveRuleChainMetaData(ctx.getTenantId(), ruleChainMetaData, tbRuleChainService::updateRuleNodeConfiguration); + } + } + + private void provisionDeviceProfiles(SolutionInstallContext ctx) { + List deviceProfiles = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "device_profiles.json", new TypeReference<>() {}); + deviceProfiles.addAll(loadListOfEntitiesFromDirectory(ctx.getTempDir(), "device_profiles", DeviceProfileDefinition.class)); + deviceProfiles.forEach(deviceProfile -> { + deviceProfile.setId(null); + deviceProfile.setCreatedTime(0L); + deviceProfile.setTenantId(ctx.getTenantId()); + if (deviceProfile.getDefaultRuleChainId() != null) { + String newId = ctx.getRealIds().get(deviceProfile.getDefaultRuleChainId().getId().toString()); + if (newId != null) { + deviceProfile.setDefaultRuleChainId(new RuleChainId(UUID.fromString(newId))); + } else { + log.error("[{}] Device profile: {} references non existing rule chain.", ctx.getTenantId(), deviceProfile.getName()); + throw new RuntimeException("Device profile: " + deviceProfile.getName() + " references non existing rule chain."); + } + } + if (deviceProfile.getDefaultEdgeRuleChainId() != null) { + String newId = ctx.getRealIds().get(deviceProfile.getDefaultEdgeRuleChainId().getId().toString()); + if (StringUtils.isEmpty(newId)) { + deviceProfile.setDefaultEdgeRuleChainId(null); + } else { + deviceProfile.setDefaultEdgeRuleChainId(new RuleChainId(UUID.fromString(newId))); + } + } + if (deviceProfile.getDefaultDashboardId() != null) { + String newId = ctx.getRealIds().get(deviceProfile.getDefaultDashboardId().getId().toString()); + if (newId != null) { + deviceProfile.setDefaultDashboardId(new DashboardId(UUID.fromString(newId))); + } + } + }); + + deviceProfiles.forEach(deviceProfileDefinition -> { + DeviceProfile deviceProfile = new DeviceProfile(deviceProfileDefinition); + deviceProfile = deviceProfileService.saveDeviceProfile(deviceProfile); + ctx.register(deviceProfileDefinition, deviceProfile); + }); + } + + private void provisionAssetProfiles(SolutionInstallContext ctx) { + List assetProfiles = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "asset_profiles.json", new TypeReference<>() {}); + assetProfiles.addAll(loadListOfEntitiesFromDirectory(ctx.getTempDir(), "asset_profiles", AssetProfileDefinition.class)); + assetProfiles.forEach(assetProfile -> { + assetProfile.setId(null); + assetProfile.setCreatedTime(0L); + assetProfile.setTenantId(ctx.getTenantId()); + if (assetProfile.getDefaultRuleChainId() != null) { + String newId = ctx.getRealIds().get(assetProfile.getDefaultRuleChainId().getId().toString()); + if (newId != null) { + assetProfile.setDefaultRuleChainId(new RuleChainId(UUID.fromString(newId))); + } else { + log.error("[{}] Asset profile: {} references non existing rule chain.", ctx.getTenantId(), assetProfile.getName()); + throw new RuntimeException("Asset profile: " + assetProfile.getName() + " references non existing rule chain."); + } + } + if (assetProfile.getDefaultEdgeRuleChainId() != null) { + String newId = ctx.getRealIds().get(assetProfile.getDefaultEdgeRuleChainId().getId().toString()); + if (StringUtils.isEmpty(newId)) { + assetProfile.setDefaultEdgeRuleChainId(null); + } else { + assetProfile.setDefaultEdgeRuleChainId(new RuleChainId(UUID.fromString(newId))); + } + } + }); + + assetProfiles.forEach(assetProfileDefinition -> { + AssetProfile assetProfile = new AssetProfile(assetProfileDefinition); + assetProfile = assetProfileService.saveAssetProfile(assetProfile); + ctx.register(assetProfileDefinition, assetProfile); + }); + } + + private CustomerId getPublicCustomerId(SolutionInstallContext ctx) { + CustomerId publicId = ctx.getSolutionInstructions().getPublicId(); + if (publicId != null) { + return publicId; + } + Customer publicCustomer = customerService.findOrCreatePublicCustomer(ctx.getTenantId()); + ctx.getSolutionInstructions().setPublicId(publicCustomer.getId()); + return publicCustomer.getId(); + } + + private void provisionDashboards(SolutionInstallContext ctx) { + List dashboardDefs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "dashboards.json", new TypeReference<>() {}); + for (DashboardDefinition entityDef : dashboardDefs) { + CustomerId customerId = entityDef.isMakePublic() ? getPublicCustomerId(ctx) : ctx.getIdFromMap(EntityType.CUSTOMER, entityDef.getCustomer()); + Path dashboardPath = ctx.getTempDir().resolve("dashboards").resolve(entityDef.getFile()); + if (!Files.exists(dashboardPath)) { + log.warn("[{}] Dashboard file not found: {}", ctx.getTenantId(), entityDef.getFile()); + continue; + } + JsonNode dashboardJson = replaceIds(ctx, JacksonUtil.toJsonNode(dashboardPath)); + Dashboard dashboardTemplate = JacksonUtil.treeToValue(dashboardJson, Dashboard.class); + + Dashboard dashboard = new Dashboard(); + dashboard.setTenantId(ctx.getTenantId()); + dashboard.setTitle(entityDef.getName()); + dashboard.setConfiguration(dashboardTemplate.getConfiguration()); + dashboard.setImage(dashboardTemplate.getImage()); + dashboard.setResources(dashboardTemplate.getResources()); + if (dashboardJson.has("mobileHide") && dashboardJson.get("mobileHide").isBoolean()) { + dashboard.setMobileHide(dashboardJson.get("mobileHide").asBoolean()); + } + if (dashboardJson.has("mobileOrder") && dashboardJson.get("mobileOrder").isInt()) { + dashboard.setMobileOrder(dashboardJson.get("mobileOrder").asInt()); + } + + dashboard = dashboardService.saveDashboard(dashboard); + if (customerId != null) { + dashboardService.assignDashboardToCustomer(ctx.getTenantId(), dashboard.getId(), customerId); + } + ctx.register(entityDef, dashboard); + ctx.putIdToMap(EntityType.DASHBOARD, entityDef.getName(), dashboard.getId()); + + if (entityDef.isMain()) { + ctx.getSolutionInstructions().setDashboardId(dashboard.getId()); + ctx.getSolutionInstructions().setMainDashboardPublic(entityDef.isMakePublic()); + } + ctx.getDashboardLinks().add(new DashboardLinkInfo(dashboard.getTitle(), dashboard.getId(), entityDef.isMakePublic())); + + log.debug("[{}] Dashboard provisioned: {}", ctx.getTenantId(), dashboard.getTitle()); + } + } + + private void provisionRelations(SolutionInstallContext ctx) { + ctx.getRelationDefinitions().forEach((id, relations) -> { + for (RelationDefinition relationDef : relations) { + log.info("[{}] Saving relation: {}", id, relationDef); + EntityRelation entityRelation = new EntityRelation(); + EntityId otherId = resolveRelatedEntityId(relationDef, ctx); + if (EntitySearchDirection.FROM.equals(relationDef.getDirection())) { + entityRelation.setFrom(otherId); + entityRelation.setTo(id); + } else { + entityRelation.setFrom(id); + entityRelation.setTo(otherId); + } + entityRelation.setTypeGroup(RelationTypeGroup.COMMON); + entityRelation.setType(relationDef.getType()); + try { + relationService.save(ctx.getTenantId(), null, entityRelation, null); + } catch (Exception e) { + log.info("[{}] Failed to save relation: {}, cause: {}", id, relationDef, e.getMessage()); + } + } + }); + } + + private EntityId resolveRelatedEntityId(RelationDefinition relationDef, SolutionInstallContext ctx) { + if (relationDef.getEntityType() == EntityType.TENANT) { + return ctx.getTenantId(); + } + return ctx.getIdFromMap(relationDef.getEntityType(), relationDef.getEntityName()); + } + + private Map provisionDevices(SolutionInstallContext ctx) { + Map result = new HashMap<>(); + Set deviceTypeSet = new HashSet<>(); + List devices = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "devices.json", new TypeReference<>() {}); + for (DeviceDefinition entityDef : devices) { + CustomerId customerId = entityDef.isMakePublic() ? getPublicCustomerId(ctx) : ctx.getIdFromMap(EntityType.CUSTOMER, entityDef.getCustomer()); + Device entity = new Device(); + entity.setTenantId(ctx.getTenantId()); + entity.setName(entityDef.getName()); + entity.setLabel(entityDef.getLabel()); + ensureDeviceProfileExists(ctx, deviceTypeSet, entityDef); + entity.setType(entityDef.getType()); + entity.setCustomerId(customerId); + entity.setAdditionalInfo(entityDef.getAdditionalInfo()); + entity = deviceService.saveDevice(entity); + entityActionService.logEntityAction(ctx.getUser(), entity.getId(), entity, customerId, ActionType.ADDED, null); + ctx.register(entityDef, entity); + log.info("[{}] Saved device: {}", entity.getId(), entity); + DeviceId entityId = entity.getId(); + ctx.putIdToMap(entityDef, entityId); + saveServerSideAttributes(ctx, entityId, entityDef.getAttributes()); + saveSharedAttributes(ctx, entityId, entityDef.getSharedAttributes()); + ctx.put(entityId, entityDef.getRelations()); + + DeviceCredentialsInfo deviceCredentialsInfo = new DeviceCredentialsInfo(); + deviceCredentialsInfo.setName(entity.getName()); + deviceCredentialsInfo.setType(entity.getType()); + deviceCredentialsInfo.setCustomerName(entityDef.getCustomer()); + deviceCredentialsInfo.setCredentials(deviceCredentialsService.findDeviceCredentialsByDeviceId(ctx.getTenantId(), entityId)); + JsonNode additionalInfo = entity.getAdditionalInfo(); + boolean isGateway = additionalInfo != null && additionalInfo.hasNonNull("gateway") && additionalInfo.get("gateway").asBoolean(); + deviceCredentialsInfo.setGateway(isGateway); + ctx.addDeviceCredentials(deviceCredentialsInfo); + + result.put(entity, entityDef); + } + return result; + } + + private void ensureDeviceProfileExists(SolutionInstallContext ctx, Set deviceTypeSet, DeviceDefinition entityDef) { + if (!deviceTypeSet.contains(entityDef.getType())) { + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByName(ctx.getTenantId(), entityDef.getType()); + if (deviceProfile == null) { + DeviceProfile created = deviceProfileService.findOrCreateDeviceProfile(ctx.getTenantId(), entityDef.getType()); + ctx.register(created.getId()); + log.info("Saved device profile: {}", created.getId()); + } + deviceTypeSet.add(entityDef.getType()); + } + } + + private void registerEmulatorsAndComputeOldestTelemetryTs(SolutionInstallContext ctx) { + List emulatorDefinitions = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "device_emulators.json", new TypeReference<>() { + }); + Map deviceEmulators = emulatorDefinitions.stream().collect(Collectors.toMap(EmulatorDefinition::getName, Function.identity())); + emulatorDefinitions.stream().filter(ed -> StringUtils.isNotEmpty(ed.getExtendz())) + .forEach(ed -> { + EmulatorDefinition parent = deviceEmulators.get(ed.getExtendz()); + if (parent != null) { + ed.enrich(parent); + } + }); + Map assetEmulators = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "asset_emulators.json", new TypeReference>() { + }).stream().collect(Collectors.toMap(EmulatorDefinition::getName, Function.identity())); + + ctx.setDeviceEmulators(deviceEmulators); + ctx.setAssetEmulators(assetEmulators); + + long solutionInstallTs = ctx.getInstallTs(); + long oldestDeviceEmulatorsTs = deviceEmulators.values().stream() + .mapToLong(value -> value.getOldestTs(solutionInstallTs)) + .min().orElse(solutionInstallTs); + long oldestAssetEmulatorsTs = assetEmulators.values().stream() + .mapToLong(value -> value.getOldestTs(solutionInstallTs)) + .min().orElse(solutionInstallTs); + long solutionOldestTs = Math.min(oldestDeviceEmulatorsTs, oldestAssetEmulatorsTs); + + ctx.setOldestTelemetryTs(solutionOldestTs); + } + + private Set> launchEmulators(SolutionInstallContext ctx, Map devicesMap, Map assets) throws Exception { + Set> results = new HashSet<>(); + + for (var entry : devicesMap.entrySet().stream().filter(e -> StringUtils.isNotBlank(e.getValue().getEmulator())).collect(Collectors.toSet())) { + results.add(DeviceEmulatorLauncher.builder() + .entity(entry.getKey()) + .emulatorDefinition(ctx.getDeviceEmulators().get(entry.getValue().getEmulator())) + .oldTelemetryExecutor(emulatorExecutor) + .tbClusterService(tbClusterService) + .partitionService(partitionService) + .tbQueueProducerProvider(tbQueueProducerProvider) + .serviceInfoProvider(serviceInfoProvider) + .tsSubService(tsSubService) + .build().launch()); + } + + for (var entry : assets.entrySet().stream().filter(e -> StringUtils.isNotBlank(e.getValue().getEmulator())).collect(Collectors.toSet())) { + results.add(AssetEmulatorLauncher.builder() + .entity(entry.getKey()) + .emulatorDefinition(ctx.getAssetEmulators().get(entry.getValue().getEmulator())) + .oldTelemetryExecutor(emulatorExecutor) + .tbClusterService(tbClusterService) + .partitionService(partitionService) + .tbQueueProducerProvider(tbQueueProducerProvider) + .serviceInfoProvider(serviceInfoProvider) + .tsSubService(tsSubService) + .build().launch()); + } + + return results; + } + + private void provisionTenantDetails(SolutionInstallContext ctx) { + TenantDefinition tenant = loadEntityIfFileExists(ctx.getTempDir(), "tenant.json", TenantDefinition.class); + if (tenant != null) { + saveServerSideAttributes(ctx, ctx.getTenantId(), tenant.getAttributes()); + ctx.put(ctx.getTenantId(), tenant.getRelations()); + } + } + + private Map provisionAssets(SolutionInstallContext ctx) { + Map result = new HashMap<>(); + Set assetTypeSet = new HashSet<>(); + List assets = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "assets.json", new TypeReference<>() {}); + for (AssetDefinition entityDef : assets) { + Asset entity = new Asset(); + entity.setTenantId(ctx.getTenantId()); + entity.setName(entityDef.getName()); + entity.setLabel(entityDef.getLabel()); + entity.setType(entityDef.getType()); + if (entityDef.isMakePublic()) { + entity.setCustomerId(getPublicCustomerId(ctx)); + } else { + entity.setCustomerId(ctx.getIdFromMap(EntityType.CUSTOMER, entityDef.getCustomer())); + } + ensureAssetProfileExists(ctx, assetTypeSet, entityDef); + entity = assetService.saveAsset(entity); + ctx.register(entityDef, entity); + log.info("[{}] Saved asset: {}", entity.getId(), entity); + AssetId entityId = entity.getId(); + ctx.putIdToMap(entityDef, entityId); + saveServerSideAttributes(ctx, entityId, entityDef.getAttributes()); + ctx.put(entityId, entityDef.getRelations()); + result.put(entity, entityDef); + } + return result; + } + + private void ensureAssetProfileExists(SolutionInstallContext ctx, Set assetTypeSet, AssetDefinition entityDef) { + if (!assetTypeSet.contains(entityDef.getType())) { + AssetProfile assetProfile = assetProfileService.findAssetProfileByName(ctx.getTenantId(), entityDef.getType()); + if (assetProfile == null) { + AssetProfile created = assetProfileService.findOrCreateAssetProfile(ctx.getTenantId(), entityDef.getType()); + ctx.register(created.getId()); + log.info("Saved asset profile: {}", created.getId()); + } + assetTypeSet.add(entityDef.getType()); + } + } + + private void provisionCustomers(SolutionInstallContext ctx, List customers) { + for (CustomerDefinition entityDef : customers) { + entityDef.setRandomNameData(generateRandomName(ctx)); + Customer customer = new Customer(); + customer.setTenantId(ctx.getTenantId()); + customer.setTitle(randomize(entityDef.getName(), entityDef.getRandomNameData())); + customer.setEmail(randomize(entityDef.getEmail(), entityDef.getRandomNameData())); + customer.setCountry(entityDef.getCountry()); + customer.setCity(entityDef.getCity()); + customer.setState(entityDef.getState()); + customer.setZip(entityDef.getZip()); + customer.setAddress(entityDef.getAddress()); + customer = customerService.saveCustomer(customer); + log.info("[{}] Saved customer: {}", customer.getId(), customer); + ctx.register(entityDef, customer); + CustomerId entityId = customer.getId(); + ctx.putIdToMap(entityDef, entityId); + saveServerSideAttributes(ctx, entityId, entityDef.getAttributes(), entityDef.getRandomNameData()); + ctx.put(entityId, entityDef.getRelations()); + entityDef.setName(customer.getName()); + } + } + + private void provisionCustomerUsers(SolutionInstallContext ctx, List customers) { + for (CustomerDefinition entityDef : customers) { + Customer customer = customerService.findCustomerByTenantIdAndTitle(ctx.getTenantId(), entityDef.getName()).get(); + for (UserDefinition uDef : entityDef.getUsers()) { + String originalName = uDef.getName(); + User user = createUser(ctx, customer, uDef, entityDef); + + UserCredentials credentials = userService.findUserCredentialsByUserId(ctx.getTenantId(), user.getId()); + credentials.setEnabled(true); + credentials.setActivateToken(null); + credentials.setPassword(passwordEncoder.encode(uDef.getPassword())); + userService.saveUserCredentials(ctx.getTenantId(), credentials); + + DashboardUserDetailsDefinition dd = uDef.getDashboard(); + if (dd != null) { + DashboardId dashboardId = ctx.getIdFromMap(EntityType.DASHBOARD, dd.getName()); + ObjectNode additionalInfo = JacksonUtil.newObjectNode(); + additionalInfo.put("defaultDashboardId", dashboardId.getId().toString()); + additionalInfo.put("defaultDashboardFullscreen", dd.isFullScreen()); + user.setAdditionalInfo(additionalInfo); + userService.saveUser(ctx.getTenantId(), user); + log.info("[{}] Added default dashboard for user {}", customer.getId(), user.getEmail()); + } + + UserCredentialsInfo credentialsInfo = new UserCredentialsInfo(); + credentialsInfo.setName(user.getFirstName() + " " + user.getLastName()); + credentialsInfo.setLogin(uDef.getName()); + credentialsInfo.setPassword(uDef.getPassword()); + credentialsInfo.setCustomerName(entityDef.getName()); + ctx.addUserCredentials(credentialsInfo); + ctx.register(entityDef, user); + ctx.put(user.getId(), uDef.getRelations()); + ctx.putIdToMap(EntityType.USER, originalName, user.getId()); + ctx.putIdToMap(EntityType.USER, uDef.getName(), user.getId()); + saveServerSideAttributes(ctx, user.getId(), uDef.getAttributes()); + } + } + } + + private User createUser(SolutionInstallContext ctx, Customer customer, UserDefinition uDef, CustomerDefinition cDef) { + int maxAttempts = 10; + int attempts = 0; + Exception finalE = null; + while (attempts < maxAttempts) { + try { + boolean lastAttempt = maxAttempts == (attempts + 1); + var randomName = lastAttempt ? RandomNameUtil.nextSuperRandom() : RandomNameUtil.next(); + User user = new User(); + if (!StringUtils.isEmpty(uDef.getFirstname())) { + user.setFirstName(randomize(uDef.getFirstname(), randomName, cDef.getRandomNameData())); + } else { + user.setFirstName(randomName.getFirstName()); + } + if (!StringUtils.isEmpty(uDef.getLastname())) { + user.setLastName(randomize(uDef.getLastname(), randomName, cDef.getRandomNameData())); + } else { + user.setLastName(randomName.getLastName()); + } + user.setAuthority(Authority.CUSTOMER_USER); + user.setEmail(randomize(uDef.getName(), randomName, cDef.getRandomNameData())); + user.setCustomerId(customer.getId()); + user.setTenantId(ctx.getTenantId()); + log.info("[{}] Saving user: {}", customer.getId(), user); + user = userService.saveUser(ctx.getTenantId(), user); + uDef.setName(user.getEmail()); + return user; + } catch (Exception e) { + finalE = e; + attempts++; + } + } + throw new RuntimeException(finalE); + } + + private void provisionEdges(SolutionInstallContext ctx) throws Exception { + List edges = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "edges.json", new TypeReference<>() {}); + RuleChain edgeTemplateRootRuleChain = ruleChainService.getEdgeTemplateRootRuleChain(ctx.getTenantId()); + for (EdgeDefinition entityDef : edges) { + CustomerId customerId = entityDef.isMakePublic() ? getPublicCustomerId(ctx) : ctx.getIdFromMap(EntityType.CUSTOMER, entityDef.getCustomer()); + Edge entity = new Edge(); + entity.setTenantId(ctx.getTenantId()); + entity.setName(entityDef.getName()); + entity.setLabel(entityDef.getLabel()); + entity.setType(entityDef.getType()); + entity.setCustomerId(customerId); + entity.setRoutingKey(UUID.randomUUID().toString()); + entity.setSecret(StringUtils.randomAlphanumeric(20)); + RuleChainId rootRuleChainId = edgeTemplateRootRuleChain.getId(); + if (StringUtils.isNotBlank(entityDef.getRootRuleChainId())) { + String newId = ctx.getRealIds().get(entityDef.getRootRuleChainId()); + if (newId != null) { + rootRuleChainId = new RuleChainId(UUID.fromString(newId)); + } else { + log.error("[{}] Edge: {} references non existing rule chain.", ctx.getTenantId(), entity.getName()); + throw new RuntimeException("Edge: " + entity.getName() + " references non existing rule chain."); + } + } + entity.setRootRuleChainId(rootRuleChainId); + RuleChain rootRuleChain = ruleChainService.findRuleChainById(ctx.getTenantId(), rootRuleChainId); + entity = tbEdgeService.save(entity, rootRuleChain, ctx.getUser()); + ctx.register(entityDef, entity); + assignRuleChainsToEdge(ctx, entityDef.getRuleChainIds(), entity); + assignAssetsToEdge(ctx, entityDef.getAssetIds(), entity); + assignDevicesToEdge(ctx, entityDef.getDeviceIds(), entity); + assignDashboardsToEdge(ctx, entityDef.getDashboardIds(), entity); + log.info("[{}] Saved edge: {}", entity.getId(), entity); + EdgeId entityId = entity.getId(); + ctx.putIdToMap(entityDef, entityId); + saveServerSideAttributes(ctx, entityId, entityDef.getAttributes()); + ctx.put(entityId, entityDef.getRelations()); + ctx.addEdgeLinkInfo(entity.getName(), new EdgeLinkInfo(entity.getId(), customerId != null ? customerId : ctx.getTenantId())); + } + } + + private void assignRuleChainsToEdge(SolutionInstallContext ctx, List ruleChainIds, Edge entity) { + if (ruleChainIds == null || ruleChainIds.isEmpty()) { + return; + } + for (String strRuleChainId : ruleChainIds) { + String newId = ctx.getRealIds().get(strRuleChainId); + if (newId != null) { + RuleChainId ruleChainId = new RuleChainId(UUID.fromString(newId)); + ruleChainService.assignRuleChainToEdge(ctx.getTenantId(), ruleChainId, entity.getId()); + } else { + log.error("[{}] Edge: {} references non existing edge rule chain.", ctx.getTenantId(), entity.getName()); + throw new RuntimeException("Edge: " + entity.getName() + " references non existing edge rule chain."); + } + } + } + + private void assignAssetsToEdge(SolutionInstallContext ctx, List assetIds, Edge entity) { + if (assetIds == null || assetIds.isEmpty()) { + return; + } + for (String strAssetId : assetIds) { + String newId = ctx.getRealIds().get(strAssetId); + if (newId != null) { + AssetId assetId = new AssetId(UUID.fromString(newId)); + assetService.assignAssetToEdge(ctx.getTenantId(), assetId, entity.getId()); + } else { + log.error("[{}] Edge: {} references non existing asset.", ctx.getTenantId(), entity.getName()); + throw new RuntimeException("Edge: " + entity.getName() + " references non existing asset."); + } + } + } + + private void assignDevicesToEdge(SolutionInstallContext ctx, List deviceIds, Edge entity) { + if (deviceIds == null || deviceIds.isEmpty()) { + return; + } + for (String strDeviceId : deviceIds) { + String newId = ctx.getRealIds().get(strDeviceId); + if (newId != null) { + DeviceId deviceId = new DeviceId(UUID.fromString(newId)); + deviceService.assignDeviceToEdge(ctx.getTenantId(), deviceId, entity.getId()); + } else { + log.error("[{}] Edge: {} references non existing device.", ctx.getTenantId(), entity.getName()); + throw new RuntimeException("Edge: " + entity.getName() + " references non existing device."); + } + } + } + + private void assignDashboardsToEdge(SolutionInstallContext ctx, List dashboardIds, Edge entity) { + if (dashboardIds == null || dashboardIds.isEmpty()) { + return; + } + for (String strDashboardId : dashboardIds) { + String newId = ctx.getRealIds().get(strDashboardId); + if (newId != null) { + DashboardId dashboardId = new DashboardId(UUID.fromString(newId)); + dashboardService.assignDashboardToEdge(ctx.getTenantId(), dashboardId, entity.getId()); + } else { + log.error("[{}] Edge: {} references non existing dashboard.", ctx.getTenantId(), entity.getName()); + throw new RuntimeException("Edge: " + entity.getName() + " references non existing dashboard."); + } + } + } + + private void provisionAlarmRules(SolutionInstallContext ctx) { + List cfs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "alarm_rules.json", new TypeReference<>() {}); + cfs.addAll(loadListOfEntitiesFromDirectory(ctx.getTempDir(), "alarm_rules", CalculatedField.class)); + cfs.forEach(cf -> ctx.register(createCalculatedField(cf, ctx))); + } + + private void provisionCalculatedFields(SolutionInstallContext ctx) { + List cfs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "calculated_fields.json", new TypeReference<>() { + }); + cfs.addAll(loadListOfEntitiesFromDirectory(ctx.getTempDir(), "calculated_fields", CalculatedFieldDefinition.class)); + + List createOnly = new ArrayList<>(); + TreeMap> ordered = new TreeMap<>(); + + for (CalculatedFieldDefinition cf : cfs) { + if (cf.getReprocessingOrder() == null || cf.getReprocessingOrder() < 0) { + createOnly.add(cf); + } else { + ordered.computeIfAbsent(cf.getReprocessingOrder(), integer -> new ArrayList<>()).add(cf); + } + } + + createOnly.forEach(cf -> ctx.register(createCalculatedField(cf, ctx))); + + for (Map.Entry> entry : ordered.entrySet()) { + List cfDefs = entry.getValue(); + for (CalculatedFieldDefinition cfDef : cfDefs) { + CalculatedField calculatedField = createCalculatedField(cfDef, ctx); + ctx.register(calculatedField); + } + } + } + + private CalculatedField createCalculatedField(CalculatedField cf, SolutionInstallContext ctx) { + cf.setId(null); + cf.setCreatedTime(0L); + cf.setTenantId(ctx.getTenantId()); + cf.setDebugSettings(new DebugSettings(true, System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(15))); + + Map realIds = ctx.getRealIds(); + + EntityId entityId = cf.getEntityId(); + if (entityId != null) { + String newEntityId = realIds.get(entityId.getId().toString()); + if (newEntityId != null) { + cf.setEntityId(EntityIdFactory.getByTypeAndUuid(entityId.getEntityType(), newEntityId)); + } else { + log.error("[{}] Calculated field: {} references non existing entity.", ctx.getTenantId(), cf.getName()); + throw new RuntimeException("Calculated field: " + cf.getName() + " references non existing entity."); + } + } + if (cf.getConfiguration() instanceof BaseCalculatedFieldConfiguration baseCfg) { + baseCfg.getArguments().forEach((key, argument) -> { + EntityId refEntityId = argument.getRefEntityId(); + if (refEntityId != null) { + if (refEntityId.getEntityType() == EntityType.TENANT) { + argument.setRefEntityId(ctx.getTenantId()); + } else { + String newId = realIds.get(refEntityId.getId().toString()); + if (newId != null) { + argument.setRefEntityId(EntityIdFactory.getByTypeAndUuid(refEntityId.getEntityType(), newId)); + } else { + log.error("[{}] Calculated field: {} references non existing entity.", ctx.getTenantId(), cf.getName()); + throw new RuntimeException("Calculated field: " + cf.getName() + " references non existing entity."); + } + } + } + }); + } + + CalculatedField calculatedField = new CalculatedField(cf); + return calculatedFieldService.save(calculatedField); + } + + private RandomNameData generateRandomName(SolutionInstallContext ctx) { + int i = 0; + while (i < 10) { + var randomName = RandomNameUtil.next(); + var user = userService.findUserByEmail(ctx.getTenantId(), randomName.getEmail()); + if (user == null) { + return randomName; + } else { + i++; + } + } + String firstName = StringUtils.randomAlphanumeric(5); + String lastName = StringUtils.randomAlphanumeric(5); + return new RandomNameData(firstName, lastName, firstName + "." + lastName + "@thingsboard.io"); + } + + private String randomize(String src, RandomNameData name) { + return randomize(src, name, null); + } + + private String randomize(String src, RandomNameData name, RandomNameData customer) { + if (src == null) { + return null; + } else { + String result = src + .replace("$randomFirstName", name.getFirstName()) + .replace("$randomLastName", name.getLastName()) + .replace("$randomEmail", name.getEmail()); + if (customer != null) { + result = result + .replace("$customerFirstName", customer.getFirstName()) + .replace("$customerLastName", customer.getLastName()) + .replace("$customerEmail", customer.getEmail()); + } + return result.replace("$random", StringUtils.randomAlphanumeric(10).toLowerCase()); + } + } + + private void saveServerSideAttributes(SolutionInstallContext ctx, EntityId entityId, JsonNode attributes) { + saveServerSideAttributes(ctx, entityId, attributes, null); + } + + private void saveServerSideAttributes(SolutionInstallContext ctx, EntityId entityId, JsonNode attributes, RandomNameData randomNameData) { + saveAttributes(ctx, entityId, attributes, randomNameData, AttributeScope.SERVER_SCOPE); + } + + private void saveSharedAttributes(SolutionInstallContext ctx, EntityId entityId, JsonNode attributes) { + if (!EntityType.DEVICE.equals(entityId.getEntityType())) { + throw new IllegalArgumentException(entityId.getEntityType() + " cannot have shared attributes."); + } + saveAttributes(ctx, entityId, attributes, null, AttributeScope.SHARED_SCOPE); + } + + private void saveAttributes(SolutionInstallContext ctx, EntityId entityId, JsonNode attributes, RandomNameData randomNameData, AttributeScope attributeScope) { + if (attributes != null && !attributes.isNull() && !attributes.isEmpty()) { + attributes = prepareAttributes(attributes); + log.info("[{}] Saving attributes: {}", entityId, attributes); + if (randomNameData != null) { + attributes = JacksonUtil.toJsonNode(randomize(JacksonUtil.toString(attributes), randomNameData, null)); + } + attributesService.save(ctx.getTenantId(), entityId, attributeScope, + new ArrayList<>(JsonConverter.convertToAttributes(JsonParser.parseString(JacksonUtil.toString(attributes)), ctx.getOldestTelemetryTs()))); + } + } + + private JsonNode prepareAttributes(JsonNode attributes) { + ObjectNode attributesObj = (ObjectNode) attributes; + attributes.fields().forEachRemaining(entry -> { + JsonNode value = entry.getValue(); + if (value.isTextual() && isTimeExpression(value.asText())) { + value = JacksonUtil.toJsonNode(parseTimeExpression(value.asText())); + } + attributesObj.set(entry.getKey(), value); + }); + return attributesObj; + } + + private boolean isTimeExpression(String text) { + return Pattern.matches("\\$\\{currentTime(?:([+-])(\\d+)([mwdh]|min))?}", text); + } + + private String parseTimeExpression(String timeExpression) { + Matcher matcher = Pattern.compile("\\$\\{currentTime(?:([+-])(\\d+)([mwdh]|min))?}").matcher(timeExpression); + + if (!matcher.matches()) { + return timeExpression; + } + + String operator = matcher.group(1); + String amountStr = matcher.group(2); + String unit = matcher.group(3); + + ZonedDateTime now = ZonedDateTime.now(); + + if (operator != null && amountStr != null && unit != null) { + int amount = Integer.parseInt(amountStr); + now = switch (unit) { + case "m" -> operator.equals("+") ? now.plusMonths(amount) : now.minusMonths(amount); + case "w" -> operator.equals("+") ? now.plusWeeks(amount) : now.minusWeeks(amount); + case "d" -> operator.equals("+") ? now.plusDays(amount) : now.minusDays(amount); + case "h" -> operator.equals("+") ? now.plusHours(amount) : now.minusHours(amount); + case "min" -> operator.equals("+") ? now.plusMinutes(amount) : now.minusMinutes(amount); + default -> now; + }; + } + + return String.valueOf(now.toInstant().toEpochMilli()); + } + + private String getTypeLabel(EntityType type) { + return type.name().toLowerCase().replace('_', ' '); + } + + private T loadEntityIfFileExists(Path tempDir, String fileName, Class clazz) { + Path filePath = tempDir.resolve("entities").resolve(fileName); + if (Files.exists(filePath)) { + return JacksonUtil.readValue(filePath.toFile(), clazz); + } else { + return null; + } + } + + private List loadListOfEntitiesIfFileExists(Path tempDir, String fileName, TypeReference> typeReference) { + Path filePath = tempDir.resolve("entities").resolve(fileName); + if (Files.exists(filePath)) { + try { + return JacksonUtil.readValue(filePath.toFile(), typeReference); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid json file " + fileName + " data structure", e); + } + } else { + return new ArrayList<>(); + } + } + + private List loadListOfEntitiesFromDirectory(Path tempDir, String dirName, Class clazz) { + Path dirPath = tempDir.resolve(dirName); + if (Files.exists(dirPath) && Files.isDirectory(dirPath)) { + List result = new ArrayList<>(); + try { + for (Path filePath : Files.list(dirPath).collect(Collectors.toList())) { + try { + result.add(JacksonUtil.readValue(filePath.toFile(), clazz)); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid json file " + filePath.getFileName() + " data structure", e); + } + } + } catch (IOException e) { + log.warn("Failed to read directory: {}", dirName, e); + throw new RuntimeException(e); + } + return result; + } else { + return new ArrayList<>(); + } + } + + private String loadSolutionId(Path tempDir) { + Path solutionJson = tempDir.resolve("solution.json"); + if (Files.exists(solutionJson)) { + JsonNode node = JacksonUtil.toJsonNode(solutionJson); + if (node != null && node.has("title")) { + String title = node.get("title").asText(""); + return title.trim().toLowerCase().replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", ""); + } + } + return null; + } + + private long loadInstallTimeoutMs(Path tempDir) { + Path solutionJson = tempDir.resolve("solution.json"); + if (Files.exists(solutionJson)) { + JsonNode node = JacksonUtil.toJsonNode(solutionJson); + if (node != null && node.has("installTimeoutMs")) { + return node.get("installTimeoutMs").asLong(0L); + } + } + return 0L; + } + + private List loadTenantTelemetryKeys(Path tempDir) { + Path solutionJson = tempDir.resolve("solution.json"); + if (Files.exists(solutionJson)) { + JsonNode node = JacksonUtil.toJsonNode(solutionJson); + if (node != null && node.has("tenantTelemetryKeys")) { + return JacksonUtil.convertValue(node.get("tenantTelemetryKeys"), new TypeReference<>() {}); + } + } + return Collections.emptyList(); + } + + private List loadTenantAttributeKeys(Path tempDir) { + Path solutionJson = tempDir.resolve("solution.json"); + if (Files.exists(solutionJson)) { + JsonNode node = JacksonUtil.toJsonNode(solutionJson); + if (node != null && node.has("tenantAttributeKeys")) { + return JacksonUtil.convertValue(node.get("tenantAttributeKeys"), new TypeReference<>() {}); + } + } + return Collections.emptyList(); + } + + private static 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..90e5fc9f66 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedAlarmRuleInfo.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data; + +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.UUID; + +public record CreatedAlarmRuleInfo(EntityId entityId, String entityName, String alarmType, String severities) { + + public String getCfPageLink(UUID cfId) { + return "/alarms/alarm-rules/" + cfId; + } + + public String getEntityPageLink() { + if (entityId == null) { + return null; + } + return switch (entityId.getEntityType()) { + case DEVICE_PROFILE -> "/profiles/deviceProfiles/" + entityId.getId(); + case ASSET_PROFILE -> "/profiles/assetProfiles/" + entityId.getId(); + default -> null; + }; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedCalculatedFieldInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedCalculatedFieldInfo.java new file mode 100644 index 0000000000..9616d738af --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedCalculatedFieldInfo.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data; + +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.UUID; + +public record CreatedCalculatedFieldInfo(EntityId entityId, String entityName, String type, String name) { + + public String getCfPageLink(UUID cfId) { + return "/calculatedFields/" + cfId; + } + + public String getEntityPageLink() { + if (entityId == null) { + return null; + } + return switch (entityId.getEntityType()) { + case DEVICE_PROFILE -> "/profiles/deviceProfiles/" + entityId.getId(); + case ASSET_PROFILE -> "/profiles/assetProfiles/" + entityId.getId(); + default -> null; + }; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedEntityInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedEntityInfo.java new file mode 100644 index 0000000000..53105a6e31 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedEntityInfo.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; + +import java.util.UUID; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CreatedEntityInfo { + + private String name; + private EntityType type; + private String owner; + + public String getEntityPageLink(UUID id) { + return switch (type) { + case CUSTOMER -> "/customers/" + id; + case USER -> "/users/" + id; + case ASSET -> "/entities/assets/" + id; + case DEVICE -> "/entities/devices/" + id; + case DEVICE_PROFILE -> "/profiles/deviceProfiles/" + id; + case ASSET_PROFILE -> "/profiles/assetProfiles/" + id; + case DASHBOARD -> "/dashboards/" + id; + case RULE_CHAIN -> "/ruleChains/" + id; + case EDGE -> "/edgeManagement/instances/" + id; + default -> null; + }; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedRuleChainInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedRuleChainInfo.java new file mode 100644 index 0000000000..9a785242b2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedRuleChainInfo.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.rule.RuleChainType; + +import java.util.UUID; + +@Data +@EqualsAndHashCode(callSuper = true) +public class CreatedRuleChainInfo extends CreatedEntityInfo { + + private RuleChainType ruleChainType; + + public CreatedRuleChainInfo(String name, RuleChainType ruleChainType, String owner) { + super(name, EntityType.RULE_CHAIN, owner); + this.ruleChainType = ruleChainType; + } + + @Override + public String getEntityPageLink(UUID id) { + return ruleChainType == RuleChainType.EDGE ? "/edgeManagement/ruleChains/" + id : "/ruleChains/" + id; + } +} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.scss b/application/src/main/java/org/thingsboard/server/service/solutions/data/DashboardLinkInfo.java similarity index 63% rename from ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.scss rename to application/src/main/java/org/thingsboard/server/service/solutions/data/DashboardLinkInfo.java index 99285e46db..63eb0e3757 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.scss +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/DashboardLinkInfo.java @@ -13,24 +13,16 @@ * See the License for the specific language governing permissions and * 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); +package org.thingsboard.server.service.solutions.data; - .mat-content { - overflow: hidden; - background-color: #fff; - } +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; - .mat-padding { - padding: 16px; - } } diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/DeviceCredentialsInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/DeviceCredentialsInfo.java new file mode 100644 index 0000000000..e93104729c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/DeviceCredentialsInfo.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data; + +import lombok.Data; +import org.thingsboard.server.common.data.security.DeviceCredentials; + +@Data +public class DeviceCredentialsInfo { + + String name; + String type; + DeviceCredentials credentials; + String customerName; + boolean gateway; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/EdgeLinkInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/EdgeLinkInfo.java new file mode 100644 index 0000000000..e5f057636d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/EdgeLinkInfo.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data; + +import lombok.Data; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; + +@Data +public class EdgeLinkInfo { + + private final EdgeId edgeId; + private final EntityId ownerId; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/SolutionInstallContext.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/SolutionInstallContext.java new file mode 100644 index 0000000000..9ccf136678 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/SolutionInstallContext.java @@ -0,0 +1,209 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data; + +import lombok.Data; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.service.solutions.data.definition.AssetDefinition; +import org.thingsboard.server.service.solutions.data.definition.AssetProfileDefinition; +import org.thingsboard.server.service.solutions.data.definition.CustomerDefinition; +import org.thingsboard.server.service.solutions.data.definition.DashboardDefinition; +import org.thingsboard.server.service.solutions.data.definition.DeviceDefinition; +import org.thingsboard.server.service.solutions.data.definition.EdgeDefinition; +import org.thingsboard.server.service.solutions.data.definition.DeviceProfileDefinition; +import org.thingsboard.server.service.solutions.data.definition.EntityDefinition; +import org.thingsboard.server.service.solutions.data.definition.EntitySearchKey; +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; +import org.thingsboard.server.service.solutions.data.definition.RelationDefinition; +import org.thingsboard.server.service.solutions.data.solution.TenantSolutionTemplateInstructions; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Data +public class SolutionInstallContext { + + private final TenantId tenantId; + private final Path tempDir; + private final User user; + private final TenantSolutionTemplateInstructions solutionInstructions; + private final List createdEntitiesList = new ArrayList<>(); + private final Map realIds = new HashMap<>(); + private final Map entityIdMap = new HashMap<>(); + private final Map> relationDefinitions = new LinkedHashMap<>(); + private final List dashboardLinks = new ArrayList<>(); + + // For instructions + private final Map createdDevices = new LinkedHashMap<>(); + private final Map createdUsers = new LinkedHashMap<>(); + private final Map createdEdges = new LinkedHashMap<>(); + private final Map createdEntities = new LinkedHashMap<>(); + private final Map createdAlarmRules = new LinkedHashMap<>(); + private final Map createdCalculatedFields = new LinkedHashMap<>(); + private final String solutionId; + private final long installTs; + private long oldestTelemetryTs; + private Map deviceEmulators; + private Map assetEmulators; + + public SolutionInstallContext(TenantId tenantId, String solutionId, Path tempDir, User user, TenantSolutionTemplateInstructions solutionInstructions) { + this.tenantId = tenantId; + this.solutionId = solutionId; + this.tempDir = tempDir; + this.user = user; + this.solutionInstructions = solutionInstructions; + this.installTs = System.currentTimeMillis(); + put(new EntitySearchKey(tenantId, EntityType.TENANT, null), tenantId); + } + + public void registerReferenceOnly(String referenceId, EntityId entityId) { + if (!StringUtils.isEmpty(referenceId)) { + realIds.put(referenceId, entityId.getId().toString()); + } + } + + public void register(String referenceId, EntityId entityId) { + registerReferenceOnly(referenceId, entityId); + register(entityId); + } + + public void register(EntityId entityId) { + createdEntitiesList.add(entityId); + } + + public void register(String referenceId, RuleChain ruleChain) { + register(referenceId, ruleChain.getId()); + createdEntities.put(ruleChain.getUuidId(), new CreatedRuleChainInfo(ruleChain.getName(), ruleChain.getType(), "Tenant")); + } + + public void register(DeviceProfileDefinition definition, DeviceProfile deviceProfile) { + register(definition.getJsonId(), deviceProfile.getId()); + createdEntities.put(deviceProfile.getUuidId(), new CreatedEntityInfo(deviceProfile.getName(), EntityType.DEVICE_PROFILE, "Tenant")); + } + + public void register(AssetProfileDefinition definition, AssetProfile assetProfile) { + register(definition.getJsonId(), assetProfile.getId()); + createdEntities.put(assetProfile.getUuidId(), new CreatedEntityInfo(assetProfile.getName(), EntityType.ASSET_PROFILE, "Tenant")); + } + + public void register(AssetDefinition definition, Asset asset) { + register(definition.getJsonId(), asset.getId()); + createdEntities.put(asset.getUuidId(), new CreatedEntityInfo(asset.getName(), EntityType.ASSET, + StringUtils.isEmpty(definition.getCustomer()) ? "Tenant" : definition.getCustomer())); + } + + public void register(DeviceDefinition definition, Device device) { + register(definition.getJsonId(), device.getId()); + createdEntities.put(device.getUuidId(), new CreatedEntityInfo(device.getName(), EntityType.DEVICE, + StringUtils.isEmpty(definition.getCustomer()) ? "Tenant" : definition.getCustomer())); + } + + public void register(CustomerDefinition definition, Customer customer) { + register(definition.getJsonId(), customer.getId()); + createdEntities.put(customer.getUuidId(), new CreatedEntityInfo(customer.getName(), EntityType.CUSTOMER, "Tenant")); + } + + public void register(CustomerDefinition customerDef, User user) { + register((String) null, user.getId()); + createdEntities.put(user.getUuidId(), new CreatedEntityInfo(user.getName(), EntityType.USER, + StringUtils.isEmpty(customerDef.getName()) ? "Tenant" : customerDef.getName())); + } + + public void register(EdgeDefinition definition, Edge edge) { + register(definition.getJsonId(), edge.getId()); + createdEntities.put(edge.getUuidId(), new CreatedEntityInfo(edge.getName(), EntityType.EDGE, + StringUtils.isEmpty(definition.getCustomer()) ? "Tenant" : definition.getCustomer())); + } + + public void register(DashboardDefinition definition, Dashboard dashboard) { + register(definition.getJsonId(), dashboard.getId()); + createdEntities.put(dashboard.getUuidId(), new CreatedEntityInfo(dashboard.getTitle(), EntityType.DASHBOARD, + StringUtils.isEmpty(definition.getCustomer()) ? "Tenant" : definition.getCustomer())); + } + + public void register(CalculatedField calculatedField) { + register(calculatedField.getId()); + EntityId entityId = calculatedField.getEntityId(); + CreatedEntityInfo entityInfo = createdEntities.get(entityId.getId()); + boolean alarmRule = false; // TODO: CE doesn't have ALARM calculated field type yet + if (entityInfo == null) { + String target = alarmRule ? "Alarm rule" : "Calculated field"; + throw new IllegalStateException("Failed to register " + target + " with name: " + + calculatedField.getName() + " for non-existing entity with id: " + entityId); + } + if (alarmRule) { + createdAlarmRules.put(calculatedField.getUuidId(), new CreatedAlarmRuleInfo(entityId, entityInfo.getName(), calculatedField.getName(), null)); + return; + } + createdCalculatedFields.put(calculatedField.getUuidId(), new CreatedCalculatedFieldInfo(entityId, entityInfo.getName(), calculatedField.getType().name(), calculatedField.getName())); + } + + public void putIdToMap(EntityDefinition entityDefinition, EntityId entityId) { + putIdToMap(entityDefinition.getEntityType(), entityDefinition.getName(), entityId); + } + + public void putIdToMap(EntityType entityType, String entityName, EntityId entityId) { + putIdToMap(tenantId, entityType, entityName, entityId); + } + + public void putIdToMap(EntityId ownerId, EntityType entityType, String entityName, EntityId entityId) { + put(new EntitySearchKey(ownerId, entityType, entityName), entityId); + } + + public void put(EntitySearchKey entitySearchKey, EntityId entityId) { + entityIdMap.put(entitySearchKey, entityId); + } + + @SuppressWarnings("unchecked") + public T getIdFromMap(EntityType entityType, String entityName) { + return (T) entityIdMap.get(new EntitySearchKey(tenantId, entityType, entityName)); + } + + public void put(EntityId entityId, List relations) { + relationDefinitions.put(entityId, relations); + } + + public void addDeviceCredentials(DeviceCredentialsInfo deviceCredentialsInfo) { + createdDevices.put(deviceCredentialsInfo.getName(), deviceCredentialsInfo); + } + + public void addUserCredentials(UserCredentialsInfo userCredentialsInfo) { + createdUsers.put(userCredentialsInfo.getName(), userCredentialsInfo); + } + + public void addEdgeLinkInfo(String edgeName, EdgeLinkInfo edgeLinkInfo) { + createdEdges.put(edgeName, edgeLinkInfo); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/UserCredentialsInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/UserCredentialsInfo.java new file mode 100644 index 0000000000..d35900c5b4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/UserCredentialsInfo.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data; + +import lombok.Data; + +@Data +public class UserCredentialsInfo { + + String name; + String login; + String password; + String customerName; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetDefinition.java new file mode 100644 index 0000000000..2554dbafb0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetDefinition.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public class AssetDefinition extends CustomerEntityDefinition { + + private String type; + private String label; + private String emulator; + + @Override + public EntityType getEntityType() { + return EntityType.ASSET; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetProfileDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetProfileDefinition.java new file mode 100644 index 0000000000..9f95294fa7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetProfileDefinition.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.asset.AssetProfile; + +@Data +@EqualsAndHashCode(callSuper = true) +public class AssetProfileDefinition extends AssetProfile { + + private String jsonId; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/BaseEntityDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/BaseEntityDefinition.java new file mode 100644 index 0000000000..84ec880380 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/BaseEntityDefinition.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Collections; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public abstract class BaseEntityDefinition implements EntityDefinition { + + @JsonProperty("name") + private String name; + @JsonProperty("attributes") + private JsonNode attributes; + @JsonProperty("sharedAttributes") + private JsonNode sharedAttributes; + @JsonProperty("relations") + private List relations = Collections.emptyList(); + @JsonProperty("jsonId") + private String jsonId; + + public void setRelations(List relations) { + if (relations != null) { + this.relations = relations; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CalculatedFieldDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CalculatedFieldDefinition.java new file mode 100644 index 0000000000..1dcc7a6bbd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CalculatedFieldDefinition.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.cf.CalculatedField; + +@Data +@EqualsAndHashCode(callSuper = true) +public class CalculatedFieldDefinition extends CalculatedField { + + private Integer reprocessingOrder; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerDefinition.java new file mode 100644 index 0000000000..3cc40c658c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerDefinition.java @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.service.solutions.data.names.RandomNameData; + +import java.util.Collections; +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +public class CustomerDefinition extends BaseEntityDefinition { + + private String email; + private String country; + private String city; + private String state; + private String zip; + private String address; + private List users = Collections.emptyList(); + + @JsonIgnore + private RandomNameData randomNameData; + + @Override + public EntityType getEntityType() { + return EntityType.CUSTOMER; + } + + public void setUsers(List users) { + if (users != null) { + this.users = users; + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerEntityDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerEntityDefinition.java new file mode 100644 index 0000000000..d2da2f266c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerEntityDefinition.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public abstract class CustomerEntityDefinition extends BaseEntityDefinition { + + private String customer; + private boolean makePublic; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardDefinition.java new file mode 100644 index 0000000000..a5a9e9c22b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardDefinition.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public class DashboardDefinition extends CustomerEntityDefinition { + + private String file; + private boolean main; + + @Override + public EntityType getEntityType() { + return EntityType.DASHBOARD; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardUserDetailsDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardUserDetailsDefinition.java new file mode 100644 index 0000000000..2ec4701829 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardUserDetailsDefinition.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.Data; + +@Data +public class DashboardUserDetailsDefinition { + + private String name; + private boolean fullScreen; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceDefinition.java new file mode 100644 index 0000000000..81239c46dd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceDefinition.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public class DeviceDefinition extends CustomerEntityDefinition { + + private String type; + private String label; + private String emulator; + private JsonNode additionalInfo; + + @Override + public EntityType getEntityType() { + return EntityType.DEVICE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceProfileDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceProfileDefinition.java new file mode 100644 index 0000000000..3dee5c179a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceProfileDefinition.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.DeviceProfile; + +@Data +@EqualsAndHashCode(callSuper = true) +public class DeviceProfileDefinition extends DeviceProfile { + + private String jsonId; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EdgeDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EdgeDefinition.java new file mode 100644 index 0000000000..5e8e7aa203 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EdgeDefinition.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; + +import java.util.Collections; +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public class EdgeDefinition extends CustomerEntityDefinition { + + private String type; + private String label; + private String rootRuleChainId; + private List ruleChainIds = Collections.emptyList(); + private List deviceIds = Collections.emptyList(); + private List assetIds = Collections.emptyList(); + private List dashboardIds = Collections.emptyList(); + + @Override + public EntityType getEntityType() { + return EntityType.EDGE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EmulatorDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EmulatorDefinition.java new file mode 100644 index 0000000000..37c726e091 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EmulatorDefinition.java @@ -0,0 +1,64 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.Data; +import org.thingsboard.server.common.data.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Data +public class EmulatorDefinition { + private String name; + private String extendz; + private String clazz; + private int publishPeriodInDays; + private int publishFrequencyInSeconds; + private int publishPauseInMillis; + private long activityPeriodInMillis; + private List telemetryProfiles = Collections.emptyList(); + + public void enrich(EmulatorDefinition parent) { + if (StringUtils.isEmpty(clazz)) { + clazz = parent.getClazz(); + } + if (publishPeriodInDays == 0) { + publishPeriodInDays = parent.getPublishPeriodInDays(); + } + if (publishFrequencyInSeconds == 0) { + publishFrequencyInSeconds = parent.getPublishFrequencyInSeconds(); + } + if (publishPauseInMillis == 0) { + publishPauseInMillis = parent.getPublishPauseInMillis(); + } + if (activityPeriodInMillis == 0L) { + activityPeriodInMillis = parent.getActivityPeriodInMillis(); + } + var profilesMap = telemetryProfiles.stream().collect(Collectors.toMap(TelemetryProfile::getKey, Function.identity())); + parent.getTelemetryProfiles().forEach(tp -> profilesMap.putIfAbsent(tp.getKey(), tp)); + telemetryProfiles = new ArrayList<>(profilesMap.values()); + } + + public long getOldestTs(long startTs) { + return startTs - TimeUnit.DAYS.toMillis(publishPeriodInDays) - publishFrequencyInSeconds; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntityDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntityDefinition.java new file mode 100644 index 0000000000..c76d183bda --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntityDefinition.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import org.thingsboard.server.common.data.EntityType; + +public interface EntityDefinition { + + String getName(); + + EntityType getEntityType(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntitySearchKey.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntitySearchKey.java new file mode 100644 index 0000000000..f880e909b0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntitySearchKey.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityId; + +@Data +@AllArgsConstructor +public class EntitySearchKey { + + private final EntityId ownerId; + private final EntityType entityType; + private final String entityName; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/ReferenceableEntityDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/ReferenceableEntityDefinition.java new file mode 100644 index 0000000000..13dabb08cc --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/ReferenceableEntityDefinition.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ReferenceableEntityDefinition { + + private String name; + private String file; + private String update; + private String jsonId; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/RelationDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/RelationDefinition.java new file mode 100644 index 0000000000..670d3f1ab4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/RelationDefinition.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RelationDefinition { + + private EntityType entityType; + private String entityName; + private EntitySearchDirection direction; + private String type; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TelemetryProfile.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TelemetryProfile.java new file mode 100644 index 0000000000..259744a2f0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TelemetryProfile.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.thingsboard.server.service.solutions.data.values.ValueStrategyDefinition; + +@Data +@AllArgsConstructor +public class TelemetryProfile { + + private String key; + private ValueStrategyDefinition valueStrategy; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TenantDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TenantDefinition.java new file mode 100644 index 0000000000..957547cff6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TenantDefinition.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.Data; +import org.thingsboard.server.common.data.EntityType; + +@Data +public class TenantDefinition extends BaseEntityDefinition { + + @Override + public EntityType getEntityType() { + return EntityType.TENANT; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/UserDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/UserDefinition.java new file mode 100644 index 0000000000..1815cc6e57 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/UserDefinition.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.definition; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public class UserDefinition extends BaseEntityDefinition { + + private String firstname; + private String lastname; + private String password; + private DashboardUserDetailsDefinition dashboard; + + @Override + public EntityType getEntityType() { + return EntityType.USER; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AbstractEmulatorLauncher.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AbstractEmulatorLauncher.java new file mode 100644 index 0000000000..abed74af3e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AbstractEmulatorLauncher.java @@ -0,0 +1,195 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.emulator; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.HasName; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgDataType; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +@Data +public abstract class AbstractEmulatorLauncher & HasTenantId & HasName> { + + protected static final TbQueueCallback EMPTY_CALLBACK = new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + + } + + @Override + public void onFailure(Throwable t) { + + } + }; + protected final T entity; + protected final EmulatorDefinition emulatorDefinition; + protected final ExecutorService oldTelemetryExecutor; + protected final TbClusterService tbClusterService; + protected final PartitionService partitionService; + protected final TbQueueProducerProvider tbQueueProducerProvider; + protected final TbServiceInfoProvider serviceInfoProvider; + protected final TelemetrySubscriptionService tsSubService; + protected final long publishFrequency; + + private final Emulator emulator; + private ScheduledFuture scheduledFuture; + + public AbstractEmulatorLauncher(T entity, + EmulatorDefinition emulatorDefinition, + ExecutorService oldTelemetryExecutor, + TbClusterService tbClusterService, + PartitionService partitionService, + TbQueueProducerProvider tbQueueProducerProvider, + TbServiceInfoProvider serviceInfoProvider, + TelemetrySubscriptionService tsSubService) throws Exception { + this.entity = entity; + this.emulatorDefinition = emulatorDefinition; + this.oldTelemetryExecutor = oldTelemetryExecutor; + this.tbClusterService = tbClusterService; + this.partitionService = partitionService; + this.tbQueueProducerProvider = tbQueueProducerProvider; + this.serviceInfoProvider = serviceInfoProvider; + this.tsSubService = tsSubService; + this.publishFrequency = TimeUnit.SECONDS.toMillis(Math.max(emulatorDefinition.getPublishFrequencyInSeconds(), 1)); + if (StringUtils.isEmpty(emulatorDefinition.getClazz())) { + emulator = new BasicEmulator(); + } else { + emulator = (Emulator) Class.forName(emulatorDefinition.getClazz()).getDeclaredConstructor().newInstance(); + } + emulator.init(emulatorDefinition); + } + + public CompletableFuture launch() { + final long oldestTs = emulatorDefinition.getOldestTs(System.currentTimeMillis()); + CompletableFuture future = new CompletableFuture<>(); + oldTelemetryExecutor.submit(() -> { + AtomicInteger pending = new AtomicInteger(1); + try { + if (emulator instanceof SimpleEmulator) { + if (oldestTs < (System.currentTimeMillis() - publishFrequency)) { + pushOldTelemetry(oldestTs, pending, future); + } + } else if (emulator instanceof CustomEmulator customEmulator) { + Pair telemetry = customEmulator.getNextValue(); + while (telemetry != null) { + pending.incrementAndGet(); + publishTelemetry(telemetry.getFirst(), telemetry.getSecond(), pending, future); + telemetry = customEmulator.getNextValue(); + } + } + } catch (Exception e) { + log.warn("Telemetry upload failed for {}", entity.getName(), e); + future.completeExceptionally(e); + } finally { + if (pending.decrementAndGet() == 0 && !future.isDone()) { + log.trace("[{}] Telemetry emulation finished. Producer and all callbacks completed successfully", entity.getName()); + future.complete(null); + } + } + + future.whenComplete((v, t) -> { + try { + postProcessEntity(entity); + } catch (Exception e) { + log.warn("Post-processing failed for {}", entity.getName(), e); + } + }); + + }); + return future; + } + + protected void postProcessEntity(T entity) { + } + + private void pushOldTelemetry(long oldestTs, AtomicInteger pending, CompletableFuture future) throws InterruptedException { + for (long ts = oldestTs; ts < System.currentTimeMillis(); ts += publishFrequency) { + pending.incrementAndGet(); + publishTelemetry(ts, pending, future); + } + } + + private void publishTelemetry(long ts, AtomicInteger pending, CompletableFuture future) throws InterruptedException { + ObjectNode values = ((SimpleEmulator) emulator).getValue(ts); + publishTelemetry(ts, values, pending, future); + Thread.sleep(emulatorDefinition.getPublishPauseInMillis()); + } + + public void stop() { + scheduledFuture.cancel(true); + } + + private void publishTelemetry(long ts, ObjectNode value, AtomicInteger pending, CompletableFuture future) { + String msgData = JacksonUtil.toString(value); + log.debug("[{}] Publishing telemetry: {}", entity.getName(), msgData); + TbMsgMetaData md = new TbMsgMetaData(); + md.putValue("ts", Long.toString(ts)); + TbMsg tbMsg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(entity.getId()) + .copyMetaData(md) + .dataType(TbMsgDataType.JSON) + .data(msgData) + .build(); + + TbQueueCallback callback = new TbQueueCallback() { + + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + log.debug("[{}] Successfully pushed message to Rule Engine for ts {}", entity.getName(), ts); + if (pending.decrementAndGet() == 0 && !future.isDone()) { + log.trace("[{}] Completing emulation future from callback for msg with ts: {}", entity.getName(), ts); + future.complete(null); + } + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}] Telemetry upload failed for ts {}", entity.getName(), ts, t); + if (pending.decrementAndGet() == 0 && !future.isDone()) { + future.completeExceptionally(t); + } + } + }; + + tbClusterService.pushMsgToRuleEngine(entity.getTenantId(), entity.getId(), tbMsg, callback); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AssetEmulatorLauncher.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AssetEmulatorLauncher.java new file mode 100644 index 0000000000..1f2d144b99 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AssetEmulatorLauncher.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.emulator; + +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import java.util.concurrent.ExecutorService; + +@Slf4j +public class AssetEmulatorLauncher extends AbstractEmulatorLauncher { + + @Builder + public AssetEmulatorLauncher(Asset entity, EmulatorDefinition emulatorDefinition, ExecutorService oldTelemetryExecutor, TbClusterService tbClusterService, + PartitionService partitionService, + TbQueueProducerProvider tbQueueProducerProvider, + TbServiceInfoProvider serviceInfoProvider, + TelemetrySubscriptionService tsSubService) throws Exception { + super(entity, emulatorDefinition, oldTelemetryExecutor, tbClusterService, partitionService, tbQueueProducerProvider, serviceInfoProvider, tsSubService); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/BasicEmulator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/BasicEmulator.java new file mode 100644 index 0000000000..c81aed3c02 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/BasicEmulator.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.emulator; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; +import org.thingsboard.server.service.solutions.data.values.TelemetryGenerator; +import org.thingsboard.server.service.solutions.data.values.TelemetryGeneratorFactory; + +import java.util.HashMap; +import java.util.Map; + +public class BasicEmulator implements SimpleEmulator { + + private final Map tsGenerators = new HashMap<>(); + + @Override + public void init(EmulatorDefinition emulatorDefinition) { + emulatorDefinition.getTelemetryProfiles().forEach(tp -> tsGenerators.put(tp.getKey(), TelemetryGeneratorFactory.create(tp))); + } + + @Override + public ObjectNode getValue(long ts) { + ObjectNode values = JacksonUtil.newObjectNode(); + tsGenerators.values().forEach(gen -> gen.addValue(ts, values)); + return values; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/CustomEmulator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/CustomEmulator.java new file mode 100644 index 0000000000..627fe4e94d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/CustomEmulator.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.emulator; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.data.util.Pair; + +public interface CustomEmulator extends Emulator { + + Pair getNextValue(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/DeviceEmulatorLauncher.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/DeviceEmulatorLauncher.java new file mode 100644 index 0000000000..62fe03aa31 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/DeviceEmulatorLauncher.java @@ -0,0 +1,98 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.emulator; + +import com.google.common.util.concurrent.FutureCallback; +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.thingsboard.rule.engine.api.AttributesSaveRequest; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; +import org.thingsboard.server.service.state.DefaultDeviceStateService; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import java.util.UUID; +import java.util.concurrent.ExecutorService; + +@Slf4j +public class DeviceEmulatorLauncher extends AbstractEmulatorLauncher { + + @Builder + public DeviceEmulatorLauncher(Device entity, + EmulatorDefinition emulatorDefinition, + ExecutorService oldTelemetryExecutor, + TbClusterService tbClusterService, + PartitionService partitionService, + TbQueueProducerProvider tbQueueProducerProvider, + TbServiceInfoProvider serviceInfoProvider, + TelemetrySubscriptionService tsSubService) throws Exception { + super(entity, emulatorDefinition, oldTelemetryExecutor, tbClusterService, partitionService, tbQueueProducerProvider, serviceInfoProvider, tsSubService); + } + + @Override + protected void postProcessEntity(Device entity) { + if (this.emulatorDefinition.getActivityPeriodInMillis() > 0) { + tsSubService.saveAttributes(AttributesSaveRequest.builder() + .tenantId(entity.getTenantId()) + .entityId(entity.getId()) + .scope(AttributeScope.SERVER_SCOPE) + .entry(new LongDataEntry(DefaultDeviceStateService.INACTIVITY_TIMEOUT, this.emulatorDefinition.getActivityPeriodInMillis())) + .callback(new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Void unused) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, entity.getTenantId(), entity.getId()); + UUID sessionId = UUID.randomUUID(); + TransportProtos.TransportToDeviceActorMsg msg = TransportProtos.TransportToDeviceActorMsg.newBuilder() + .setSessionInfo(TransportProtos.SessionInfoProto.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setDeviceIdMSB(entity.getId().getId().getMostSignificantBits()) + .setDeviceIdLSB(entity.getId().getId().getLeastSignificantBits()) + .setDeviceProfileIdMSB(entity.getId().getId().getMostSignificantBits()) + .setDeviceProfileIdLSB(entity.getId().getId().getLeastSignificantBits()) + .setDeviceName(entity.getName()) + .setDeviceType(entity.getType()) + .setTenantIdMSB(entity.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(entity.getTenantId().getId().getLeastSignificantBits()) + .setNodeId(serviceInfoProvider.getServiceId()) + .build()) + .setSubscriptionInfo(TransportProtos.SubscriptionInfoProto.newBuilder().setLastActivityTime(System.currentTimeMillis()).build()) + .build(); + tbQueueProducerProvider.getTbCoreMsgProducer().send(tpi, + new TbProtoQueueMsg<>(entity.getUuidId(), + TransportProtos.ToCoreMsg.newBuilder().setToDeviceActorMsg(msg).build()), EMPTY_CALLBACK + ); + } + + @Override + public void onFailure(Throwable throwable) { + } + }) + .build()); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/Emulator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/Emulator.java new file mode 100644 index 0000000000..45a9a029b8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/Emulator.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.emulator; + +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; + +public interface Emulator { + + void init(EmulatorDefinition emulatorDefinition); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/FieldIrrigationStateEmulator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/FieldIrrigationStateEmulator.java new file mode 100644 index 0000000000..44c9eb550f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/FieldIrrigationStateEmulator.java @@ -0,0 +1,108 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.emulator; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.data.util.Pair; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; +import java.util.TimeZone; + +public class FieldIrrigationStateEmulator implements CustomEmulator { + + private static final int START_HOUR = 6; + private int idx; + private List> data = new ArrayList<>(); + + @Override + public void init(EmulatorDefinition emulatorDefinition) { + idx = 0; + var tz = TimeZone.getTimeZone("America/New_York"); + Calendar c = Calendar.getInstance(); + c.setTimeInMillis(System.currentTimeMillis()); + c.setTimeZone(tz); + int curHour = c.get(Calendar.HOUR_OF_DAY); + c.add(Calendar.DAY_OF_MONTH, curHour < START_HOUR ? -7 : -6); + c.set(Calendar.HOUR_OF_DAY, START_HOUR); + c.set(Calendar.MINUTE, 0); + + add(c, JacksonUtil.newObjectNode() + .put("startTs", c.getTimeInMillis()) + .put("durationThreshold", 30 * 60000) + .put("consumption", 423) + .put("duration", 30 * 60000) + ); + add(c, JacksonUtil.newObjectNode() + .put("startTs", c.getTimeInMillis()) + .put("durationThreshold", 30 * 60000) + .put("consumption", 407) + .put("duration", 30 * 60000) + ); + add(c, JacksonUtil.newObjectNode() + .put("startTs", c.getTimeInMillis()) + .put("consumptionThreshold", 1000) + .put("consumption", 1000) + .put("duration", 63 * 60000) + ); + add(c, JacksonUtil.newObjectNode() + .put("startTs", c.getTimeInMillis()) + .put("consumptionThreshold", 500) + .put("consumption", 500) + .put("duration", 36 * 60000) + ); + add(c, JacksonUtil.newObjectNode() + .put("startTs", c.getTimeInMillis()) + .put("consumptionThreshold", 1000) + .put("consumption", 1000) + .put("duration", 63 * 60000) + ); + add(c, JacksonUtil.newObjectNode() + .put("startTs", c.getTimeInMillis()) + .put("durationThreshold", 30 * 60000) + .put("duration", 30 * 60000) + .put("consumption", 452) + ); + add(c, JacksonUtil.newObjectNode() + .put("startTs", c.getTimeInMillis()) + .put("durationThreshold", 30 * 60000) + .put("duration", 30 * 60000) + .put("consumption", 447) + ); + } + + private void add(Calendar c, ObjectNode objectNode) { + ObjectNode startIrrigationMsg = JacksonUtil.newObjectNode(); + startIrrigationMsg.put("irrigationState", "DONE"); + startIrrigationMsg.set("irrigationTask", objectNode); + data.add(Pair.of(c.getTimeInMillis(), startIrrigationMsg)); + c.add(Calendar.DAY_OF_MONTH, 1); + } + + @Override + public Pair getNextValue() { + if (idx < data.size()) { + var result = data.get(idx); + idx++; + return result; + } else { + return null; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/SimpleEmulator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/SimpleEmulator.java new file mode 100644 index 0000000000..144a5f7f26 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/SimpleEmulator.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.emulator; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +public interface SimpleEmulator extends Emulator { + + ObjectNode getValue(long ts); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameData.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameData.java new file mode 100644 index 0000000000..ca66c37199 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameData.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.names; + +import lombok.Data; + +@Data +public class RandomNameData { + + private final String firstName; + private final String lastName; + private final String email; +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameUtil.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameUtil.java new file mode 100644 index 0000000000..50bc3b9dc5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameUtil.java @@ -0,0 +1,100 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.names; + +import org.thingsboard.server.common.data.StringUtils; + +import java.util.Random; + +public class RandomNameUtil { + + private static final Random RANDOM = new Random(System.currentTimeMillis()); + + private static String[] FIRST_NAMES = new String[]{ + "Nadia", "Lynne", "Lera", "Holly", "Zina", "Mandi", "Kasie", "Josef", "Liane", "Man", + "Karan", "Nga", "Tesha", "Elva", "Bree", "Ida", "Mimi", "Altha", "Omar", "Ollie", + "Nicol", "Ned", "Teri", "Sabra", "Kari", "Eva", "Penni", "Simon", "Kaci", "Jess", + "Bea", "Ron", "Melba", "Vella", "Nada", "Cyndi", "Audry", "Helga", "Chana", "Lola", + "Terry", "Tami", "Dedra", "Erwin", "Anne", "Zoila", "Nelia", "Hyman", "Deane", "Erica", + "Amos", "Maura", "Gwenn", "Evan", "Lelia", "Grant", "Abe", "Fanny", "Cindi", "Pilar", + "Darcy", "Len", "Casie", "Jose", "Kylie", "Cami", "Casey", "Kerri", "Bruno", "Theda", + "Ardis", "Carin", "Belia", "Jeff", "Yetta", "Ola", "Lyla", "Megan", "Zita", "Rocky", + "Darci", "Dale", "Mirta", "Tanja", "Stacy", "Julie", "Aisha", "Avis", "Hugh", "Anh", + "Bud", "Earle", "Ossie", "Odell", "Dovie", "Afton", "Joel", "Aida", "Vina", "Emiko", + "Tori", "Keena", "Addie", "Nancy", "Tonda", "Josue", "Dina", "Mitch", "Thea", "Cole", + "Lyman", "Donna", "Roma", "Deena", "Lue", "Bette", "Ilene", "Vera", "Kirby", "Lenna", + "Pat", "Grace", "Gus", "Frank", "Lena", "Adele", "Kerry", "Santo", "Wade", "Trudi", + "Bruce", "Raul", "Katy", "Heidi", "Marna", "Faith", "Ronna", "Kylee", "Emma", "Luna", + "Ilda", "Mose", "Juan", "Cori", "Gayla", "Otha", "Jung", "Bambi", "Joi", "Sibyl", + "Reid", "Bonny", "Roni", "Joy", "Farah", "Jeane", "Jill", "Tisha", "Leon", "Leigh", + "Inge", "Zella", "Rubin", "Carri", "Nana", "Sofia", "Lucio", "Eboni", "Adam", "Lino", + "Elvis", "Marci", "Adina", "Nanci", "Joyce", "James", "Louis", "Aurea", "Chi", "Kathy", + "Nina", "Lise", "Reda", "Candi", "Ralph", "Velva", "Mari", "Mavis", "Kory", "Tammi", + "Lucy", "Lavon", "Ana", "Lin", "Alena", "Haley", "Myra", "Amy", "Marlo", "Ami" + }; + + private static String[] LAST_NAMES = new String[]{ + "Vinson", "Rogers", "Burkett", "Gamboa", "Gross", "Toledo", "Kiser", "Harman", "Pierce", "Lovett", + "Sexton", "Coates", "Seymour", "Holland", "Finley", "Hagen", "Schmitt", "Beach", "Rea", "Peck", + "Noel", "Archer", "Zamora", "Wood", "Rivera", "Lentz", "Alonso", "Rider", "Story", "Schwab", + "Mercado", "Caruso", "Bynum", "Spears", "Bingham", "Tyler", "Ponce", "Skaggs", "Matos", "Stinson", + "Burgos", "Coles", "Helton", "Joyce", "Galvan", "Grant", "Stahl", "Daigle", "Hayden", "Quiroz", + "Horne", "Shaw", "Addison", "Harvey", "Watkins", "Romano", "Ahmed", "Lind", "Swenson", "Kemp", + "Barnes", "Horton", "Dugan", "Farr", "Swanson", "Hale", "Hidalgo", "Schulte", "Ervin", "Osorio", + "Lincoln", "Zhang", "Oakes", "Stiles", "Lu", "Culver", "Singer", "Knott", "Bullard", "Butts", + "Coley", "Roth", "Cobb", "Darnell", "Vaughan", "Conrad", "Austin", "Rico", "Hobson", "Brunner", + "Mccray", "Mattson", "Barber", "Clement", "Ly", "Dejesus", "Painter", "Macias", "Healy", "Duncan", + "Forrest", "Hurley", "Torres", "Blue", "Rucker", "Ferrell", "Maddox", "Osborne", "Holt", "Briggs", + "Irvin", "Bassett", "Urban", "Overton", "Sloan", "Guzman", "Estrada", "Redding", "Gold", "Paz", + "Lucero", "Reyes", "Conner", "Keith", "Hays", "Hutton", "Moore", "Tuttle", "Powers", "William", + "Reeder", "Castro", "Vela", "Baca", "Frazier", "Kline", "Huggins", "Zepeda", "Pate", "Marion", + "Hollis", "Ybarra", "Cuellar", "Gallo", "Gomes", "Louis", "Bolden", "Liu", "Hayes", "Simpson", + "Otero", "Conway", "Peoples", "Mohr", "Loomis", "Engel", "Hughes", "Chapman", "Bower", "Schmidt", + "Mcmahon", "Booth", "Ward", "Drake", "Cutler", "Gordon", "Black", "Clifton", "Curran", "Pierre", + "Workman", "Tilley", "Moody", "Kuhn", "Eastman", "Scott", "Vang", "Tillman", "Cleary", "Yi", + "Griffin", "Mendoza", "Mason", "Pittman", "Landis", "Gay", "Rowland", "Jordan", "Lo", "Kumar", + "Vernon", "Boyd", "Rocha", "Mcqueen", "Cornett", "Deaton", "Borden", "Lacey", "Gaston", "Miles" + }; + + public static String nextFirstName() { + return randomElement(FIRST_NAMES); + } + + public static String nextLastName() { + return randomElement(LAST_NAMES); + } + + private static String toEmail(String firstName, String lastName) { + return firstName.toLowerCase() + "." + lastName.toLowerCase() + "@thingsboard.io"; + } + + public static RandomNameData next() { + var firstName = nextFirstName(); + var lastName = nextLastName(); + return new RandomNameData(firstName, lastName, toEmail(firstName, lastName)); + } + + public static RandomNameData nextSuperRandom() { + var firstName = nextFirstName() + StringUtils.randomAlphanumeric(10).toLowerCase(); + var lastName = nextLastName() + StringUtils.randomAlphanumeric(10).toLowerCase(); + return new RandomNameData(firstName, lastName, toEmail(firstName, lastName)); + } + + private static String randomElement(String[] array) { + return array[RANDOM.nextInt(array.length)]; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/SolutionInstallResponse.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/SolutionInstallResponse.java new file mode 100644 index 0000000000..a122a562a9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/SolutionInstallResponse.java @@ -0,0 +1,54 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.solution; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.Collections; +import java.util.List; + +@Schema +@Data +public class SolutionInstallResponse extends TenantSolutionTemplateInstructions { + + @Schema(description = "Indicates that template was installed successfully") + private boolean success; + @Schema(description = "List of entity IDs created during solution installation") + private List createdEntityIds; + @Schema(description = "What keys to delete during template uninstall") + private List tenantTelemetryKeys; + @Schema(description = "What attributes to delete during template uninstall") + private List tenantAttributeKeys; + + public SolutionInstallResponse(TenantSolutionTemplateInstructions instructions, boolean success, List createdEntityIds) { + this(instructions, success, createdEntityIds, Collections.emptyList(), Collections.emptyList()); + } + + public SolutionInstallResponse(TenantSolutionTemplateInstructions instructions, boolean success, List createdEntityIds, + List tenantTelemetryKeys, List tenantAttributeKeys) { + super(instructions); + this.success = success; + this.createdEntityIds = createdEntityIds; + this.tenantTelemetryKeys = tenantTelemetryKeys; + this.tenantAttributeKeys = tenantAttributeKeys; + } + + public SolutionInstallResponse() { + super(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/TenantSolutionTemplateInstructions.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/TenantSolutionTemplateInstructions.java new file mode 100644 index 0000000000..fabd89d072 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/TenantSolutionTemplateInstructions.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.solution; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; + +@Schema +@Data +@AllArgsConstructor +@NoArgsConstructor +public class TenantSolutionTemplateInstructions { + + @Schema(description = "Id of the main dashboard of the solution") + private DashboardId dashboardId; + @Schema(description = "Id of the public customer if solution has public entities") + private CustomerId publicId; + @Schema(description = "Is the main dashboard public") + private boolean mainDashboardPublic; + @Schema(description = "Markdown with solution usage instructions") + private String details; + + public TenantSolutionTemplateInstructions(TenantSolutionTemplateInstructions instructions) { + this.dashboardId = instructions.getDashboardId(); + this.publicId = instructions.getPublicId(); + this.mainDashboardPublic = instructions.isMainDashboardPublic(); + this.details = instructions.getDetails(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyDefinition.java new file mode 100644 index 0000000000..ba803faf87 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyDefinition.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class CompositeValueStrategyDefinition implements ValueStrategyDefinition { + + private ValueStrategyDefinition defaultHours; + private ValueStrategyDefinition workHours; + private ValueStrategyDefinition nightHours; + private ValueStrategyDefinition holidayHours; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.COMPOSITE; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyGenerator.java new file mode 100644 index 0000000000..6cbfaf550e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyGenerator.java @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +public class CompositeValueStrategyGenerator extends TelemetryGenerator { + + TelemetryGenerator defaultGenerator; + TelemetryGenerator whGenerator; + TelemetryGenerator nhGenerator; + TelemetryGenerator hhGenerator; + + public CompositeValueStrategyGenerator(TelemetryProfile tp) { + super(tp); + var def = (CompositeValueStrategyDefinition) tp.getValueStrategy(); + defaultGenerator = TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), def.getDefaultHours())); + if (def.getWorkHours() != null) { + whGenerator = TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), def.getWorkHours())); + } + if (def.getNightHours() != null) { + nhGenerator = TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), def.getNightHours())); + } + if (def.getHolidayHours() != null) { + hhGenerator = TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), def.getHolidayHours())); + } + } + + @Override + public void addValue(long ts, ObjectNode values) { + if (hhGenerator != null && GeneratorTools.isHoliday(ts)) { + hhGenerator.addValue(ts, values); + } else if (nhGenerator != null && GeneratorTools.isNightHour(ts)) { + nhGenerator.addValue(ts, values); + } else if (whGenerator != null && GeneratorTools.isWorkHour(ts)) { + whGenerator.addValue(ts, values); + } else { + defaultGenerator.addValue(ts, values); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantTelemetryGenerator.java new file mode 100644 index 0000000000..59ca319f67 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantTelemetryGenerator.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +public class ConstantTelemetryGenerator extends TelemetryGenerator { + + private final ConstantValueStrategyDefinition strategy; + private JsonNode value; + + public ConstantTelemetryGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + this.strategy = (ConstantValueStrategyDefinition) telemetryProfile.getValueStrategy(); + this.value = strategy.getValue(); + } + + @Override + public void addValue(long ts, ObjectNode values) { + values.set(key, value); + } + + @Override + public double getValue() { + if (value != null && value.isNumber()) { + return value.doubleValue(); + } else { + return super.getValue(); + } + } + + @Override + public void setValue(double value) { + // do nothing; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantValueStrategyDefinition.java new file mode 100644 index 0000000000..ef18ef2c9b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantValueStrategyDefinition.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class ConstantValueStrategyDefinition implements ValueStrategyDefinition { + private JsonNode value; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.CONSTANT; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterTelemetryGenerator.java new file mode 100644 index 0000000000..544d143893 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterTelemetryGenerator.java @@ -0,0 +1,69 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Getter; +import lombok.Setter; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.getMultiplier; +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomDouble; + +public class CounterTelemetryGenerator extends TelemetryGenerator { + + private final CounterValueStrategyDefinition strategy; + @Getter @Setter + private double value; + + public CounterTelemetryGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + this.strategy = (CounterValueStrategyDefinition) telemetryProfile.getValueStrategy(); + this.value = getRandomStartValue(); + } + + @Override + public void addValue(long ts, ObjectNode values) { + double step = randomDouble(strategy.getMinIncrement(), strategy.getMaxIncrement()); + double multiplier = getMultiplier(ts, strategy.getHolidayMultiplier(), strategy.getWorkHoursMultiplier(), strategy.getNightHoursMultiplier()); + value += step * multiplier; + if (value > getRandomEndValue()) { + value = getRandomStartValue(); + } + put(values, value); + } + + private void put(ObjectNode values, double value) { + if (strategy.getPrecision() == 0) { + values.put(key, (int) value); + } else { + values.put(key, BigDecimal.valueOf(value) + .setScale(strategy.getPrecision(), RoundingMode.HALF_UP) + .doubleValue()); + } + } + + public double getRandomStartValue() { + return randomDouble(strategy.getMinStartValue(), strategy.getMaxStartValue()); + } + + public double getRandomEndValue() { + return randomDouble(strategy.getMinEndValue(), strategy.getMaxEndValue()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterValueStrategyDefinition.java new file mode 100644 index 0000000000..9010af51bd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterValueStrategyDefinition.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class CounterValueStrategyDefinition implements ValueStrategyDefinition { + + private int precision; + private double minStartValue; + private double maxStartValue; + private double minEndValue; + private double maxEndValue; + private double minIncrement; + private double maxIncrement; + private double holidayMultiplier; + private double workHoursMultiplier; + private double nightHoursMultiplier; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.COUNTER; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementTelemetryGenerator.java new file mode 100644 index 0000000000..bbcb6cedf7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementTelemetryGenerator.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomDouble; + +public class DecrementTelemetryGenerator extends IncDecTelemetryGenerator { + + public DecrementTelemetryGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + } + + @Override + public void addValue(long ts, ObjectNode values) { + double step = randomDouble(strategy.getMinDecrement(), strategy.getMaxDecrement()); + double newValue = value - step; + value = Math.max(newValue, endValue); + put(values, value); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementValueStrategyDefinition.java new file mode 100644 index 0000000000..b5f254e2e5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementValueStrategyDefinition.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class DecrementValueStrategyDefinition extends IncDecValueStrategyDefinition { + + private double minDecrement; + private double maxDecrement; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.DECREMENT; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventTelemetryGenerator.java new file mode 100644 index 0000000000..82555f7ed4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventTelemetryGenerator.java @@ -0,0 +1,54 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomLong; + + +public class EventTelemetryGenerator extends TelemetryGenerator { + + private final EventValueStrategyDefinition strategy; + private final long currentAnomaly; + private Object value; + + public EventTelemetryGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + this.strategy = (EventValueStrategyDefinition) telemetryProfile.getValueStrategy(); + currentAnomaly = randomLong(0, strategy.getAnomalyChance()); + } + + @Override + public void addValue(long ts, ObjectNode values) { + boolean anomaly = false; + if (randomLong(0, strategy.getAnomalyChance()) == currentAnomaly) { + anomaly = true; + } + this.value = anomaly ? strategy.getAnomalyValue() : strategy.getNormalValue(); + if (value instanceof Boolean) { + values.put(key, (Boolean) value); + } else if (value instanceof Double) { + values.put(key, ((Double) value)); + } else if (value instanceof Integer) { + values.put(key, ((Integer) value)); + } else { + throw new RuntimeException("Not supported value for event telemetry generator: " + value); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventValueStrategyDefinition.java new file mode 100644 index 0000000000..855004da02 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventValueStrategyDefinition.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class EventValueStrategyDefinition implements ValueStrategyDefinition { + private Object normalValue; + private Object anomalyValue; + private double anomalyChance; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.EVENT; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/GeneratorTools.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/GeneratorTools.java new file mode 100644 index 0000000000..918379faa6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/GeneratorTools.java @@ -0,0 +1,96 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import java.util.Calendar; +import java.util.Date; +import java.util.Random; +import java.util.TimeZone; + +public class GeneratorTools { + + private static final Random random = new Random(); + + public static long randomLong(double min, double max) { + return (long) randomDouble(min, max); + } + + public static double randomDouble(double min, double max) { + return min + random.nextDouble() * Math.abs(max - min); + } + + public static double getMultiplier(long ts, double holidayMultiplier, double workHoursMultiplier, double nightHoursMultiplier) { + Date date = new Date(ts); + Calendar c = Calendar.getInstance(); + c.setTime(date); + int dayOfWeek = c.get(Calendar.DAY_OF_WEEK); + int hour = c.get(Calendar.HOUR_OF_DAY); + double multiplier = 1.0; + if (dayOfWeek == 1 || dayOfWeek == 7) { + multiplier *= holidayMultiplier; + } + + if (hour > 8 && hour < 18) { + multiplier *= workHoursMultiplier; + } else if (hour < 6 || hour > 22) { + multiplier *= nightHoursMultiplier; + } + return multiplier; + } + + public static int getHour(TimeZone tz, long ts) { + Date date = new Date(ts); + Calendar c = Calendar.getInstance(); + c.setTimeZone(tz); + c.setTime(date); + return c.get(Calendar.HOUR_OF_DAY); + } + + public static int getMinute(TimeZone tz, long ts) { + Date date = new Date(ts); + Calendar c = Calendar.getInstance(); + c.setTimeZone(tz); + c.setTime(date); + return c.get(Calendar.MINUTE); + } + + public static boolean isHoliday(long ts) { + Date date = new Date(ts); + Calendar c = Calendar.getInstance(); + c.setTime(date); + int dayOfWeek = c.get(Calendar.DAY_OF_WEEK); + return dayOfWeek == 1 || dayOfWeek == 7; + } + + public static boolean isWorkHour(long ts) { + Date date = new Date(ts); + Calendar c = Calendar.getInstance(); + c.setTime(date); + int hour = c.get(Calendar.HOUR_OF_DAY); + + return hour > 8 && hour < 18; + } + + public static boolean isNightHour(long ts) { + Date date = new Date(ts); + Calendar c = Calendar.getInstance(); + c.setTime(date); + int hour = c.get(Calendar.HOUR_OF_DAY); + + return hour < 6 || hour >= 22; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecTelemetryGenerator.java new file mode 100644 index 0000000000..09d7e2dce2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecTelemetryGenerator.java @@ -0,0 +1,64 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Getter; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomDouble; + +public abstract class IncDecTelemetryGenerator extends TelemetryGenerator { + + protected final T strategy; + @Getter + protected double value; + protected double endValue; + + @SuppressWarnings("unchecked") + public IncDecTelemetryGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + this.strategy = (T) telemetryProfile.getValueStrategy(); + this.value = getRandomStartValue(); + this.endValue = getRandomEndValue(); + } + + public void setValue(double value) { + this.value = value; + this.endValue = getRandomEndValue(); + } + + protected void put(ObjectNode values, double value) { + if (strategy.getPrecision() == 0) { + values.put(key, (int) value); + } else { + values.put(key, BigDecimal.valueOf(value) + .setScale(strategy.getPrecision(), RoundingMode.HALF_UP) + .doubleValue()); + } + } + + public double getRandomStartValue() { + return randomDouble(strategy.getMinStartValue(), strategy.getMaxStartValue()); + } + + public double getRandomEndValue() { + return randomDouble(strategy.getMinEndValue(), strategy.getMaxEndValue()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecValueStrategyDefinition.java new file mode 100644 index 0000000000..adc3100dab --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecValueStrategyDefinition.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public abstract class IncDecValueStrategyDefinition implements ValueStrategyDefinition { + + private int precision; + private double minStartValue; + private double maxStartValue; + private double minEndValue; + private double maxEndValue; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementTelemetryGenerator.java new file mode 100644 index 0000000000..d18f36017f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementTelemetryGenerator.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomDouble; + +public class IncrementTelemetryGenerator extends IncDecTelemetryGenerator { + + public IncrementTelemetryGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + } + + @Override + public void addValue(long ts, ObjectNode values) { + double step = randomDouble(strategy.getMinIncrement(), strategy.getMaxIncrement()); + double newValue = value + step; + value = Math.min(newValue, endValue); + put(values, value); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementValueStrategyDefinition.java new file mode 100644 index 0000000000..64d7bc0b4c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementValueStrategyDefinition.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class IncrementValueStrategyDefinition extends IncDecValueStrategyDefinition { + + private double minIncrement; + private double maxIncrement; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.INCREMENT; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalTelemetryGenerator.java new file mode 100644 index 0000000000..927eb5a76f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalTelemetryGenerator.java @@ -0,0 +1,94 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Getter; +import lombok.Setter; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.getMultiplier; +import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomDouble; + +public class NaturalTelemetryGenerator extends TelemetryGenerator { + + private final NaturalValueStrategyDefinition strategy; + @Getter @Setter + private double value; + private boolean isIncrement; + private double lowValue; + private double highValue; + + public NaturalTelemetryGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + this.strategy = (NaturalValueStrategyDefinition) telemetryProfile.getValueStrategy(); + this.value = getRandomStartValue(); + this.lowValue = getRandomLowValue(); + this.highValue = getRandomHighValue(); + isIncrement = !strategy.isDecrementOnStart(); + } + + @Override + public void addValue(long ts, ObjectNode values) { + + double multiplier = getMultiplier(ts, strategy.getHolidayMultiplier(), strategy.getWorkHoursMultiplier(), strategy.getNightHoursMultiplier()); + + if (isIncrement) { + double step = randomDouble(strategy.getMinIncrement(), strategy.getMaxIncrement()); + value += step * multiplier; + if (value > highValue) { + value = highValue; + highValue = getRandomHighValue(); + isIncrement = false; + } + } else { + double step = randomDouble(strategy.getMinDecrement(), strategy.getMaxDecrement()); + value -= step * multiplier; + if (value < lowValue) { + value = lowValue; + lowValue = getRandomLowValue(); + isIncrement = true; + } + } + + put(values, value); + } + + private void put(ObjectNode values, double value) { + if (strategy.getPrecision() == 0) { + values.put(key, (int) value); + } else { + values.put(key, BigDecimal.valueOf(value) + .setScale(strategy.getPrecision(), RoundingMode.HALF_UP) + .doubleValue()); + } + } + + public double getRandomStartValue() { + return randomDouble(strategy.getMinStartValue(), strategy.getMaxStartValue()); + } + + public double getRandomLowValue() { + return randomDouble(strategy.getMinLowValue(), strategy.getMaxLowValue()); + } + + public double getRandomHighValue() { + return randomDouble(strategy.getMinHighValue(), strategy.getMaxHighValue()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalValueStrategyDefinition.java new file mode 100644 index 0000000000..3141baa47a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalValueStrategyDefinition.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class NaturalValueStrategyDefinition implements ValueStrategyDefinition { + private int precision; + private double minStartValue; + private double maxStartValue; + private double minLowValue; + private double maxLowValue; + private double minHighValue; + private double maxHighValue; + private double minIncrement; + private double maxIncrement; + private double minDecrement; + private double maxDecrement; + private double holidayMultiplier; + private double workHoursMultiplier; + private double nightHoursMultiplier; + private boolean decrementOnStart; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.NATURAL; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyDefinition.java new file mode 100644 index 0000000000..4804863bd3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyDefinition.java @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class ScheduleValueStrategyDefinition implements ValueStrategyDefinition { + + private List schedule; + private String timeZone; + private ValueStrategyDefinition defaultDefinition; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.SCHEDULE; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyGenerator.java new file mode 100644 index 0000000000..1d067a3044 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyGenerator.java @@ -0,0 +1,70 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TimeZone; + +public class ScheduleValueStrategyGenerator extends TelemetryGenerator { + + private TelemetryGenerator defaultGenerator; + private TimeZone timeZone; + private Map scheduleGenerators; + private TelemetryGenerator prevGenerator; + + public ScheduleValueStrategyGenerator(TelemetryProfile tp) { + super(tp); + var def = (ScheduleValueStrategyDefinition) tp.getValueStrategy(); + timeZone = TimeZone.getTimeZone(def.getTimeZone()); + defaultGenerator = TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), def.getDefaultDefinition())); + scheduleGenerators = new LinkedHashMap<>(); + for (ValueStrategySchedule scheduleItem : def.getSchedule()) { + scheduleGenerators.put(scheduleItem, TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), scheduleItem.getDefinition()))); + } + } + + @Override + public void addValue(long ts, ObjectNode values) { + int hour = GeneratorTools.getHour(timeZone, ts); + int minute = GeneratorTools.getMinute(timeZone, ts); + TelemetryGenerator generator = scheduleGenerators.entrySet().stream().filter(pair -> { + var schedule = pair.getKey(); + if (hour == schedule.getStartHour() && hour == schedule.getEndHour()) { + return schedule.getStartMinute() <= minute && minute <= schedule.getEndMinute(); + } else if (hour == schedule.getStartHour() && hour < schedule.getEndHour()) { + return schedule.getStartMinute() <= minute; + } else if (hour > schedule.getStartHour() && hour < schedule.getEndHour()) { + return true; + } else if (hour > schedule.getStartHour() && hour == schedule.getEndHour()) { + return minute <= schedule.getEndHour(); + } else { + return false; + } + }).map(Map.Entry::getValue).findFirst().orElse(defaultGenerator); + + if (prevGenerator != null && !prevGenerator.equals(generator)) { + generator.setValue(prevGenerator.getValue()); + } + + generator.addValue(ts, values); + + prevGenerator = generator; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyDefinition.java new file mode 100644 index 0000000000..d523715d67 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyDefinition.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; + + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SequenceValueStrategyDefinition implements ValueStrategyDefinition { + private boolean random; + private ObjectNode telemetry; + + @Override + public ValueStrategyDefinitionType getStrategyType() { + return ValueStrategyDefinitionType.SEQUENCE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyGenerator.java new file mode 100644 index 0000000000..d7c75025f4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyGenerator.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +import java.util.Random; + +public class SequenceValueStrategyGenerator extends TelemetryGenerator { + + private final SequenceValueStrategyDefinition strategy; + private int max; + private int index; + + public SequenceValueStrategyGenerator(TelemetryProfile telemetryProfile) { + super(telemetryProfile); + this.strategy = (SequenceValueStrategyDefinition) telemetryProfile.getValueStrategy(); + max = strategy.getTelemetry().fields().next().getValue().size() - 1; + index = strategy.isRandom() ? new Random().nextInt(max + 1) : 0; + + } + + @Override + public void addValue(long ts, ObjectNode values) { + strategy.getTelemetry().fields().forEachRemaining(entry -> { + String key = entry.getKey(); + values.set(key, entry.getValue().get(index)); + }); + index++; + if (index > max) { + index = 0; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGenerator.java new file mode 100644 index 0000000000..5024f4e590 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGenerator.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +@Data +public abstract class TelemetryGenerator { + protected final TelemetryProfile profile; + protected final String key; + + public TelemetryGenerator(TelemetryProfile telemetryProfile) { + this.profile = telemetryProfile; + this.key = telemetryProfile.getKey(); + } + + public double getValue() { + throw new RuntimeException("Not supported"); + } + + public void setValue(double value) { + throw new RuntimeException("Not supported"); + } + + public abstract void addValue(long ts, ObjectNode values); +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGeneratorFactory.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGeneratorFactory.java new file mode 100644 index 0000000000..17d29ce3e1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGeneratorFactory.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + + +import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile; + +public class TelemetryGeneratorFactory { + + public static TelemetryGenerator create(TelemetryProfile tp) { + switch (tp.getValueStrategy().getStrategyType()) { + case COUNTER: + return new CounterTelemetryGenerator(tp); + case NATURAL: + return new NaturalTelemetryGenerator(tp); + case EVENT: + return new EventTelemetryGenerator(tp); + case CONSTANT: + return new ConstantTelemetryGenerator(tp); + case SEQUENCE: + return new SequenceValueStrategyGenerator(tp); + case COMPOSITE: + return new CompositeValueStrategyGenerator(tp); + case SCHEDULE: + return new ScheduleValueStrategyGenerator(tp); + case INCREMENT: + return new IncrementTelemetryGenerator(tp); + case DECREMENT: + return new DecrementTelemetryGenerator(tp); + default: + throw new RuntimeException("Not supported!"); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinition.java new file mode 100644 index 0000000000..34d5f9b5e7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinition.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = CounterValueStrategyDefinition.class, name = "counter"), + @JsonSubTypes.Type(value = NaturalValueStrategyDefinition.class, name = "natural"), + @JsonSubTypes.Type(value = EventValueStrategyDefinition.class, name = "event"), + @JsonSubTypes.Type(value = SequenceValueStrategyDefinition.class, name = "sequence"), + @JsonSubTypes.Type(value = ConstantValueStrategyDefinition.class, name = "constant"), + @JsonSubTypes.Type(value = CompositeValueStrategyDefinition.class, name = "composite"), + @JsonSubTypes.Type(value = ScheduleValueStrategyDefinition.class, name = "schedule"), + @JsonSubTypes.Type(value = IncrementValueStrategyDefinition.class, name = "inc"), + @JsonSubTypes.Type(value = DecrementValueStrategyDefinition.class, name = "dec")}) +public interface ValueStrategyDefinition { + + ValueStrategyDefinitionType getStrategyType(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinitionType.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinitionType.java new file mode 100644 index 0000000000..a561d6469e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinitionType.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +public enum ValueStrategyDefinitionType { + + COUNTER, NATURAL, EVENT, SEQUENCE, CONSTANT, COMPOSITE, SCHEDULE, INCREMENT, DECREMENT; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategySchedule.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategySchedule.java new file mode 100644 index 0000000000..0bb2ccf8fa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategySchedule.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.solutions.data.values; + +import lombok.Data; + +@Data +public class ValueStrategySchedule { + + private int startHour; + private int startMinute; + + private int endHour; + private int endMinute; + + private ValueStrategyDefinition definition; + +} diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 8eb2671ab1..292e61edb2 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -2174,3 +2174,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 3cd2ada38f..4983c879db 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 @@ -45,4 +45,5 @@ public class SystemParams { long minAllowedAggregationIntervalInSecForCF; long intermediateAggregationIntervalInSecForCF; TrendzSettings trendzSettings; + String iotHubBaseUrl; } 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/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..544f44cbc2 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItemDescriptor.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.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 = "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..f684607759 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/RuleChainInstalledItemDescriptor.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.RuleChainId; + +@Data +public class RuleChainInstalledItemDescriptor implements IotHubInstalledItemDescriptor { + + private RuleChainId ruleChainId; + +} 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 69a04f301b..64ab95ced6 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 @@ -763,6 +763,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/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/ui-ngx/angular.json b/ui-ngx/angular.json index 6d90f9ec20..a5e140322b 100644 --- a/ui-ngx/angular.json +++ b/ui-ngx/angular.json @@ -130,6 +130,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/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts index 623ceefadd..07a934e3bc 100644 --- a/ui-ngx/src/app/core/auth/auth.models.ts +++ b/ui-ngx/src/app/core/auth/auth.models.ts @@ -40,6 +40,7 @@ export interface SysParamsState { calculatedFieldDebugPerTenantLimitsConfiguration?: string; intermediateAggregationIntervalInSecForCF: number; trendzSettings: TrendzSettings; + 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 51e02b1ab8..63ebc320fe 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -42,7 +42,8 @@ const emptyUserAuthState: AuthPayload = { maxDebugModeDurationMinutes: 0, intermediateAggregationIntervalInSecForCF: 0, userSettings: initialUserSettings, - trendzSettings: initialTrendzSettings + trendzSettings: initialTrendzSettings, + 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..12878ac0fc --- /dev/null +++ b/ui-ngx/src/app/core/http/iot-hub-api.service.ts @@ -0,0 +1,242 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +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; +} + +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) } + ); + } + + 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..8a391383c5 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: 'store', + 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 e971c3da39..5ccb658482 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 @@ -405,68 +405,15 @@ (applyWidgetConfig)="saveWidget()"> - -
- -
-
- - -
-
- - - {{ 'widgets-bundle.widgets-bundles' | translate }} - {{ 'widget.all-widgets' | translate }} - - - - {{ 'widget.all' | translate }} - {{ 'widget.actual' | translate }} - {{ 'widget.deprecated' | translate }} - -
- - -
+ + diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss index 7b0d56b2e6..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 582e266005..6719d02eb7 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html @@ -16,14 +16,220 @@ -->
- - - - - - - - + +
+ @if (showTopLevelWidgetModeToggle()) { + + {{ 'widget.installed' | translate }} + {{ 'iot-hub.iot-hub' | translate }} + + } @else if (showBackButton()) { + + {{ getHeaderTitle() }} + } + + @if (selectWidgetMode === 'installed') { + + } + +
+
+ + @if (isFilterVisible()) { + + } + @if (selectWidgetMode === 'iotHub' && iotHubSubMode !== 'default') { + + + @for (option of iotHubSortOptions; track option.value; let i = $index) { + + } + + } +
+
+
+ @switch (selectWidgetMode) { + @case ('installed') { + @if (widgetsBundle) { + + } @else if (installedSubMode === 'allWidgets') { + + } @else { + + + + @switch (item.__logical) { + @case (LOGICAL_ALL_WIDGETS) { + +
+
{{ 'widget.all-widgets' | translate }}
+
+
+
+ {{ 'widget.all-widgets' | translate }} +
+
+ } + @default { + +
+
+ {{item.title}} +
+
+ @if (isSystem(item)) { +
sys
+ } +
i
+
+
+
+
+ {{ item.title }} +
+
+ } + } +
+ +
+ + {{ 'widgets-bundle.loading-widgets-bundles' | translate }} + + +
+
+ } + } + @case ('iotHub') { + @switch (iotHubSubMode) { + @case ('default') { + + + + @switch (item.__logical) { + @case (LOGICAL_ALL_WIDGETS) { + +
+
{{ 'widget.all-widgets' | translate }}
+
+
+
+ {{ 'widget.all-widgets' | translate }} +
+
+ } + @case (LOGICAL_INSTALLED_FROM_IOT_HUB) { + +
+
{{ 'iot-hub.installed-from-iot-hub' | translate }}
+
+
+
+ {{ 'iot-hub.installed-from-iot-hub' | translate }} +
+
+ } + @default { + +
+
{{ item.name }}
+
+
+
+ @if (getCategoryImageUrl(item); as imgUrl) { + {{ item.name }} + } @else { + {{ item.name }} + } +
+
+ } + } +
+ +
+ +
+
+ } + @case ('installed') { + + } + @default { + + } + } + } + } +
+ + [noData]="noData"> - +
{{item.name}} @@ -44,9 +250,11 @@
{{ 'widget.' + item.widgetType + '-short' | translate }}
-
i
+
i
-
widget.deprecated
+ @if (item.deprecated) { +
widget.deprecated
+ }
@@ -63,59 +271,304 @@
- - {{(selectWidgetMode === 'bundles' ? 'widgets-bundle.empty' : 'widget.no-widgets-text') | translate}} - - + + +
+
+
+
+
+
+
+ + + [dataLoading]="loadingIotHubWidgets" + [noData]="noData"> - - -
-
- {{item.title}} -
-
-
sys
-
i
-
-
-
-
- {{ item.title }} -
-
+ + - +
- {{ 'widgets-bundle.loading-widgets-bundles' | translate }} + {{ 'widget.loading-widgets' | translate }} - +
- - widgets-bundle.no-widgets-bundles-text + + + + + + + + + +
+ + {{ 'widget.loading-widgets' | translate }} + + +
- - + + +
+
+
+

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

+

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

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

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

+ arrow_forward +
+ } +
+
+ + +
+
+ {{item.name}} +
+
+ @if (item.dataDescriptor?.widgetType) { +
{{ 'widget.' + item.dataDescriptor.widgetType + '-short' | translate }}
+ } +
i
+
+ @if (getIotHubItemImage(item); as imgUrl) { + {{ item.name }} + } @else { + {{ item.name }} + } +
+
+ + + +
+
+ {{ 'iot-hub.filters' | translate }} + + @if (hasActiveFilters()) { + + } + +
+ +
+ @if (selectWidgetMode === 'installed') { + @if (widgetTypes.size > 1) { + + + + {{ 'iot-hub.type' | translate }} + @if (filterWidgetTypes?.length) { + {{ filterWidgetTypes.length }} + } + + +
+ @for (type of widgetTypesList; track type) { +
+ + {{ getWidgetTypeLabel(type) }} + +
+ } +
+
+ } + @if (hasDeprecated) { + @if (widgetTypes.size > 1) { + + } +
+ + {{ 'iot-hub.include-deprecated' | translate }} + +
+ } + } @else { + + + + {{ 'iot-hub.type' | translate }} + @if (iotHubAppliedWidgetTypes.size) { + {{ iotHubAppliedWidgetTypes.size }} + } + + + @if (iotHubWidgetTypeOptions.length >= 10) { + + } +
+ @for (opt of getFilteredIotHubItems(iotHubWidgetTypeOptions, 'widgetType'); track opt.key) { +
+ + {{ getIotHubWidgetTypeLabel(opt.key) }} + +
+ } +
+
+ @if (!hideCategoriesFilterSection) { + + + + + {{ 'iot-hub.category' | translate }} + @if (iotHubAppliedCategories.size) { + {{ iotHubAppliedCategories.size }} + } + + + @if (iotHubCategoryOptions.length >= 10) { + + } +
+ @for (opt of getFilteredIotHubItems(iotHubCategoryOptions, 'category'); track opt.key) { +
+ + {{ opt.key }} + +
+ } +
+
+ } + + + + + {{ 'iot-hub.use-case' | translate }} + @if (iotHubAppliedUseCases.size) { + {{ iotHubAppliedUseCases.size }} + } + + + @if (iotHubUseCaseOptions.length >= 10) { + + } +
+ @for (group of getGroupedIotHubFilterItems(iotHubUseCaseOptions, 'useCase'); track group.label) { + @if (group.label) { +
{{ group.label | translate }}
+ } + @for (opt of group.items; track opt.key) { +
+ + {{ opt.key }} + +
+ } + } +
+
+ } +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss index 04803e6a00..f3d589c8a8 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,221 @@ * 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-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 +348,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..8671057520 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'; @@ -26,16 +26,37 @@ import { widgetType, 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 { debounceTime, distinctUntilChanged, map, skip, switchMap } from 'rxjs/operators'; +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,408 @@ 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 itemIds = (this.installedWidgets || []).map(i => i.itemId); + if (itemIds.length === 0) { + this.installedWidgetVersions = []; + return of([]); + } + return this.iotHubApiService.getItemsPublishedVersions(itemIds, { ignoreLoading: true }).pipe( + switchMap(infos => { + if (infos.length === 0) { + return of([]); + } + const versionRequests = infos.map(info => + this.iotHubApiService.getVersionInfo(info.publishedVersionId, { ignoreLoading: true }) + ); + return forkJoin(versionRequests); + }), + 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..71a93e3ae5 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 { @@ -202,6 +202,7 @@ import { ApiKeyGeneratedDialogComponent } from '@home/components/api-key/api-key import { ApiKeysTableDialogComponent } from '@home/components/api-key/api-keys-table-dialog.component'; import { AuditLogFilterComponent } from "@home/components/audit-log/audit-log-filter.component"; import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; +import { SolutionInstallDialogComponent } from '@home/components/solution/solution-install-dialog.component'; @NgModule({ declarations: @@ -326,7 +327,6 @@ import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; DashboardStateDialogComponent, DashboardImageDialogComponent, EmbedDashboardDialogComponent, - DisplayWidgetTypesPanelComponent, TenantProfileQueuesComponent, QueueFormComponent, RepositorySettingsComponent, @@ -359,7 +359,8 @@ import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; ApiKeyGeneratedDialogComponent, AuditLogHeaderComponent, AuditLogFilterComponent, - EventsDialogComponent + EventsDialogComponent, + SolutionInstallDialogComponent, ], imports: [ CommonModule, @@ -367,6 +368,7 @@ import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; SharedHomeComponentsModule, CalculatedFieldsModule, AlarmRuleModule, + IotHubComponentsModule, WidgetConfigComponentsModule, BasicWidgetConfigModule, Lwm2mProfileComponentsModule, @@ -471,6 +473,7 @@ import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; SelectDashboardBreakpointComponent, EditWidgetComponent, DashboardWidgetSelectComponent, + IotHubComponentsModule, AddWidgetDialogComponent, MoveWidgetsDialogComponent, ManageDashboardLayoutsDialogComponent, @@ -480,7 +483,6 @@ import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; DashboardStateDialogComponent, DashboardImageDialogComponent, EmbedDashboardDialogComponent, - DisplayWidgetTypesPanelComponent, TenantProfileQueuesComponent, QueueFormComponent, RepositorySettingsComponent, @@ -508,7 +510,8 @@ import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; ResourcesLibraryComponent, ApiKeysTableComponent, ApiKeysTableDialogComponent, - EventsDialogComponent + EventsDialogComponent, + SolutionInstallDialogComponent, ], 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..4cbe30650d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/_iot-hub-common.scss @@ -0,0 +1,72 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// 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; + } +} 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..ad0ef8fd1e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html @@ -0,0 +1,366 @@ + + + + +
+ @switch (ws.type) { + + @case ('instruction') { +
+ + +
+ } + + @case ('form') { +
+ @if (ws.formGroup) { +
+ @for (field of ws.formFields; track field.key) { + @switch (field.type) { + @case ('BOOLEAN') { + + {{ field.label }} + + } + @case ('SELECT') { + + {{ field.label }} + + @for (opt of field.options; track opt.value) { + {{ opt.label }} + } + + @if (ws.formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + + } + @case ('PASSWORD') { + + {{ field.label }} + + + @if (ws.formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + @if (ws.formGroup.controls[field.key]?.hasError('pattern')) { + {{ getPatternErrorMessage(field) }} + } + + } + @case ('INTEGER') { + + {{ field.label }} + + @if (ws.formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + @if (ws.formGroup.controls[field.key]?.hasError('pattern')) { + {{ getPatternErrorMessage(field) }} + } + + } + @default { + + {{ field.label }} + + @if (ws.formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + @if (ws.formGroup.controls[field.key]?.hasError('pattern')) { + {{ getPatternErrorMessage(field) }} + } + + } + } + @if (field.helpText) { +
{{ field.helpText }}
+ } + @if (field.helpImage) { + + } + } +
+ } +
+ } + + @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 (!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) { + +
+ + @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 ('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..192d569f56 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.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. + */ + +@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%; + } +} + +: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: 14px; + line-height: 20px; + margin: 0; + } + + .tb-connectivity-button { + min-width: 100px; + } + + .tb-connectivity-button.selected { + background: #305680; + color: #fff; + } +} + +.tb-tab-content { + padding: 16px 24px; +} + +// Instruction view +.tb-device-install-instruction { + font-size: 14px; + line-height: 24px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + + ::ng-deep tb-markdown .tb-markdown-view { + padding: 16px 24px 24px; + + h1, h2, h3, h4, h5, h6 { + font-size: 20px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.76); + margin: 0; + padding: 0; + } + + > h1, > h2, > h3, > h4, > h5, > h6 { + padding: 0; + margin-top: 20px; + } + + > :first-child { + margin-top: 0; + } + + h1 { + font-size: 24px; + line-height: 32px; + padding-right: 0; + } + + p { + font-size: 14px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + 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; + } + + ul, ol { + padding-left: 21px; + padding-right: 0; + margin: 0; + font-size: 14px; + line-height: 24px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + + + h1, + h2, + h3, + h4, + h5, + h6 { + padding-top: 0; + margin-top: 20px; + } + } + + li { + padding-bottom: 0; + margin-bottom: 0; + line-height: 24px; + } + + code:not([class*=language-]) { + font-size: 14px; + } + } + + ::ng-deep { + + .tb-download-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 16px; + border: 1px solid #305680; + border-radius: 4px; + color: #305680; + font-size: 14px; + font-weight: 500; + text-decoration: none; + cursor: pointer; + + &:hover { + background: rgba(48, 86, 128, 0.08); + } + } + + .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 { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 12px 0; + } + + .tb-gallery-img { + max-height: 160px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + cursor: pointer; + transition: max-height 0.2s ease; + object-fit: contain; + + &:hover { + border-color: rgba(0, 0, 0, 0.24); + } + + &.tb-gallery-img-expanded { + max-height: none; + max-width: 100%; + } + } + } +} + +// 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..b50ad22f5c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts @@ -0,0 +1,1061 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, ViewChild } from '@angular/core'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +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 { 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 { + 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 = '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; + +@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 + 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); + } + + // Wizard + wizardSteps: WizardStep[] = []; + wizardStarted = false; + passwordVisible: Record = {}; + reviewMode = false; + + // Variable resolution state + formValues: Record = {}; + entityOutputs = new Map(); + transportVars: Record = {}; + gatewayDockerComposeContent: string | null = null; + + 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 + ) { + 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.showConnectivitySelector = false; + this.restoreInstallState(this.data.installState); + this.startWizard(); + } 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; + } + this.loading = false; + this.cdr.detectChanges(); + } + + // --- Connectivity --- + + selectConnectivity(ct: string): void { + this.selectedInstallMethod = ct; + } + + 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 = this.resolveImages(this.resolveVariables(raw)); + } else if (ws.type === 'progress' && !ws.progressDone) { + this.showCompletedEntitySteps(ws); + } + } + + confirmConnectivity(): void { + if (!this.selectedInstallMethod) { + return; + } + if (this.isSelectedPeOnly) { + this.showPeOnlyPanel = true; + this.cdr.detectChanges(); + 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(); + } + } + }); + }); + // Gallery image click-to-expand + const galleryImages = container.querySelectorAll('.tb-gallery-img'); + galleryImages.forEach(img => { + img.addEventListener('click', () => { + img.classList.toggle('tb-gallery-img-expanded'); + }); + }); + } + + resolveImagePath(path: string): string { + if (path.startsWith('data:') || path.startsWith('http')) { + return path; + } + return this.zipImages.get(path) || path; + } + + getPatternErrorMessage(field: FormFieldDefinition): string { + return field.validators?.[0]?.message || 'Invalid format'; + } + + // --- Variable Resolution --- + + resolveVariables(content: string): string { + return content.replace(/\$\{([^}]+)}/g, (_match, key) => { + if (key in this.formValues) { + return String(this.formValues[key]); + } + if (key in this.transportVars) { + return this.transportVars[key]; + } + // Special action placeholders + if (key === 'gateway.downloadButton') { + return '⬇ Download docker-compose.yml'; + } + // 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}
`; + } + // Image gallery: ${images.gallery(path1,path2,path3)} + const galleryMatch = key.match(/^images\.gallery\((.+)\)$/); + if (galleryMatch) { + const paths = galleryMatch[1].split(',').map((p: string) => p.trim()); + const images = paths + .map((p: string) => this.zipImages.get(p)) + .filter((src: string | undefined) => !!src) + .map((src: string) => ``) + .join(''); + return ``; + } + 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 '${' + key + '}'; + }); + } + + resolveImages(content: string): string { + return content.replace(/!\[([^\]]*)]\(([^)]+)\)/g, (match, alt, path) => { + if (path.startsWith('data:') || path.startsWith('http')) { + return match; + } + const dataUri = this.zipImages.get(path); + return dataUri ? `![${alt}](${dataUri})` : match; + }); + } + + // --- 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.passwordVisible = {}; + 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 = this.resolveImages(this.resolveVariables(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 { + const rawSteps = this.packageInfo.installSteps[this.selectedInstallMethod] || []; + this.wizardSteps = []; + 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') { + 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 = storedValues?.[field.key] ?? field.defaultValue ?? (field.type === FormFieldType.BOOLEAN ? false : ''); + controls[field.key] = new UntypedFormControl(initialValue, validators); + if (field.type === FormFieldType.PASSWORD) { + this.passwordVisible[field.key] = true; // Show passwords in review mode + } + } + ws.formGroup = new UntypedFormGroup(controls); + if (this.reviewMode) { + ws.formGroup.disable(); + ws.completed = true; + } + } + + 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 = this.resolveImages(this.resolveVariables(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: true})); + const creds = await this.resolveCredentials(step, result.id.id); + return { id: result.id.id, name: result.name, url: `/entities/devices/${result.id.id}`, token: creds.credentialsId }; + } + case InstallStepType.GATEWAY: { + const result = await firstValueFrom(this.deviceService.saveDevice(template, {ignoreErrors: true})); + const creds = await this.resolveCredentials(step, result.id.id); + const output = { + id: result.id.id, + name: result.name, + url: '/entities/gateways', + token: creds.credentialsId, + dockerComposeUrl: `/api/device-connectivity/gateway-launch/${result.id.id}/docker-compose/download` + }; + // Store entity output early so docker-compose template can resolve ${gateway.token} etc. + this.entityOutputs.set('gateway', output); + // Resolve custom docker-compose template if provided + if (step.dockerCompose) { + const raw = this.zipFiles.get(step.dockerCompose); + if (raw) { + this.gatewayDockerComposeContent = this.resolveVariables(raw); + } + } + return output; + } + case InstallStepType.GATEWAY_CONNECTOR: { + const gatewayOutput = this.entityOutputs.get('gateway'); + if (!gatewayOutput) { + throw new Error('GATEWAY step must precede GATEWAY_CONNECTOR'); + } + const gatewayEntityId = { entityType: 'DEVICE', id: gatewayOutput.id } as EntityId; + const connectorName = template.name; + + // Fetch current active_connectors + let activeConnectors: string[] = []; + try { + const attrs = await firstValueFrom( + this.attributeService.getEntityAttributes(gatewayEntityId, AttributeScope.SHARED_SCOPE, ['active_connectors'], {ignoreErrors: true}) + ); + const existing = attrs?.find(a => a.key === 'active_connectors'); + if (existing?.value && Array.isArray(existing.value)) { + activeConnectors = existing.value; + } + } catch (_e) { + // First connector — no existing attributes + } + + // Append connector name + if (!activeConnectors.includes(connectorName)) { + activeConnectors.push(connectorName); + } + + // Save attributes + await firstValueFrom(this.attributeService.saveEntityAttributes( + gatewayEntityId, AttributeScope.SHARED_SCOPE, + [ + { key: 'active_connectors', value: activeConnectors }, + { key: connectorName, value: template } + ], + {ignoreErrors: true} + )); + + return { id: gatewayOutput.id, name: connectorName }; + } + case InstallStepType.DASHBOARD: { + const result = await firstValueFrom(this.dashboardService.saveDashboard(template, {ignoreErrors: true})); + return { id: result.id.id, name: result.title, url: `/dashboards/${result.id.id}` }; + } + case InstallStepType.RULE_CHAIN: { + const ruleChain = template.ruleChain || template; + const metadata = template.metadata; + const existing = await this.findRuleChainByName(ruleChain.name); + if (existing) { + return existing; + } + return this.saveRuleChainWithMetadata(ruleChain, metadata); + } + default: + throw new Error(`Unsupported entity step type: ${step.type}`); + } + } + + private async saveRuleChainWithMetadata(ruleChain: any, metadata: any): Promise { + const saved = await firstValueFrom(this.ruleChainService.saveRuleChain(ruleChain, {ignoreErrors: true})); + if (metadata) { + metadata.ruleChainId = saved.id; + await firstValueFrom(this.ruleChainService.saveRuleChainMetadata(metadata, {ignoreErrors: true})); + } + return { id: saved.id.id, name: saved.name, url: `/ruleChains/${saved.id.id}` }; + } + + private async resolveCredentials(step: DeviceInstallStep, deviceId: string): Promise { + const creds = await firstValueFrom(this.deviceService.getDeviceCredentials(deviceId, false, {ignoreErrors: true})); + if (step.credentials) { + const raw = this.zipFiles.get(step.credentials); + if (raw) { + const credTemplate = JSON.parse(this.resolveVariables(raw)); + creds.credentialsType = credTemplate.credentialsType || creds.credentialsType; + if (credTemplate.credentialsValue) { + creds.credentialsValue = typeof credTemplate.credentialsValue === 'string' + ? credTemplate.credentialsValue + : JSON.stringify(credTemplate.credentialsValue); + } + if (credTemplate.credentialsId) { + creds.credentialsId = credTemplate.credentialsId; + } + await firstValueFrom(this.deviceService.saveDeviceCredentials(creds, {ignoreErrors: true})); + } + } + return creds; + } + + private async findDeviceProfileByName(name: string): Promise { + const profiles = await firstValueFrom(this.deviceProfileService.getDeviceProfileNames()); + const match = profiles.find(p => p.name === name); + return match ? { id: match.id.id, name: match.name, url: `/profiles/deviceProfiles/${match.id.id}` } : null; + } + + private async findRuleChainByName(name: string): Promise { + const page = await firstValueFrom(this.ruleChainService.getRuleChains(new PageLink(100, 0, name))); + const match = page.data.find(rc => rc.name === name); + return match ? { id: match.id.id, name: match.name, url: `/ruleChains/${match.id.id}` } : null; + } + + private async findDeviceByName(name: string): Promise { + try { + const device = await firstValueFrom(this.deviceService.findByName(name, {ignoreErrors: true})); + if (device) { + return { id: device.id.id, name: device.name, url: `/entities/devices/${device.id.id}` }; + } + } catch (_e) { + // 404 = not found + } + return null; + } + + private async findDashboardByTitle(title: string): Promise { + const page = await firstValueFrom(this.dashboardService.getTenantDashboards(new PageLink(10, 0, title), {ignoreErrors: true})); + const match = page.data.find(d => d.title === title); + return match ? { id: match.id.id, name: match.title, url: `/dashboards/${match.id.id}` } : null; + } + + private async overwriteEntity(step: DeviceInstallStep, existing: EntityStepOutput): Promise { + const raw = this.zipFiles.get(step.template); + if (!raw) throw new Error(`Template file not found: ${step.template}`); + const resolved = this.resolveVariables(raw); + const template = JSON.parse(resolved); + + switch (step.type) { + case InstallStepType.DEVICE_PROFILE: { + template.id = { id: existing.id, entityType: 'DEVICE_PROFILE' }; + const result = await firstValueFrom(this.deviceProfileService.saveDeviceProfile(template, {ignoreErrors: true})); + return { id: result.id.id, name: result.name, url: `/profiles/deviceProfiles/${result.id.id}` }; + } + case InstallStepType.DEVICE: { + template.id = { id: existing.id, entityType: 'DEVICE' }; + const result = await firstValueFrom(this.deviceService.saveDevice(template, {ignoreErrors: true})); + const creds = await this.resolveCredentials(step, result.id.id); + return { id: result.id.id, name: result.name, url: `/entities/devices/${result.id.id}`, token: creds.credentialsId }; + } + case InstallStepType.GATEWAY: { + template.id = { id: existing.id, entityType: 'DEVICE' }; + const result = await firstValueFrom(this.deviceService.saveDevice(template, {ignoreErrors: true})); + const creds = await this.resolveCredentials(step, result.id.id); + const output: EntityStepOutput = { + id: result.id.id, + name: result.name, + url: '/entities/gateways', + token: creds.credentialsId, + dockerComposeUrl: `/api/device-connectivity/gateway-launch/${result.id.id}/docker-compose/download` + }; + this.entityOutputs.set('gateway', output); + if (step.dockerCompose) { + const dcRaw = this.zipFiles.get(step.dockerCompose); + if (dcRaw) this.gatewayDockerComposeContent = this.resolveVariables(dcRaw); + } + return output; + } + case InstallStepType.DASHBOARD: { + template.id = { id: existing.id, entityType: 'DASHBOARD' }; + const result = await firstValueFrom(this.dashboardService.saveDashboard(template, {ignoreErrors: true})); + return { id: result.id.id, name: result.title, url: `/dashboards/${result.id.id}` }; + } + case InstallStepType.RULE_CHAIN: { + const ruleChain = template.ruleChain || template; + const metadata = template.metadata; + ruleChain.id = { id: existing.id, entityType: 'RULE_CHAIN' }; + return this.saveRuleChainWithMetadata(ruleChain, metadata); + } + default: + throw new Error(`Unsupported overwrite for step type: ${step.type}`); + } + } + + private async preCheckEntity(step: DeviceInstallStep): Promise { + const raw = this.zipFiles.get(step.template); + if (!raw) return null; + const resolved = this.resolveVariables(raw); + const template = JSON.parse(resolved); + + switch (step.type) { + case InstallStepType.DEVICE_PROFILE: + return this.findDeviceProfileByName(template.name); + case InstallStepType.RULE_CHAIN: { + const name = template.ruleChain?.name || template.name; + return this.findRuleChainByName(name); + } + case InstallStepType.DEVICE: + case InstallStepType.GATEWAY: + return this.findDeviceByName(template.name); + case InstallStepType.DASHBOARD: + return this.findDashboardByTitle(template.title); + default: + return null; + } + } + + private getConflictType(stepType: InstallStepType): 'use-or-overwrite' | 'overwrite-or-copy' { + return stepType === InstallStepType.DASHBOARD ? 'overwrite-or-copy' : 'use-or-overwrite'; + } + + private buildInstallState(): Record { + const state: Record = {}; + for (const ws of this.wizardSteps) { + if (ws.type === 'form' && ws.formGroup) { + state[ws.label] = { formValues: ws.formGroup.getRawValue() }; + } else if (ws.type === 'progress' && ws.entitySteps) { + for (const ep of ws.entitySteps) { + if (ep.status === 'success' && ep.entityOutput) { + state[ep.step.name] = { entityOutput: ep.entityOutput, resolution: ep.resolution || 'created' }; + } + } + } + } + return state; + } + + private collectCreatedEntityIds(): { entityType: string; id: string }[] { + const ids: { entityType: string; id: string }[] = []; + for (const ws of this.wizardSteps) { + if (ws.type === 'progress' && ws.entitySteps) { + for (const ep of ws.entitySteps) { + if (ep.status === 'success' && ep.entityOutput) { + const entityType = this.stepTypeToEntityType(ep.step.type); + if (entityType) { + ids.push({ entityType, id: ep.entityOutput.id }); + } + } + } + } + } + return ids; + } + + private findCreatedDashboardId(): { entityType: string; id: string } | undefined { + for (const ws of this.wizardSteps) { + if (ws.type === 'progress' && ws.entitySteps) { + for (const ep of ws.entitySteps) { + if (ep.step.type === InstallStepType.DASHBOARD && ep.status === 'success' && ep.entityOutput) { + return { entityType: 'DASHBOARD', id: ep.entityOutput.id }; + } + } + } + } + return undefined; + } + + private async saveStepAttributes(step: DeviceInstallStep, output: EntityStepOutput): Promise { + const entityType = this.stepTypeToEntityType(step.type); + if (!entityType) { + return; + } + const entityId = { entityType, id: output.id } as EntityId; + if (step.serverAttributes) { + const raw = this.zipFiles.get(step.serverAttributes); + if (raw) { + const resolved = JSON.parse(this.resolveVariables(raw)); + const attrs = Object.entries(resolved).map(([key, value]) => ({ key, value })); + await firstValueFrom(this.attributeService.saveEntityAttributes( + entityId, AttributeScope.SERVER_SCOPE, attrs, {ignoreErrors: true} + )); + } + } + if (step.sharedAttributes) { + const raw = this.zipFiles.get(step.sharedAttributes); + if (raw) { + const resolved = JSON.parse(this.resolveVariables(raw)); + const attrs = Object.entries(resolved).map(([key, value]) => ({ key, value })); + await firstValueFrom(this.attributeService.saveEntityAttributes( + entityId, AttributeScope.SHARED_SCOPE, attrs, {ignoreErrors: true} + )); + } + } + } + + private stepTypeToEntityType(stepType: InstallStepType): string | null { + switch (stepType) { + case InstallStepType.DEVICE_PROFILE: return 'DEVICE_PROFILE'; + case InstallStepType.DEVICE: return 'DEVICE'; + case InstallStepType.GATEWAY: return 'DEVICE'; + case InstallStepType.GATEWAY_CONNECTOR: return 'DEVICE'; + case InstallStepType.DASHBOARD: return 'DASHBOARD'; + case InstallStepType.RULE_CHAIN: return 'RULE_CHAIN'; + default: return null; + } + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/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..e83580e271 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.ts @@ -0,0 +1,118 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { DialogService } from '@core/services/dialog.service'; +import { TranslateService } from '@ngx-translate/core'; +import { EntityId } from '@shared/models/id/entity-id'; +import { IotHubActionsService } from './iot-hub-actions.service'; + +export interface IotHubAddItemDialogData { + itemType: ItemType; + itemSubType?: string; + entityId?: EntityId; +} + +export interface IotHubAddItemDialogResult { + item: MpItemVersionView; + descriptor: any; +} + +@Component({ + selector: 'tb-iot-hub-add-item-dialog', + standalone: false, + templateUrl: './iot-hub-add-item-dialog.component.html', + styleUrls: ['./iot-hub-add-item-dialog.component.scss'] +}) +export class TbIotHubAddItemDialogComponent extends DialogComponent { + + itemType: ItemType; + itemSubType: string; + isInstalling = false; + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: IotHubAddItemDialogData, + private translate: TranslateService, + private iotHubApiService: IotHubApiService, + private dialogService: DialogService, + private iotHubActions: IotHubActionsService + ) { + super(store, router, dialogRef); + this.itemType = data.itemType; + this.itemSubType = data.itemSubType; + } + + getTitle(): string { + const typeKey = itemTypeTranslations.get(this.itemType); + const typeLabel = typeKey ? this.translate.instant(typeKey) : ''; + return this.translate.instant('iot-hub.add-item-from-iot-hub', { type: typeLabel }); + } + + onAddItem(item: MpItemVersionView): void { + if (this.itemType === ItemType.DEVICE) { + this.installDeviceItem(item); + return; + } + this.isInstalling = true; + const versionId = item.id as string; + const installData = this.data.entityId ? { entityId: this.data.entityId } : undefined; + this.iotHubApiService.installItemVersion(versionId, { ignoreLoading: true }, installData).subscribe({ + next: (result) => { + this.isInstalling = false; + if (result.success) { + this.dialogRef.close({ item, descriptor: result.descriptor } as IotHubAddItemDialogResult); + } else { + const message = result.errorMessage || this.translate.instant('iot-hub.install-error', { name: item.name }); + this.dialogService.alert( + this.translate.instant('iot-hub.install-error-title'), + message + ); + } + }, + error: (err) => { + this.isInstalling = false; + const message = err?.error?.message || err?.message || this.translate.instant('iot-hub.install-error', { name: item.name }); + this.dialogService.alert( + this.translate.instant('iot-hub.install-error-title'), + message + ); + } + }); + } + + private installDeviceItem(item: MpItemVersionView): void { + this.iotHubActions.installDevice(item).subscribe(result => { + if (result === 'installed') { + this.dialogRef.close({ item, descriptor: { type: 'DEVICE' } } as IotHubAddItemDialogResult); + } + }); + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html new file mode 100644 index 0000000000..254438e2e6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html @@ -0,0 +1,490 @@ + +
+ + + + + + + + + +
+ @if (!fixedSubType && subtypeOptions.length > 0) { + + + {{ '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 + + } + + +
+ } +
+
+ +
+
+ + + +
+
+ +
+
+ + +
+ +
+
+ search + + @if (textSearch) { + + } +
+ +
+ + +
+ + {{ '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 + + } + + + } + + + + @for (option of sortOptions; track option.value; let i = $index) { + + } + +
+ + + @if (isLoading) { +
+ +
+ } + + + @if (hasError && !isLoading) { +
+
+

{{ 'iot-hub.unable-to-load' | translate }}

+

{{ 'iot-hub.unable-to-load-text' | translate }}

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

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

+

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

+ @if (hasActiveFilters()) { + + } +
+ } + + + @if (!isLoading && !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 }} + } + +
+
+ } +
+
+
+
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..4a0ad1b5cd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.scss @@ -0,0 +1,609 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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: absolute; + 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; + margin-bottom: -48px; + + .embedded & { + margin-bottom: 0; + } +} + +.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-search-container; + width: 320px; +} + +.tb-iot-hub-search-icon { + @include tb-iot-hub-search-icon; +} + +.tb-iot-hub-search-input { + @include tb-iot-hub-search-input-text; +} + +.tb-iot-hub-search-clear.mat-mdc-icon-button { + @include tb-iot-hub-search-clear; +} + +// Sort button — Design: 14px Medium, primary color, with filter_list icon +.tb-iot-hub-sort-btn { + flex-shrink: 0; + font-size: 14px; + font-weight: 500; +} + +// 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 { + 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%; + } + + .tb-iot-hub-search-field { + width: 200px; + } +} + +@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..554e158e12 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts @@ -0,0 +1,690 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { 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 } 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 { + + 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.RULE_CHAIN; + } + + items: MpItemVersionView[] = []; + totalElements = 0; + pageSize = 12; + pageIndex = 0; + isLoading = false; + hasError = false; + filterDrawerOpened = false; + + 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 }[] = [ + { type: ItemType.WIDGET, label: 'item.type-widget-plural' }, + { type: ItemType.DASHBOARD, label: 'item.type-dashboard-plural' }, + { type: ItemType.SOLUTION_TEMPLATE, label: 'item.type-solution-template-plural' }, + { type: ItemType.CALCULATED_FIELD, label: 'item.type-calculated-field-plural' }, + { type: ItemType.RULE_CHAIN, label: 'item.type-rule-chain-plural' }, + { type: ItemType.DEVICE, label: 'item.type-device-plural' } + ]; + 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.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 = []; + } + }); + } + + loadItems(): void { + this.isLoading = true; + this.hasError = false; + 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; + }, + 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..ccefb3069b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.models.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 { 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; + } + 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..44bc1ad4c8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts @@ -0,0 +1,71 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { 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 { IotHubActionsService } from './iot-hub-actions.service'; + +@NgModule({ + declarations: [ + TbIotHubItemDetailDialogComponent, + TbIotHubBrowseComponent, + TbIotHubItemCardComponent, + TbIotHubAddItemDialogComponent, + TbIotHubInstallDialogComponent, + TbIotHubUpdateDialogComponent, + TbIotHubDeleteDialogComponent, + TbIotHubUnpublishedWarningDialogComponent, + TbDeviceInstallDialogComponent, + TbIotHubSearchComponent, + TbIotHubInstalledItemsTableComponent, + TbIotHubInstalledItemsDialogComponent + ], + imports: [ + CommonModule, + SharedModule + ], + providers: [ + IotHubActionsService + ], + exports: [ + TbIotHubItemDetailDialogComponent, + TbIotHubBrowseComponent, + TbIotHubItemCardComponent, + TbIotHubAddItemDialogComponent, + TbIotHubInstallDialogComponent, + TbIotHubUpdateDialogComponent, + TbIotHubDeleteDialogComponent, + TbIotHubUnpublishedWarningDialogComponent, + TbDeviceInstallDialogComponent, + TbIotHubSearchComponent, + TbIotHubInstalledItemsTableComponent, + TbIotHubInstalledItemsDialogComponent + ] +}) +export class IotHubComponentsModule { } 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-delete-dialog.component.html similarity index 52% 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-delete-dialog.component.html index 51eebbf7ed..dfbbbf5d79 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-delete-dialog.component.html @@ -15,9 +15,11 @@ limitations under the License. --> -
- - {{ 'widget.' + type.type | translate }} - +
+

{{ '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..0bae4f8d60 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html @@ -0,0 +1,86 @@ + +
+ @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 } }}

+

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

+ + + } + @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') { + + + } + @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..bf04778bcd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.scss @@ -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. + */ + +.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..44e4d1a601 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts @@ -0,0 +1,139 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { 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/solution/solution-install-dialog.component'; + +export interface IotHubInstallDialogData { + item: MpItemVersionView; +} + +export type InstallState = 'select-entity' | '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; + + item: MpItemVersionView; + typeTranslations = itemTypeTranslations; + state: InstallState = 'confirm'; + errorMessage = ''; + entityDetailsUrl: string | null = null; + + 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: IotHubInstallDialogData, + private dialog: MatDialog, + private translate: TranslateService, + private iotHubApiService: IotHubApiService + ) { + super(store, router, dialogRef); + this.item = data.item; + } + + getTypeLabel(): string { + const key = this.typeTranslations.get(this.item.type); + return key ? this.translate.instant(key) : ''; + } + + install(): void { + if (this.item.type === ItemType.CALCULATED_FIELD) { + this.state = 'select-entity'; + return; + } + this.doInstall(); + } + + doInstall(): 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..176aac34ab --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html @@ -0,0 +1,162 @@ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ '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 }} +
+ } + + + + + +
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..817bd7dc16 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss @@ -0,0 +1,244 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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); + } +} + +.tb-installed-empty { + color: rgba(0, 0, 0, 0.54); +} + +: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..b8e24f7d70 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts @@ -0,0 +1,308 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, + HostBinding, + Input, + NgZone, + OnChanges, + OnDestroy, + OnInit, + 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; + + @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 '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 '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.entityId?.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 descriptor.entityId?.entityType as EntityType; + 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..70dc7d986e --- /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..77d363f4ad --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.scss @@ -0,0 +1,407 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-card-preview-device { + background: white; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + } +} + +.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..a84d81ef56 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts @@ -0,0 +1,149 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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.RULE_CHAIN; + } + + getPreviewUrl(): string | null { + if (!this.item.image) { + return null; + } + const url = this.item.image.split('?')[0]; + const resolved = url.endsWith('/preview') ? this.item.image : `${url}/preview`; + 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.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: { + 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..e2023d96e9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html @@ -0,0 +1,399 @@ + +
+ +
+ @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..0724cac2e3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.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"; + +// 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-device { + background: white; + border: 1px solid rgba(0, 0, 0, 0.12); + } +} + +.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 — rendered via tb-markdown, same overrides as dlg-readme +.dlg-description { + ::ng-deep tb-markdown .tb-markdown-view { + padding: 0; + + > p, > div { + padding: 0; + } + + p { + font-size: 14px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + margin: 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 — Design: px-24, pt-16, pb-24, gap-20 +.dlg-readme { + font-size: 14px; + line-height: 24px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + + > h3 { + padding-left: 24px; + } + + // Override markdown-view styles to match Design: px-24, pt-16, pb-24, gap-20 + ::ng-deep tb-markdown .tb-markdown-view { + padding: 16px 24px 24px; + + // Headings: Design:20px SemiBold, line-height 24, tracking 0.1 + // Override markdown-view's padding: 30px 32px 10px on direct headings + h1, h2, h3, h4, h5, h6 { + font-size: 20px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.76); + margin: 0; + padding: 0; + } + + // Direct child headings: override markdown-view's > h padding + > h1, > h2, > h3, > h4, > h5, > h6 { + padding: 0; + margin-top: 20px; + } + + // First heading: no top margin + > :first-child { + margin-top: 0; + } + + h1 { + font-size: 24px; + line-height: 32px; + padding-right: 0; + } + + // Paragraphs: Design:14px Regular, tracking 0.2, line-height 24 + // Override markdown-view's font-size: 16px and padding + p { + font-size: 14px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + margin: 0; + } + + > p, > div { + padding-left: 0; + padding-right: 0; + } + + p + p { + margin-top: 8px; + } + + // Heading followed by content: 8px gap + 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, no extra margin/padding + // Override markdown-view's padding-left: 62px, margin: 16px + ul, ol { + padding-left: 21px; + padding-right: 0; + margin: 0; + font-size: 14px; + line-height: 24px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + + // Override markdown-view's heading-after-list padding + + h1, + h2, + h3, + h4, + h5, + h6 { + padding-top: 0; + margin-top: 20px; + } + } + + // List items: override markdown-view's padding-bottom: .75em + li { + padding-bottom: 0; + margin-bottom: 0; + line-height: 24px; + } + + // Code blocks: keep smaller font + code:not([class*=language-]) { + font-size: 14px; + } + } +} + +// 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..4223a58554 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts @@ -0,0 +1,310 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, 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/solution/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.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.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'; + } + 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) { + 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: + return this.getCompactSubtypeLabel(); + 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': + entityId = descriptor.entityId?.id; + entityType = descriptor.entityId?.entityType as EntityType; + 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 = this.prefixResourceUrls(content || '') + ); + } + + private prefixResourceUrls(markdown: string): string { + const baseUrl = this.iotHubApiService.baseUrl; + return markdown.replace(/([("])(\/api\/resources\/[^)"]*)/g, `$1${baseUrl}$2`); + } +} 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..af3931b9a6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html @@ -0,0 +1,144 @@ + +
+ + @if (!isLoading && totalElements > 0) { + + {{ 'iot-hub.results-count' | translate:{ count: totalElements } }} + + } +
+ +
+
+ search + + @if (searchText) { + + } +
+ + + + @for (option of sortOptions; track option.value; let i = $index) { + + } + +
+ + +@if (!isLoading && totalElements > 0) { +
+ + {{ 'iot-hub.results-count' | translate:{ count: totalElements } }} + +
+
+ + @for (p of getPageNumbers(); track p) { + + } + +
+
+ {{ 'iot-hub.items-per-page' | translate }}: + + @for (size of pageSizeOptions; track size) { + {{ size }} + } + +
+
+
+} + + +@if (!isLoading && totalElements === 0 && searchText?.trim()) { +
+ + {{ 'iot-hub.results-count' | translate:{ count: 0 } }} + +
+} + + +@if (isLoading) { +
+ +
+} + + +@if (!isLoading && totalElements === 0 && searchText?.trim()) { +
+
+

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

+

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

+
+} + + +@if (!isLoading && resultGroups.length > 0) { +
+
+ @for (group of resultGroups; track group.type) { +
+ + + +
+ @for (item of group.items; track item.id) { + + + } +
+
+ } +
+} 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..14ebf0dd60 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.scss @@ -0,0 +1,301 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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; + gap: 40px; +} + +.tb-search-results-count-mobile { + display: none; + margin-left: auto; +} + +.tb-search-input-field { + @include tb-iot-hub-search-container; + width: 320px; + min-width: 0; +} + +.tb-search-input-icon { + @include tb-iot-hub-search-icon; +} + +.tb-search-input { + @include tb-iot-hub-search-input-text; +} + +.tb-search-input-clear.mat-mdc-icon-button { + @include tb-iot-hub-search-clear; +} + +.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 { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: -28px; +} + +.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; + } +} + +// Loading / Empty negative margin +.tb-search-loading, +.tb-search-empty { + margin-top: -20px; +} + +// Empty state no-data-bg + text +.tb-search-empty { + .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.76); + margin: 0 0 8px; + } + + p { + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.54); + margin: 0; + } +} + +// Divider before categories +.tb-search-divider { + background: rgba(0, 0, 0, 0.12); + height: 1px; + margin-bottom: -20px; + margin-top: -20px; +} + +// Categories / sections +.tb-search-categories { + display: flex; + flex-direction: column; + gap: 48px; + margin-top: -20px; + overflow: auto; + padding-top: 20px; +} + +// 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; + 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-input-field { + width: 100%; + flex: 1; + } + + .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..0d92d87bee --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts @@ -0,0 +1,326 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, switchMap } 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.WIDGET, ItemType.DASHBOARD, ItemType.SOLUTION_TEMPLATE, ItemType.DEVICE, + ItemType.CALCULATED_FIELD, 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(); + + resultGroups: SearchResultGroup[] = []; + totalElements = 0; + isLoading = false; + + 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 = {}; + installedDashboardCounts: Record = {}; + installedCalcFieldCounts: 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(), + switchMap(text => { + this.isLoading = true; + this.pageIndex = 0; + return this.fetchResults(text); + }) + ).subscribe(result => { + this.applyResults(result.data, result.totalElements); + }); + 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.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.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 '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.DASHBOARD: + return this.installedDashboardCounts[item.itemId] || 0; + case ItemType.CALCULATED_FIELD: + return this.installedCalcFieldCounts[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]); + } + + // Data loading + private loadResults(): void { + this.isLoading = true; + this.fetchResults(this.searchText || '').subscribe(result => { + this.applyResults(result.data, result.totalElements); + }); + } + + 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 }); + } + + 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), + dashboardCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DASHBOARD, config), + calcFieldCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.CALCULATED_FIELD, 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.installedDashboardCounts = results.dashboardCounts; + this.installedCalcFieldCounts = results.calcFieldCounts; + this.installedRuleChainCounts = results.ruleChainCounts; + }); + } + + private reloadInstalledItems(): void { + this.loadInstalledItems(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html new file mode 100644 index 0000000000..2f14b5f99a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html @@ -0,0 +1,32 @@ + +
+
+ warning +

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

+
+

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

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

@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/solution/solution-install-dialog.component.scss b/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.scss new file mode 100644 index 0000000000..d1af6f174c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.scss @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import "../../../../../scss/constants"; + +:host { + h2 { + .mat-icon { + padding-right: 8px; + box-sizing: initial; + vertical-align: sub; + } + } +} + +.tb-dialog-content { + max-width: 100%; + display: flex; + flex-direction: column; + + @media #{$mat-xs} { + min-width: 100% !important; + height: 100%; + } +} + +:host ::ng-deep { + .mat-mdc-dialog-content { + tb-markdown { + .tb-markdown-view { + .table-wrapper { + overflow-y: auto; + margin: 30px 32px; + border: 1px solid rgba(42, 125, 236, .2); + border-radius: 4px; + padding: 0; + width: calc(100% - 64px); + table { + width: 100%; + table-layout: auto; + white-space: nowrap; + margin: 0; + border: 0; + border-radius: 0; + } + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts b/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts new file mode 100644 index 0000000000..5ef74c4879 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/solution/solution-install-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 { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; + +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 = 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..1eeb13c707 --- /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..96f4e8070e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.scss @@ -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. + */ +@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: white; + 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; + } + } + + // Dot pattern used for solution template preview cards + .tb-iot-hub-widget-card-image-pattern { + background: + radial-gradient(circle, rgba(0, 0, 0, 0.06) 1px, transparent 1px) 0 0 / 15px 15px, + rgba(0, 0, 0, 0.04); + } + + // 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 3d3823d12a..1be09e0ddd 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-link.component.html +++ b/ui-ngx/src/app/modules/home/menu/menu-link.component.html @@ -15,7 +15,8 @@ limitations under the License. --> - + {{section.icon}} {{section.customTranslate ? (section.name | customTranslate) : (section.name | translate)}} diff --git a/ui-ngx/src/app/modules/home/menu/menu-link.component.scss b/ui-ngx/src/app/modules/home/menu/menu-link.component.scss index 16bc26c7e2..7720091e79 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-link.component.scss +++ b/ui-ngx/src/app/modules/home/menu/menu-link.component.scss @@ -13,6 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -:host { +@import '../../../../scss/constants'; + +:host ::ng-deep { + a.is-new::after { + content: "NEW"; + font-size: 12px; + letter-spacing: .03125em; + transform: translate(6px, -6px); + display: inline-block; + border-radius: 10px; + line-height: 22px; + height: 20px; + background: #fff; + color: $tb-primary-color; + padding-left: 6px; + padding-right: 6px; + margin-left: -6px; + } } diff --git a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html index 31753751af..ca379c6e73 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html +++ b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html @@ -15,7 +15,8 @@ limitations under the License. --> - + {{section.icon}} {{section.customTranslate ? (section.name | customTranslate) : (section.name | translate)}} = (_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.importDashboard($event) + }, + { + name: this.translate.instant('iot-hub.add-from-iot-hub'), + icon: 'store', + isEnabled: () => true, + onAction: ($event) => this.addDashboardFromIotHub($event) } ); } @@ -393,6 +402,14 @@ export class DashboardsTableConfigResolver { } } + addDashboardFromIotHub(_$event: Event) { + this.iotHubActions.addItem(ItemType.DASHBOARD).subscribe(result => { + if (result?.descriptor?.type === 'DASHBOARD' && result.descriptor.dashboardId?.id) { + this.router.navigateByUrl(`dashboards/${result.descriptor.dashboardId.id}`); + } + }); + } + importDashboard(_$event: Event) { this.importExport.importDashboard(this.editMissingAliases.bind(this)).subscribe( (dashboard) => { diff --git a/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts index 3363532b5f..f711b24d8f 100644 --- a/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts @@ -84,6 +84,8 @@ import { DeviceCheckConnectivityDialogData } from '@home/pages/device/device-check-connectivity-dialog.component'; import { EntityId } from '@shared/models/id/entity-id'; +import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; interface DevicePageQueryParams extends PageQueryParam { deviceProfileId?: string; @@ -107,7 +109,8 @@ export class DevicesTableConfigResolver { private translate: TranslateService, private datePipe: DatePipe, private router: Router, - private dialog: MatDialog) { + private dialog: MatDialog, + private iotHubActions: IotHubActionsService) { this.config.entityType = EntityType.DEVICE; this.config.entityComponent = DeviceComponent; @@ -416,6 +419,12 @@ export class DevicesTableConfigResolver { isEnabled: () => true, onAction: ($event) => this.importDevices($event) }, + { + name: this.translate.instant('iot-hub.add-from-iot-hub'), + icon: '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..3e597f61ac --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html @@ -0,0 +1,91 @@ + + 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..1da9383741 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss @@ -0,0 +1,324 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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%; +} + +// 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-search-categories { + 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-search-categories { + 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; + } + + .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-search-categories { + 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-search-categories { + 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..f0ad8f3f94 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts @@ -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 { 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; + + 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']); + } + + private loadCreator(): void { + this.iotHubApiService.getCreatorProfile(this.creatorId, { ignoreLoading: true }).subscribe({ + next: creator => this.creator = creator, + error: () => void this.router.navigate(['/iot-hub']) + }); + } + +} 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..fcf9f9bb2b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.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 { 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; +} 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..3a83f6f469 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html @@ -0,0 +1,314 @@ + +
+ + @if (installedItemsCount > 0) { + + } + + +
+ +
+ + + @for (heroType of heroTypes; track heroType.type) { + @if (heroType.icons.length) { +
+ @for (iconUrl of heroType.icons; track iconUrl; let i = $index) { +
+ +
+ } +
+ } + } + +
+

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

+
+ {{ 'iot-hub.home-subtitle-prefix' | translate }} + @for (ht of heroTypes; track ht.type; let i = $index; let last = $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 }} + +
+ } +
+ + @if (!isLoading) { + + @if (popularWidgets.length) { +
+ +
+ @for (item of popularWidgets; track item.id) { + + + } +
+
+ } + + + @if (popularDashboards.length) { +
+ +
+ @for (item of popularDashboards; track item.id) { + + + } +
+
+ } + + + @if (popularSolutionTemplates.length) { +
+ +
+ @for (item of popularSolutionTemplates; track item.id) { + + + } +
+
+ } + + + @if (popularCalcFields.length) { +
+ +
+ @for (item of popularCalcFields; track item.id) { + + + } +
+
+ } + + + @if (popularRuleChains.length) { +
+ +
+ @for (item of popularRuleChains; track item.id) { + + + } +
+
+ } + + + @if (popularDevices.length) { +
+ +
+ @for (item of popularDevices; track item.id) { + + + } +
+
+ } + } + + @if (isLoading) { +
+ +
+ } +
+ + + @if (!isLoading) { +
+
+

{{ '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..a304cbf5d3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss @@ -0,0 +1,762 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 + &::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: 0; + } + + > *:not(.tb-iot-hub-installed-btn) { + position: relative; + z-index: 1; + } +} + +// 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: 2; + 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; +} + +// Animated background gradient +.tb-iot-hub-hero-gradient { + position: absolute; + inset: 0; + transition: background 0.6s ease; + pointer-events: none; +} + +// Floating icons container per type +.tb-iot-hub-hero-icons { + position: absolute; + inset: 0; + pointer-events: none; + + // Icons start near center (translated toward center, scaled down, invisible) + .tb-iot-hub-hero-float-icon { + opacity: 0; + transform: translate(var(--start-x), var(--start-y)) scale(0.9); + transition: opacity 0.5s ease, transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); + } + + // Active: icons move outward to final position, scale to 1, fully visible + &.active .tb-iot-hub-hero-float-icon { + opacity: 1; + transform: translate(0, 0) scale(1); + } +} + +// Individual floating icon — SVG has rotation/bg/shadow baked in, CSS handles position + animation +// Base: ≥1600px — 78px top icons (top: 40px), 87px bottom icons (top: 216px, bottom edge: 303px) +.tb-iot-hub-hero-float-icon { + position: absolute; + width: 78px; + height: 78px; + + img { + width: 100%; + height: 100%; + display: block; + } + + // Top-left + &.icon-pos-1 { + top: 40px; + left: calc(50% - 404px); + --start-x: 40px; + --start-y: 12px; + } + + // Top-right — mirrors icon-pos-1 + &.icon-pos-2 { + top: 40px; + left: auto; + right: calc(50% - 404px); + --start-x: -40px; + --start-y: 12px; + } + + // Bottom-left — 87px bounding box + &.icon-pos-3 { + top: 216px; + left: calc(50% - 487px); + width: 87px; + height: 87px; + --start-x: 50px; + --start-y: -8px; + } + + // Bottom-right — 87px bounding box, mirrors icon-pos-3 + &.icon-pos-4 { + top: 216px; + left: auto; + right: calc(50% - 487px); + width: 87px; + height: 87px; + --start-x: -45px; + --start-y: -4px; + } +} + +.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, border, overflow-clip, h=220 +.tb-iot-hub-category-card { + border-radius: 8px; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + padding-top: 24px; + transition: transform 0.2s, box-shadow 0.2s; + border: 1px solid rgba(0, 0, 0, 0.12); + overflow: hidden; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + } +} + +// Title — Design: 18px Medium, centered, tracking=0.15, flex child +.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); + text-align: center; + width: 100%; + flex-shrink: 0; +} + +// Card image — Design: h=148, w=full, overflow-clip, fills remaining space +.tb-iot-hub-category-img { + width: 100%; + object-fit: contain; + flex-shrink: 0; +} + +// Exact Design gradients (104.75deg angle) +.category-widgets { + background: linear-gradient(104.75deg, rgb(240, 255, 245) 0%, rgb(147, 246, 182) 100%); +} + +.category-dashboards { + background: linear-gradient(104.75deg, rgb(245, 246, 255) 0%, rgb(189, 197, 255) 100%); +} + +.category-solutions { + background: linear-gradient(104.75deg, rgb(245, 250, 255) 0%, rgb(149, 200, 255) 100%); +} + +.category-calc-fields { + background: linear-gradient(104.75deg, rgb(245, 252, 255) 0%, rgb(149, 222, 248) 100%); +} + +.category-rule-chains { + background: linear-gradient(104.75deg, rgb(255, 252, 245) 0%, rgb(255, 238, 194) 100%); +} + +.category-devices { + background: linear-gradient(104.75deg, rgb(245, 255, 252) 0%, rgb(147, 240, 213) 100%); +} + +// 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); +} + +// 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; + } + + // Icons: ≥1280px — 69px top (top: 90px), 77px bottom (top: 246px, bottom edge: 323px) + .tb-iot-hub-hero-float-icon { + width: 69px; + height: 69px; + + &.icon-pos-1 { + top: 90px; + left: 106px; + } + + &.icon-pos-2 { + top: 90px; + left: auto; + right: 106px; + } + + &.icon-pos-3 { + top: 246px; + left: 28px; + width: 77px; + height: 77px; + } + + &.icon-pos-4 { + top: 246px; + left: auto; + right: 28px; + width: 77px; + height: 77px; + } + } +} + +// ≤959px: only top pair, area width ≤160px (icon 51px + gap + icon 51px) +@media #{$mat-lt-md} { + .tb-iot-hub-hero-float-icon { + width: 51px; + height: 51px; + + &.icon-pos-1 { + top: 80px; + left: calc(50% - 80px); + } + + &.icon-pos-2 { + top: 80px; + left: auto; + right: calc(50% - 80px); + } + + &.icon-pos-3, + &.icon-pos-4 { + display: none; + } + } +} + +// ≤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..f0e52e6b14 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts @@ -0,0 +1,522 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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; + gradientColor: string; + icons: string[]; +} + +interface SearchResultGroup { + type: ItemType; + items: MpItemVersionView[]; +} + +@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.WIDGET, labelKey: 'item.type-widget-plural', color: '#2c9755', + gradientColor: 'rgba(44, 151, 85, 0.1)', + icons: ['assets/iot-hub/hero-widget-icon-1.svg', 'assets/iot-hub/hero-widget-icon-2.svg', 'assets/iot-hub/hero-widget-icon-3.svg', 'assets/iot-hub/hero-widget-icon-4.svg'] + }, + { + type: ItemType.DASHBOARD, labelKey: 'item.type-dashboard-plural', color: '#4d5fd0', + gradientColor: 'rgba(77, 95, 208, 0.1)', + icons: ['assets/iot-hub/hero-dashboard-icon-1.svg', 'assets/iot-hub/hero-dashboard-icon-2.svg', 'assets/iot-hub/hero-dashboard-icon-3.svg', 'assets/iot-hub/hero-dashboard-icon-4.svg'] + }, + { + type: ItemType.SOLUTION_TEMPLATE, labelKey: 'item.type-solution-template-plural', color: '#2666a9', + gradientColor: 'rgba(38, 102, 169, 0.1)', + icons: ['assets/iot-hub/hero-solution-template-icon-1.svg', 'assets/iot-hub/hero-solution-template-icon-2.svg', 'assets/iot-hub/hero-solution-template-icon-3.svg', 'assets/iot-hub/hero-solution-template-icon-4.svg'] + }, + { + type: ItemType.CALCULATED_FIELD, labelKey: 'item.type-calculated-field-plural', color: '#006d92', + gradientColor: 'rgba(0, 109, 146, 0.1)', + icons: ['assets/iot-hub/hero-calculated-field-icon-1.svg', 'assets/iot-hub/hero-calculated-field-icon-2.svg', 'assets/iot-hub/hero-calculated-field-icon-3.svg', 'assets/iot-hub/hero-calculated-field-icon-4.svg'] + }, + { + type: ItemType.RULE_CHAIN, labelKey: 'item.type-rule-chain-plural', color: '#95694b', + gradientColor: 'rgba(149, 105, 75, 0.1)', + icons: ['assets/iot-hub/hero-rule-chain-icon-1.svg', 'assets/iot-hub/hero-rule-chain-icon-2.svg', 'assets/iot-hub/hero-rule-chain-icon-3.svg', 'assets/iot-hub/hero-rule-chain-icon-4.svg'] + }, + { + type: ItemType.DEVICE, labelKey: 'iot-hub.and-devices', color: '#4b8a79', + gradientColor: 'rgba(75, 138, 121, 0.1)', + icons: ['assets/iot-hub/hero-device-icon-1.svg', 'assets/iot-hub/hero-device-icon-2.svg', 'assets/iot-hub/hero-device-icon-3.svg', 'assets/iot-hub/hero-device-icon-4.svg'] + } + ]; + + activeHeroType: HeroTypeConfig = this.heroTypes[0]; + heroIconsReady = false; + private heroInterval: any; + + categoryCards: CategoryCard[] = [ + { type: ItemType.WIDGET, titleKey: 'item.type-widget-plural', icon: 'widgets', cssClass: 'category-widgets', image: 'assets/iot-hub/category-widgets-img.svg' }, + { type: ItemType.DASHBOARD, titleKey: 'item.type-dashboard-plural', icon: 'dashboard', cssClass: 'category-dashboards', image: 'assets/iot-hub/category-dashboards-img.svg' }, + { 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.CALCULATED_FIELD, titleKey: 'item.type-calculated-field-plural', icon: 'functions', cssClass: 'category-calc-fields', image: 'assets/iot-hub/category-calculated-fields-img.svg' }, + { 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.svg' }, + { type: ItemType.DEVICE, titleKey: 'iot-hub.device-library', icon: 'memory', cssClass: 'category-devices', image: 'assets/iot-hub/category-device-library-img.svg' } + ]; + + popularWidgets: MpItemVersionView[] = []; + popularDashboards: MpItemVersionView[] = []; + popularSolutionTemplates: MpItemVersionView[] = []; + popularCalcFields: MpItemVersionView[] = []; + popularRuleChains: MpItemVersionView[] = []; + popularDevices: MpItemVersionView[] = []; + + installedWidgets: IotHubInstalledItem[] = []; + installedSolutionTemplates: IotHubInstalledItem[] = []; + installedDeviceCounts: Record = {}; + installedDashboardCounts: Record = {}; + installedCalcFieldCounts: Record = {}; + installedRuleChainCounts: Record = {}; + installedItemsCount = 0; + + isLoading = true; + + 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.RULE_CHAIN; + } + + getCompactIcon(item: MpItemVersionView): string { + return item.icon || (item.type === ItemType.CALCULATED_FIELD ? 'functions' : 'settings_ethernet'); + } + + 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.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)]); + } + + 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.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.DASHBOARD) { + this.iotHubApiService.getInstalledItemCounts(ItemType.DASHBOARD, config).subscribe(counts => { + this.installedDashboardCounts = counts; + }); + } else if (type === ItemType.CALCULATED_FIELD) { + this.iotHubApiService.getInstalledItemCounts(ItemType.CALCULATED_FIELD, config).subscribe(counts => { + this.installedCalcFieldCounts = 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.DASHBOARD: + return this.installedDashboardCounts[item.itemId] || 0; + case ItemType.CALCULATED_FIELD: + return this.installedCalcFieldCounts[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.DASHBOARD && this.installedDashboardCounts[item.itemId]) { + this.installedDashboardCounts[item.itemId] = Math.max(0, this.installedDashboardCounts[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.RULE_CHAIN && this.installedRuleChainCounts[item.itemId]) { + this.installedRuleChainCounts[item.itemId] = Math.max(0, this.installedRuleChainCounts[item.itemId] - 1); + } + }); + } + + openSignup(): void { + window.open('https://iothub.thingsboard.io/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; + } + } + + private loadPopularItems(): void { + const sortOrder: SortOrder = { property: 'totalInstallCount', direction: Direction.DESC }; + const config = { ignoreLoading: 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), + dashboards: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.DASHBOARD, 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), + 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), + installedDashboardCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DASHBOARD, config), + installedCalcFieldCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.CALCULATED_FIELD, config), + installedRuleChainCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.RULE_CHAIN, config), + installedCount: this.iotHubApiService.getInstalledItemsCount(null, config) + }).subscribe({ + next: (results) => { + this.popularWidgets = results.widgets.data; + this.popularDashboards = results.dashboards.data; + this.popularSolutionTemplates = results.solutionTemplates.data; + this.popularCalcFields = results.calcFields.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.installedDashboardCounts = results.installedDashboardCounts; + this.installedCalcFieldCounts = results.installedCalcFieldCounts; + this.installedRuleChainCounts = results.installedRuleChainCounts; + this.installedItemsCount = results.installedCount; + this.isLoading = false; + }, + error: () => { + this.isLoading = false; + } + }); + } + + private groupSearchResults(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 Array.from(groupMap.entries()).map(([type, groupItems]) => ({ type, items: groupItems })); + } +} 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..c284d3d548 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html @@ -0,0 +1,88 @@ + +
+ + + + +
+

{{ '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..eb97f64682 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss @@ -0,0 +1,189 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import "../../../../../scss/constants"; + +: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; +} + +// Toolbar: filter + search +.tb-installed-toolbar { + display: flex; + align-items: center; + gap: 8px; + 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; + } +} + +// Search field +.tb-installed-search { + width: 320px; + height: 40px; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: white; +} + +.tb-installed-search-icon { + font-size: 24px; + width: 24px; + height: 24px; + color: rgba(0, 0, 0, 0.38); + flex-shrink: 0; +} + +.tb-installed-search-input { + flex: 1; + border: none; + outline: none; + font-size: 14px; + font-weight: 400; + color: rgba(0, 0, 0, 0.87); + background: transparent; + font-family: inherit; + + &::placeholder { + color: rgba(0, 0, 0, 0.38); + } +} + +// Responsive +@media #{$mat-lt-lg} { + .tb-installed-toolbar { + flex-wrap: wrap; + } + + .tb-installed-search { + order: 1; + } + + .tb-installed-filter-chips { + order: 10; + } + + .tb-installed-clear-btn { + order: 11; + } + + .tb-installed-toolbar::before { + content: ''; + width: 100%; + order: 9; + } +} + +@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; + } + + .tb-installed-search { + width: 200px; + } +} 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..116df9f91f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts @@ -0,0 +1,122 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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[] = ['WIDGET', 'DASHBOARD', 'SOLUTION_TEMPLATE', 'CALCULATED_FIELD', 'RULE_CHAIN', 'DEVICE']; + + @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 = []; + } + + 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..0239966f10 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts @@ -0,0 +1,127 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 params = this.route.snapshot.paramMap; + const itemVersionId = params.get('itemVersionId'); + const itemId = params.get('itemId'); + const byVersion = itemVersionId != null; + const id = byVersion ? itemVersionId : itemId; + + if (!isUUID(id)) { + this.failTo('iot-hub.deep-link-invalid-id'); + return; + } + + const fetch$ = byVersion + ? this.iotHubApi.getVersionInfo(id, { ignoreErrors: true }) + : this.iotHubApi.getPublishedVersion(id, { ignoreErrors: true }); + + fetch$.subscribe({ + next: v => this.handleResolved(v, byVersion), + 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, 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..d85cee280c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.scss @@ -0,0 +1,179 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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; + overflow: hidden; +} + +.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..0e8c731ce6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts @@ -0,0 +1,148 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { 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' + }, + 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('https://iothub.thingsboard.io/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 }, ''); + 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)); + } +} 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..118256edff --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts @@ -0,0 +1,167 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { 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: 'store' + } + }, + 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: '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: '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..739409c4b5 --- /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-search-categories { + 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-search-categories { + 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-search-categories { + 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-search-categories { + 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..c4152106df 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; @@ -99,6 +102,12 @@ export class WidgetTypesTableConfigResolver { icon: 'file_upload', isEnabled: () => true, onAction: ($event) => this.importWidgetType($event) + }, + { + name: this.translate.instant('iot-hub.add-from-iot-hub'), + icon: 'store', + isEnabled: () => true, + onAction: (_$event) => this.addWidgetFromIotHub() } ); @@ -183,6 +192,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/widgets-bundle-search.component.html b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html deleted file mode 100644 index 8332d0a268..0000000000 --- a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html +++ /dev/null @@ -1,29 +0,0 @@ - -
- search - - -
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/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/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..77eb99ef46 --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts @@ -0,0 +1,267 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 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: Set = 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; +} + +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[]; +} + +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..9b30377ceb --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts @@ -0,0 +1,92 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +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 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 + | 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..88f350bc3b --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts @@ -0,0 +1,55 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +export enum ItemType { + WIDGET = 'WIDGET', + DASHBOARD = 'DASHBOARD', + SOLUTION_TEMPLATE = 'SOLUTION_TEMPLATE', + CALCULATED_FIELD = 'CALCULATED_FIELD', + 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.RULE_CHAIN, 'item.type-rule-chain'], + [ItemType.DEVICE, 'item.type-device'] + ] +); + +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..94bb971323 --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts @@ -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 { 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[]; +} + +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..e38264101c 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'; @@ -401,6 +401,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) MarkdownEditorComponent, TruncateWithTooltipDirective, ContextMenuDirective, + ChipOverflowDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -422,7 +423,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) EntityGatewaySelectComponent, ContactComponent, OtaPackageAutocompleteComponent, - WidgetsBundleSearchComponent, CopyButtonComponent, TogglePasswordComponent, WidgetButtonToggleComponent, @@ -675,6 +675,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) MarkdownEditorComponent, TruncateWithTooltipDirective, ContextMenuDirective, + ChipOverflowDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -697,7 +698,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/iot-hub/category-calculated-fields-img.svg b/ui-ngx/src/assets/iot-hub/category-calculated-fields-img.svg new file mode 100644 index 0000000000..e74a12f489 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/category-calculated-fields-img.svg @@ -0,0 +1,233 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/category-dashboards-img.svg b/ui-ngx/src/assets/iot-hub/category-dashboards-img.svg new file mode 100644 index 0000000000..2db1d0fafa --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/category-dashboards-img.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/category-device-library-img.svg b/ui-ngx/src/assets/iot-hub/category-device-library-img.svg new file mode 100644 index 0000000000..57b90cde55 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/category-device-library-img.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/category-rule-chains-img.svg b/ui-ngx/src/assets/iot-hub/category-rule-chains-img.svg new file mode 100644 index 0000000000..09bd6882da --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/category-rule-chains-img.svg @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..efcd683abd 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.svg b/ui-ngx/src/assets/iot-hub/category-widgets-img.svg new file mode 100644 index 0000000000..607a9c4911 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/category-widgets-img.svg @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-calculated-field-icon-1.svg b/ui-ngx/src/assets/iot-hub/hero-calculated-field-icon-1.svg new file mode 100644 index 0000000000..7734b75c7a --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-calculated-field-icon-1.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-calculated-field-icon-2.svg b/ui-ngx/src/assets/iot-hub/hero-calculated-field-icon-2.svg new file mode 100644 index 0000000000..ac28694c40 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-calculated-field-icon-2.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-calculated-field-icon-3.svg b/ui-ngx/src/assets/iot-hub/hero-calculated-field-icon-3.svg new file mode 100644 index 0000000000..860997b4a5 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-calculated-field-icon-3.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-calculated-field-icon-4.svg b/ui-ngx/src/assets/iot-hub/hero-calculated-field-icon-4.svg new file mode 100644 index 0000000000..356d5dece0 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-calculated-field-icon-4.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-dashboard-icon-1.svg b/ui-ngx/src/assets/iot-hub/hero-dashboard-icon-1.svg new file mode 100644 index 0000000000..85f1a39345 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-dashboard-icon-1.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-dashboard-icon-2.svg b/ui-ngx/src/assets/iot-hub/hero-dashboard-icon-2.svg new file mode 100644 index 0000000000..ff17afc8fb --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-dashboard-icon-2.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-dashboard-icon-3.svg b/ui-ngx/src/assets/iot-hub/hero-dashboard-icon-3.svg new file mode 100644 index 0000000000..36ec0aca84 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-dashboard-icon-3.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-dashboard-icon-4.svg b/ui-ngx/src/assets/iot-hub/hero-dashboard-icon-4.svg new file mode 100644 index 0000000000..547826eeb1 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-dashboard-icon-4.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-device-icon-1.svg b/ui-ngx/src/assets/iot-hub/hero-device-icon-1.svg new file mode 100644 index 0000000000..8ef1b628a4 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-device-icon-1.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-device-icon-2.svg b/ui-ngx/src/assets/iot-hub/hero-device-icon-2.svg new file mode 100644 index 0000000000..57e615a72d --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-device-icon-2.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-device-icon-3.svg b/ui-ngx/src/assets/iot-hub/hero-device-icon-3.svg new file mode 100644 index 0000000000..1e7f844ba7 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-device-icon-3.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-device-icon-4.svg b/ui-ngx/src/assets/iot-hub/hero-device-icon-4.svg new file mode 100644 index 0000000000..164db57b9a --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-device-icon-4.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-rule-chain-icon-1.svg b/ui-ngx/src/assets/iot-hub/hero-rule-chain-icon-1.svg new file mode 100644 index 0000000000..53a6019a4d --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-rule-chain-icon-1.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-rule-chain-icon-2.svg b/ui-ngx/src/assets/iot-hub/hero-rule-chain-icon-2.svg new file mode 100644 index 0000000000..917f875faa --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-rule-chain-icon-2.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-rule-chain-icon-3.svg b/ui-ngx/src/assets/iot-hub/hero-rule-chain-icon-3.svg new file mode 100644 index 0000000000..c5ecbe9b74 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-rule-chain-icon-3.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-rule-chain-icon-4.svg b/ui-ngx/src/assets/iot-hub/hero-rule-chain-icon-4.svg new file mode 100644 index 0000000000..eda7544128 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-rule-chain-icon-4.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-solution-template-icon-1.svg b/ui-ngx/src/assets/iot-hub/hero-solution-template-icon-1.svg new file mode 100644 index 0000000000..f1d0ee4799 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-solution-template-icon-1.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-solution-template-icon-2.svg b/ui-ngx/src/assets/iot-hub/hero-solution-template-icon-2.svg new file mode 100644 index 0000000000..9708abc935 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-solution-template-icon-2.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-solution-template-icon-3.svg b/ui-ngx/src/assets/iot-hub/hero-solution-template-icon-3.svg new file mode 100644 index 0000000000..7151c40b16 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-solution-template-icon-3.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-solution-template-icon-4.svg b/ui-ngx/src/assets/iot-hub/hero-solution-template-icon-4.svg new file mode 100644 index 0000000000..11a06c0a4f --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-solution-template-icon-4.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-widget-icon-1.svg b/ui-ngx/src/assets/iot-hub/hero-widget-icon-1.svg new file mode 100644 index 0000000000..7058c55569 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-widget-icon-1.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-widget-icon-2.svg b/ui-ngx/src/assets/iot-hub/hero-widget-icon-2.svg new file mode 100644 index 0000000000..905ae06615 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-widget-icon-2.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-widget-icon-3.svg b/ui-ngx/src/assets/iot-hub/hero-widget-icon-3.svg new file mode 100644 index 0000000000..129999a1a7 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-widget-icon-3.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/iot-hub/hero-widget-icon-4.svg b/ui-ngx/src/assets/iot-hub/hero-widget-icon-4.svg new file mode 100644 index 0000000000..4483d53642 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/hero-widget-icon-4.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..fa88380d61 --- /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..cbe42a4707 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/items-page-devices-hero.svg @@ -0,0 +1,258 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index eb0f7cb2ad..3977bcbd93 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", @@ -4174,7 +4175,239 @@ } }, "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-rule-chain": "Rule Chain", + "type-rule-chain-plural": "Rule Chains", + "type-device": "Device", + "type-device-plural": "Devices" + }, + "iot-hub": { + "iot-hub": "IoT Hub", + "home-title": "ThingsBoard IoT Hub", + "home-subtitle-prefix": "Discover ready-to-use", + "and-devices": "& Devices", + "search-placeholder": "Search in IoT Hub...", + "search": "Search...", + "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", + "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-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", + "popular-widgets": "Popular Widgets", + "popular-dashboards": "Popular Dashboards", + "popular-solution-templates": "Popular Solution Templates", + "popular-calculated-fields": "Popular Calculated Fields", + "popular-rule-chains": "Popular Rule Chains", + "popular-devices": "Popular Devices", + "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-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-rule-chains": "Browse rule chain templates", + "subtitle-devices": "Browse device configurations", + "search-widgets": "Search widgets...", + "search-dashboards": "Search dashboards...", + "search-solution-templates": "Search solution templates...", + "search-calculated-fields": "Search calculated fields...", + "search-rule-chains": "Search rule chains...", + "search-devices": "Search devices...", + "sort-most-installed": "Most installed", + "sort-newest": "Newest", + "sort-name": "Name (A-Z)", + "type": "Type", + "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.", + "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}}'", + "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", + "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": "Select your connection type:", + "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", + "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!", @@ -7306,6 +7539,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", @@ -7336,6 +7570,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", @@ -9991,6 +10230,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/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%;