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 new file mode 100644 index 0000000000..bfee44b341 --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json @@ -0,0 +1,50 @@ +{ + "widgetsBundle": { + "alias": "home_page_widgets", + "title": "Home page widgets", + "image": "data:image/png;base64,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", + "description": "Useful to define home dashboard of the user. Contains widgets that enable navigation to necessary links.", + "externalId": null, + "name": "Home page widgets" + }, + "widgetTypes": [ + { + "alias": "documentation_links", + "name": "Documentation links", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAZQAAAFECAMAAAA3JTi9AAAA9lBMVEUAAAAAAAAAAAAAAADo6OhtbW34+Pj7+/vt7e26urr////9/f31+fn7+/v39/f5+fny8vIwVoAhISF1dXX19fXj4+Oenp7z9PRYWFg8PDx0dHT5+/uQkJDW3+Xg4OC6urrIyMisrKzE0NupqamGhobu7u7Hx8eCgoLLy8vc5eovLy+Sp7wuLi7V1dWOjo70+PhJao+ru8uru8y5ublmZmbo7/Hc3NyXl5d5k619fX3m5uaYmJjd3d3Ozs6rvMvW1tZKSkrDw8Pu8/SxsbFhfp5LS0vr6+vh6e2ysrKhoaHQ2uK3xtOGnbQ8YIeescRtiKXh4eFuiKUhOgT+AAAACnRSTlMfAAwYnTHV46tXmfAXZwAAIUdJREFUeNrs1dENwCAMA9EjAdpm/4ErYAj84bfCyTJti0zsssxoGzsJpuFk4TT5+lN22XwHxIkSjF4moY9VhdVklomYqwot8U6EdGgEo0zIR5C8ZUI6SeJHkfKQQJkUcBQ5jiLIUQQ5iiBHEeQoghxFkKMIchRBP3v1z9o4DMZxfP+VcwOPSLdokARa4iUgIzkZhKEk273/V3OPJd8lJlHtoUeC6y8U1OjPoA+JVpQXbEV5wVaUF2xFeU4hoNSK8qRO2+0JhVaU58QmZZUV5Sm9q+0XKk9GMfiRGWyyygceV0Y5t9zF4v9lqcYP7LR1WUVt8LgySkOp2uAb6nQcH64DsCeNid4/r71hEfF7klSKJl+jSCBoavENKZK4TdMRgMBU1e7aLywhNskqh5LJJAqET9dXSaVsvqaYR3vpAEQZ4KQ78mdimOGsUtEARkbedzYQsqZGGuB4VmcL3uFJRQRp04F5NawM/Tm47a3KfS4FhU0GlWLTKOhIAcETpwVg9DBSaVqThaT0WVsTF4EqDbSBI9/PeGOIY9wD9UsVZFoAS+2/AwPQUNqocJ/Z7X4vAoVNcuodpaZR8musqROmphZo8kiOURxsf7GiI90z1oJHLaPkxR3yarYxMJ5C/vnKKIoaIQ49c9OfE4nEI5OPsASUq8kGxWah6OFFNkTCkAfg9GWEovIrkZYA6dYFL3ZpMU8PKMIFADXZGxTBUMM5DUUA/P8jEywBZZ7JzG+KJDW8zntqkSqjBCLFeTKO9AgFVby0rR+hpDV5viGbUR6ZLAFlpsmsN+UCSd2AYqdRHJFO3aNoajqpp1HuTZaAMtdkEiVfe2QYzlMI+Q6NKaPwn0BqjDI8T2hGKIE8uAvFAkoyWQLKbJNJlEqRBhx5A0QeCv/3NmUPVfl7FKSXHdbcoeQteoQCPiHtMyOUsckSUP6wZ8cqDMJAGID3lELhLB07eAEXXQJKkAxdir7/C/VOKbYapLeEVO6ffjyT5UORKDDZRcG+8YB2eofhMABUJMPNU2sBygFhi8KT8T5C/4niAEu7LOHP33JGqfmiJ8cFZW1yBBSByT4KBV1hOM4D+MfUkFrFhcZdE0ExNfLo60kpEGjOu3QjA9TUZhRT8YbORFFO17eJufz1MYvERHBKbO26FZbBtllG2/W2iNxhucdzvpkjpJWY6P+UNAkSE0VJkyAxUZQ0CRITRUmTIDFRlDR5tpRfTRQlyyhKhlGUDKMoGUZRXuzUUW7DQAgE0P/M/e9bqZnoabHSbSS7yUdxVccsDAyD/YH2L8oH2laUzB+b4BMsm5PLLW/l/7Mo2c0nt+i5//Nqf9kfZXP+ikWP+7J+XsH/dVFYMttVPkNDPlELxQylp2MC8qk5gzmc74cT4EeYP+T/e1Ey20+KqH7MrUvTPp00rt0kB2iRazE0ymPkiSQBqDHFuhRKkltE5/7XlBRW+GX8WXai2JDozA1Za2Ry7aA80TCgB2ii/UeO5zpwrx2+HI0psr5jYvc82qwqw6QF/vJO5X/DH+gUZfUirTTOrfl9M73m4D9EaILQdbetbCyBw0CxkyHE0rQyTUUBiN70Y4SX8yeEUlOUJ3oXJ15qxNeFbFhd2u5JoltbEsfmMYD67CxqgYQftONzFYGo6MTNwzX8bc1DGP4hCiMiCDtpBaMT+iNh+fVus5A0La2DlgkYcW4QkLTnzEWK8ZnSUj1n84/LW8+GKF4oiK2GP6cKCnKxMe1l0tThHsFHYBEoywHE4YzRVH0HouGdwd+hF8k3+KkoFXAyQdFtsTFfl9zJXQpTFo4sjzQdgWwoNGH1hZIQiewM/g59zkiSIQoLwc83Xb3dvtg1m1WHQSAK7x2PZiHMwvd/z3sbD5wMdpFQpBT8SskPjk34OIkDTfZNFNPQCs1STAvQzXKUd0aEbkxSrlHi+I/BzVGwpYDf59e3/v6vj9E5KbGJe/sD0K4AxmZ8AJ4BjExH4IYyoPqoSVPzAKOYqFgzBweaMJZxJlbw8uJ0WHv/s5KxP0kZJymNw2pzPzZLcG9VWhK9JIuPL5pTSMqrruJFvwE2T6i1HX5qCctEJUWdv7SgeRlD0PM9+i//BfsLWHGHKQzUEJIyPgTeuCLo+T7byjNsWAm9V0qSEvsqeGNZznlbWUc7rciJpJy7ISjuLOp5W1lKcxPXd8rUmBQ3BeURPW0eYV70iEoW3inGcwxKeRuUHZUFlCP2LRY7egKrh7EibymrMa8hKv9QSlh7obW0pfyxc3Y7asNAFL734saM1sGOUpH0IhEs7CJBbguKipD69/6P03GcMCHABloXVamPdr1jr8UFn86MsR0epudnW+uJTF1TWlcGOEd2HsrjFExaRFC0+iIhFQ/lkQoEP7FKA6XqEBXBb4DyqfBQnIgLfqRyUlPIPBx1C5Tv4y8einMoFkwFxQSoe5wyG4/H3zwUR1Ce2lapoVDuutkpK4SyKjwUV06xUOqMdXQK64Pys22Mz2OjFw/FWfriT4SFakoPlNn4x4j0pYKy8lAcQUFdcAoxuQplXKD+FSgyEWwgIignS2IMboHyEVXeCyXJo00smWvFkLCB6HT1RemrocLfhXJA3euUHBa7HUB8OppSnxSn7JKyWPwHUDjvQqE+fw/Kb9SUV4gEY1J33kIF7FygrnhCDh1KcITSvjhB25FuoYiFZkYZRLafSILSLQ+gmv6W1drKFpRtU0bEdjssKJYJfVSh8xTySuAMyrTJW3GIjVAAsJcsApRuMtkcwNhJAwrRLc0kbTjARoFKwaieN69eLcRIR0ODwjntSdr0ReJOocSwZiS9eM1S9M4y20O2rG0Bu3WWg2JZBvtMMjnX6yVOMlBgF6YyiyDNkAmodbaB1ES7NQ4OCUpgoXSd0r4/GPRD+Ty2eumFklAnhRTbEKZN+qIZG92kr7UytHLAFrSgmqJ1lQ2ViUx/UE4JiAlt3XeM0ltTkAlR6YeiUBtDQlTWyNtQ1pDLbqGXMocE+xEV+szmwQjEEqLBFXrepsLOV189NYWYEJXe9IVMTD7ag1VEUCpTgMolQUmUKTJdKCnUkohncFBOnNJafd0DZdzW7D0oKYQ20Bp/53ElSl+VZLhZQGyh2BpzwSkpbOJKYohQOCcql/a+uFMoAhRBUXNx/jlFStPsQNRQcjAD8ZX0ZdPf4KBYpxCWSzWlH8qPlV19rXqgMFW/mSZ9vdo4aUFpSG1A1lAiWJq2C0Us5qJGqOdicIU+IKsQFEZIbnHKj6+z70afZqseKFJDNE1CBTljQkOeTKtlbQThtElw+2kSzzWGej7NsK/TVAGsj1BCfAWBrcbXMfNSjKbDWhJfTF8muGP1tfo6GhW4OXnA0VXxLhQmowUA6NDGGC7WGC01fXjUOKhk9e4bDK+mG0PcQDEsQdbzNoLhDIzU8NIXPyv0RyGT3vSFerNQbtq6l1I2oTiGUtD/m1hUAc2hCc08GhiQyCmd9HVjTSEmBwPFn9E7PQ6+uvfVU1PIJqhZ8eahuHdKF0pvoS/x7LFElzQqccBDcbzN8tSpKQTl8nGw8cfHjmYeitMNSSr0tzjl4xUdCg/F6S6xAyiowkNxfhzMes9TUC8vNnUdGn9UjYfy16Aw1ltTUGU5mhTl6K0s30pceE3wrxnAcZ++3EGh1ZcJaZfYPwpxh9zXFOuU0517/3zKYxVMOIruTdAucfsyS+Afr3uknp+Dzg3vizckP0z8g6gP05P4wI2OSDr3vpqaEvzBI9vM6z59mARtp+DP+W0Wm7/8lxv8Yu9cdtuGgSi6F0uNWzAg+gAadFGgQIEAXXWXffv//1PzIR5yHNtyQ3MR8MaWaL7EmasRLUaeGQXjnVT/dmxdS7ULLd795/XLDAjeYK5mkLu/pNPIb+3Fe7ERsFI/4L0Yrl9r5TDn6QZO7MUYEtqZ/5W0Yf9KLZj9tUiSvhn75Xd+Vc+y1MssrBNnVnzs5NoF7On4gjbDUY3aaIUbxpqS5e8U5pqNIPQLTuV1lgbNGNseHumbUeyTn1znBUPRlqJoEZH1UJywKfWnVEqk3TH/iHefT8NuMG5YUEhFlBpzMa4FGijVa4fWLRc6SoBi2HAsdAuNdIQYlFbd4tX9JvnN6oOdCJzkis0d/QIrNtBycJlG2YHpr/AmiLigX4ESZvrtjr5ZaMmsTL+ed4VfJcAKrCz1z+u2GAiwIpGVVeJmDXBpw/aYD5xIznTsYoGjYdoDYb81d2xd7om2bUvyOKDE+qmpqu2k9CW0UcIACvrLn8SFEmMNYEGyjmgAKZEQkSwsIxEnaU+BArmOLJ124qgHAWlLER2e1R354HxjcVQX8lE4zCFxV/kl8WITJ7Cy1F6M8MjP5ctKJMYGRsv5IhJfdC7lNE9v6sYPJGspy6hq5OM4oU7MpOdSbxcLQh2Rtieab0nVJTnpwH3lx0r0LUraVKToxySZ4FGD2jHqRnxKSEirLxRBQ4CgWTRUSeO2T9o5Wia42OQckY6eNC11I9dV/iBS4sTUlnLq2FO5nUiWkjqA7O1UQHAoC4WcE2xyM8autSABCACEhFjMK9NEH3ygBX+CjcBOxTxiMQzVdT/58yHsWlmK9uuJw5zylFFlK5Kgz1GUw3ARsUxE0o6XSxQ8CF1wTSO7VlWuj0YUx/SHfigJYOhUr4+KUKok5/aRPyY31doIAxZWiYnJhDP93CztIGTjB0ktmk0l1Mn1MHKhIAFbKdl25VtfMJCcKLqgKxGyrBSeOInJYtBQJnVvqA8aY7+pqLv8MAIr23qICtWRwGzfEsMsll85f2O/aI88xlUacPJjJegXhRX5aEGHtCzTT0UsnzIwqcbO0BKsM4vGzKz27vLb+LYS5xMd+wjHngGwEjDvHu8I791qbT2psPwHKSeWMqNC3AlEhViDpYDWixGxnzAVP6NC3A0sSIqFlnLBwgVuu/41o0KMgPGemxQdqoMl6YgZFWIYnBcMRQcgaKNx2RkVYhScS1+K6+C9L/m6n1EhBsL41VplLIWUZpF4RoUYh/VgWCRW9yn1Hf06o0KMg/FSwh8uhrWvbDcF87HVkXBO3aow0cPJjAoxFuKN+k7MnMJUP5+6HwqJ3lZZkMRSmh9zHewkZRB0VIilCf+Ea89JylhAyhI32uPEJGUkIMWcfWx1uUbKj18b/h5j2pQPfyYpHSzlzGOrAfYSKd/eB2Q3q7/xWDhJ6TWn6N+nZGImKQMBKRhKvUoMJUNJeX4MW5x77232Zpx9RVIkkGL1nAInfS3l8CXgggbxW/g9eMy7juKS8u1Au5aClNpUpBspDx8iPn6dpFwlRf8UwjDR286kfEoeCR8nKfssRd+nwEpPUuLuZ/KQfiCwQ0hFUo7J50wK4YOOqYeFmiUZUqFSJuX5TYQb+sfeufU6CURR+J2mUieFQDWp+FBibb3EmvikPuiLRo3//8+4Z2bTzxmKFaUoyvKcMncZ1tmzuXUvH8F70RVW3bmbn7SUDw8bPLpKSvLE2AXs/t6ovE1uU7UjxSbLo4s8XDTVmU0VWdDyvqn3pvJVdVk2VZvp0/IdKcErRjaJn/8ZSwlwlZSjPa61qY+NsMPphaTuW1LKfJebwpJi6he70uTSq5TCygkNPJE+R9fyvhBQHZOTqXcvXKzWo1eHqJOpw8dm+f5J1wWfkv4UKY9+0lLYFrWL6X1qIkK/LSwpmbOXldO9URWcjS2UglqSp0RweCukWGZoJQVukNP7ZOroCFf43TOuWzh6tqssM4WL5b3C0XsWxKdo0nEgKPc6iFiOxlF3n+roc1P/G5FwwyhGLF+avRkpR7td3S+NoJDURjZ51ibF/f1bY/Klwlzu+igprqUnRfUk/gVesBSFkvJzZ18vHzb40JOUrcntAX1y9JYiLFmvXl0k5cFKO52ElFpcCJYSkOL0JIwMPHU0se7jr0JcjbYaoxcp3nVo2hQavjs77GNSlAldvt6XrrUAUnKWL68nUe4nf/pFZLzw3tc1UrAURT9SRFhQz8Dks/AaEY4QSFEXnllCanfkt1KwMoUrbEhxrbSgVD2Jf4AULKUzhmQ6pE95f/9+XZjCHrnS1JVcqrx1Qg8PHtRmE5IihVVpKndKXFkFiMzWb6QPPiUpfCvn6E+iJyGsTR2dwaJvdkUvOBSVz5SSzt0f+BOb3KxCUnJXH148SkrKxByUFHeOYOqiTPQcoJi+p9eLxw71uqHvfQH0H4IkQBaiO0UBg0x+7QpJURCukAv6dH7INRYgpTOsunIykzIqusU3YWV+HDwyIEXBDUnFbCmjo62Iel1SELx83sC+YnTOzK8YDXCdgqcnuEF49jW/jDceuu8SYykzKSMDS2mfEs+k/BnwkAtaWL5gJZ2/CjEiOPuKwhUmLjuTMjZQhehavhSzKsTYWK+XKZYS3rpPZlWI8YEqBHaCqE3g6WdViBGAKoQ1FJ7Qh8uXYlaFGBULDXbffp4SX6j8nipEG4secg1D4m9ZSxdXVSFghGf0gadP5acrYM7TS0mwvBarPy4iRe76jOzOd1X1JYSmi19WjgB9578WToJ38cLIeME3VCJViDYBqjwQ1bkh0Q0Ai7YOAM38JsjoF2W7lBvoQiNJQxU9qAALKulPp6BVB/v0Dep6zT9QhbgoQBCuXwIXhG3RqELo4QdWCSJS6rjH8Yl3lPIFM2Xe+sFh0gpN6z8mxrDYDBUcde1D/jwQY2EgBELTGpq1/hOGkQRte81/cWclnHA536FeFyiorGdViNuAAJ3rcyDc710KV/RYCqHu1+s5rufNYLVTBERWRy6FcIWYioIIx1ZnIZBF0MRaU6mmfCuy2puGmqWeNr6YiMLab30eOUWhoKlGECAYh73VDaAxVVENQ9Fu0PlrsQcCBMTwvhwGhCWLKL3teayZHmoBDYvMiRQjkA0oicUnmqmvW//Lmv/gcuz61KW0SEdAVMP+aBk8W7h+vudaSGIyg8yfMuITL2OPEgbMiWS5GloujMiRC7lnSyUF7CWp8AOZhVY74pVTGG8IBk62rSmgnFCJvAI19AcDzj/Vlcv+2uMdGAoveCdtU3Fd7IDyEawr0dEiUDnhxAH7GO9/vO9s15fmkTYrGJYVcktlVEENJRcZjXaI9kPOnzDfeJQmjDcxJFFE5e0vLAVrIRS8/qTxNOPp04A46sF6GEf3R5+Abr6G2ShahYxPqbbSdOfBIpy/gE6Y1rDzB0trKIGmTSRqoyWE8EYVQkZbsjToL2YexH2njElpTjPNhj3VXlTDCxNmoto5XvR0QBwhy5k1d0ZBHSJW2kjZaCP2coj5pxEnyzT0KIFUh6Wk09VraH3GZm8CTRDfghLdaJLTDQpZFfRHEGsM6DBL1QHgoHG0U7hcQp62xJxCr7VMWXBYgNAQiU1xwPlLCfoDeJT4yaMFpKjvSTGWZnSGxtUwWcRGlvYDySTqw2a6iYfHiFLaMDcYRGMG21tqqSKNFg6gPbEQKGE/MYwh569oc6K2wnVKECzaL1/LO7MqxK3gLufVAsLlK4yMF3j6xawKcUOgCqFunpt07WDRfJ0+nVUhbgdUIYQTWJEPfIrNBzclZ1WIGwNVCL8scYuF65TW6desCnFzoAqBT4k0ucIbxbMqxGhY2xUMBDckQ/nNWRXi9uDFiVBRkItHC1iZVSFGhBiA+pTW8hVoEKSzKsQIQBViiaEEF4/BA5X5tdUxsV7jVYLrFFQGBbMqxJhAFQJH335DciZlXKScf3m7CMKqz6oQ44MI3rGlBLeIZwGCkQApcBI4ev03W8r4wFLaYdV5ltJNClIQ4ONMynCkJNENSeykm5THdy/g2cuZlAFIgRYsBUP5gX7K3Yv4PJMyYATv8zN6HnBdjUsM/nAYkFWVbzObeHBMpg1HCgJQBPYMTGUoUq6rQuS/HIA7K43AsjL52Hhdjt4lf5qUd68EX54NoApxMhDWD4V5kR2r5J8hRYBPUUffSxXitc9++X1ViOyXlh6o+PdICb4K0aAHKR96qkJ0yziIMASpTiUi//mGgSElUCrKJqYV4UnhLUli3fcj5eW7d3J9Aik/pwqxVVWIYr+yOVNp7Odaissq8boRrHWVqUrJ3neL1RPrQLLCeAmJjREUtruSQijQanJaEWEUo4Tv0fe0lEfOXPqEVa8tC4XVgsiTvAnG7X5dlO7dWynzqXKfNaSYJzu/9BXmUOerVXmwBftjcnxhit3xO1LKg+hIHCSVWa2IJ5Na0iClOzKeYEhS2JZuDTuUIuqwsYmTI8bXSVmhqZ2plRTVgVDxCGc6bqxCB4QUX/PEPNBIuacpKRE5UvAoDSkug6HchhSxFpUVcOvX1rzwR1Ujpe+2Z8mHt+fly27en6OvF5JSYY+QFK3JhMUX09OKUFKgpRXrfmhLQRViaxRZspOjvyn1qNbyK3A5jxJSPItKSlkkKhwRk1Jqz41zUO+nxUtXWPVQ1GZIn4IqRGVO9x1Wbv0qNxdIqV2DPCDlBClvlZQHLVL2fuit14o4iMlMB12BPTGUgR09qhB++VLU++3Z2efmhWsq9EgZ6Fy+TvHy5dp4qFbE2ynJEuh1CrTgU/pZyrt3D3uQ8sRtDvtVc9R25n0ZykGsysL5dME2w9HbIU5KigyTX3b0WpMIp4VqRUxoAWtI6VaFGPDiMVaFyI0Xe3ArkTcbPSXePNi+935fUvfNefkyuVWAyJQUIc4VHLKIFFdTP9gWZieOvhStiP3Ezr7CmDlYSm9SXn7tqQqR2GtB4cf/0WeOFC4ed6744IUhlJS6lGyFRme2sQMck4gUX6NjVJPTimhF8E4uLV/Xb0hafOp96x4ZB4AnIIVP0SxYBQVRzUS1IhAgCGOzAEvKX/I8xZLyP0AtBVO5rJ8ykzIO8ClXSUl7Pg5+fiNSvrF3N0lSwzAUgPcxJizYcA6KU3D/+wCxnA/bhBRT7lQBfvSk/SNZkl8rSWcG+/Onz9v/gO6BpGuKXzuuP5x4FDIFK7Y+v76mgK0g4OuX9SdGU5dV95R47QoxAZO+PLr7Wn+2OgFTnhLLlLUrBDwL+9FbVh0pLvVrA4JH4fQ1LoEbrKxdIR7FuCvEto0PJNeuEE/j40d7crULe/6oYWXtCvEA7ArRPiQeH0gWVtauEM/hfSzijYR+WfW8doV4FHaFwIlMaRclvtgVYp29ZsKuEAdwYhE2tGDlz1Mlbwt/hI8fypKrLihOX8Oq6t/Xxk8/s7Ly5BVIH8vJq2Fl80DSf4YIWizC9i6wFmGbBYuw7TtSup2GjmLgXL/7/doV4iWwK8T3+a0nr+wpS79NLVrWrhAvg10hrIPbbUAwLqt+JoeFrRemwtrTuSI4aU9fbr9yBVbusb/Zu7eK7Y6gquJ9gvdzRrA4caVEprSrrUJOSGlXGd+toL93a/PzrBiM/tJlAKsm0xlWtDeyBZjPQfRZyJuHgTClU0f1aze4gwiMydEZ8VencdIQ0q3gnRCT5QpwwytQGr207Njk6356d6qETO2P4ilinKZPFxEdCIkeA5uz1oyjdsW3xC821QqswCZTCk2WK7TcPQB/VBVuIBqgK0hQVgIwnxomYHb8COl2GbIRnEyJLytdruR9YT4kSb8rRLMMiG+PLisNMznYdahdmIvOfO55QCtAXU1lhC6wt0snNPgRnnhl3Z0kGBumx5/zmCg1JWRKtLisuDf22rNC2KyNO9HilpqjIUKKfO0+lRrJmIQ4Ut1/FmkVQ4Hr0RSmQzwOjDKiYUb8hoSgxK/nf7VVxzZ8s3eGP7PGDQoDbvYOh9LhwtnGI7vB7GbcrVKx2OjuktXNUbQUuZ2RzJlqRJsB6WqrbIVq38bp2hbucIaRyndrBBE13L0QIkeCAaQcVbQU7IV3zpQqwrX5BAdMJ1R2tJrzQbfEBmGXEYrG09i7LRAD9qNJglaMjdbD0WxvRn6AgZFScqRdlzhZmBgtdXMP1xcl0PEi9OYd05sti0lBj0pYmRx/mdiUfI/HzAGZ0lHi2mKYnI93Y+YcTRistSIczdGVqsI5nIBSLZOubcUFZuub0VvGqm2mjIbPlMNdqsYuChiiexl/eb+Ln0i7cFGz4bNv9G6K2/uw+GkIMWTrY+LdABKkMwt+cKseoo1+AVkjVIK5R8VQrY+mEdvHq52Ht8ePRZApduoYvqeUVpLAkcagVNQ1CimMEwmaGtlRbJS56L8zIIJLcGNK/IAShKThMUupl2SJt4VXAiXlAEiRPyE9zewt/ukPwHbXHf8kzEjK0ZmJ9wM8MMVvscnhuXhZ/BzOZ54c76nNlPMt56NAL4rqihqYukaj0JugTPyiSIdf+oANrfrG3j7WF8a/1buqfFSgkgKHsEQJ654CXFsjxlENiKDQz4eyZjFcm2dLgUXdERGHYPRZv8LU+HmRzX26IqVyhw0j/8y00M1aN5+mSANt45Kx36TWUu11ObExEp0unxjZjMHVCNiMUY4yn6fET5WlHJ4czUjpQKqoC5c9brTsxalRuEW+IALXYVJD1WCbIQWAn2Kl8wSFVb1aF4FZOWRw3p4QkWCYufGfQtk91kCKi00mEyMWs84BwvRT3sxvHA1TSuVldmIUExg/aOHDGSqTOPXSHMWe2zMFZUGoqIifzRnx15zwyhj5babQ3iol9NjatDElSblrungNOk0+xVBXoC8Rfm6KdpPISn/SpFWlmozR9ML4XVNuSUG9tGQYa3wD7oxz6GPVCYPs19Imgh7XEtWLoRErrkHAfAszaJ8SP56oZOWRFNQZ6g/gBNVBkDfw2TWSmnITNumm70qGAPgcOFM9F3/K6L3LlGur1yb03CP90RAa3uyB5nvFR+P37XEghXDKf+Vfan9j5w5yIASBIIruuyK4Ycv9jzkJLiY6JjgbUtXWu4H5aTQGKK3V+AfJU2I71779+kKIRum9hKL5pCAgmUQ4SiCmyxfLWL8oyt2LnnOtfU+UqyNKCo5CyFGY1DLsve9lUG+TIUrrF0q3Ev9yFEoZopTr8lVDW4Yog1/0lByFkKMQchRCyaP41/1K2OIkVZTammSUyBxFVepJkeUofDwpjByFjyeFkaPw8aQQehgFvvN5oe1RFDjKQk/OPAZkd+OpwnyDdwCqG1c1fQ8AHTwpBEaTSZRAyO7x/rRzbysMwkAQhicnDxHf/3W7gcqWEoqUghM7X++2BYs/0ZvoiDoXJo/iBt7kPZ7eqUbTzVeH3ek9En9e0/WjHI+p+2ud/BvnPz89cz+YmS9mPAf2l62cieJ3+/3p9S/uB5/t7zPjs+3jbOvO/uLAW3+l3GrjxH0oCiFFIaQohBSFkKIQUhRCikJIUQgBSJirEJmQ7JOrEFmQELFWIVIQEaClwiQjBYSIMlUhMRVEi9KqaK2QyK2JRWlVUBatlsvNeYU1aVFMTBAOMRxRWhZ1uVxKlqR5AIswd8iFiqq+AAAAAElFTkSuQmCC", + "description": "Display quick links to the platform or documentation.", + "descriptor": { + "type": "static", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "\n\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-doc-links-widget-settings", + "defaultConfig": "{\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"columns\":3},\"title\":\"Documentation links\",\"dropShadow\":true}" + } + }, + { + "alias": "getting_started", + "name": "Getting started", + "image": "data:image/png;base64,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", + "description": "Gettings started instruction.", + "descriptor": { + "type": "static", + "sizeX": 7.5, + "sizeY": 6.5, + "resources": [], + "templateHtml": "\n\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "", + "defaultConfig": "{\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Getting started\",\"dropShadow\":true}" + } + } + ] +} diff --git a/application/src/main/data/upgrade/3.4.4/schema_update.sql b/application/src/main/data/upgrade/3.4.4/schema_update.sql index 5965837898..995b43967f 100644 --- a/application/src/main/data/upgrade/3.4.4/schema_update.sql +++ b/application/src/main/data/upgrade/3.4.4/schema_update.sql @@ -47,11 +47,15 @@ UPDATE alarm SET acknowledged = true, cleared = false WHERE status = 'ACTIVE_ACK UPDATE alarm SET acknowledged = false, cleared = true WHERE status = 'CLEARED_UNACK'; UPDATE alarm SET acknowledged = false, cleared = false WHERE status = 'ACTIVE_UNACK'; --- Drop index by 'status' column and replace with new one that has only active alarms; +-- Drop index by 'status' column and replace with new indexes that has only active alarms; DROP INDEX IF EXISTS idx_alarm_originator_alarm_type_active; CREATE INDEX IF NOT EXISTS idx_alarm_originator_alarm_type_active ON alarm USING btree (originator_id, type) WHERE cleared = false; +DROP INDEX IF EXISTS idx_alarm_tenant_alarm_type_active; +CREATE INDEX IF NOT EXISTS idx_alarm_tenant_alarm_type_active + ON alarm USING btree (tenant_id, type) WHERE cleared = false; + -- Cover index by alarm type to optimize propagated alarm queries; DROP INDEX IF EXISTS idx_entity_alarm_entity_id_alarm_type_created_time_alarm_id; CREATE INDEX IF NOT EXISTS idx_entity_alarm_entity_id_alarm_type_created_time_alarm_id ON entity_alarm @@ -107,7 +111,7 @@ CREATE TABLE IF NOT EXISTS notification_template ( tenant_id UUID NOT NULL, name VARCHAR(255) NOT NULL, notification_type VARCHAR(50) NOT NULL, - configuration VARCHAR(10000) NOT NULL, + configuration VARCHAR(10000000) NOT NULL, CONSTRAINT uq_notification_template_name UNIQUE (tenant_id, name) ); CREATE INDEX IF NOT EXISTS idx_notification_template_tenant_id_created_time ON notification_template(tenant_id, created_time DESC); @@ -132,7 +136,7 @@ CREATE TABLE IF NOT EXISTS notification_request ( tenant_id UUID NOT NULL, targets VARCHAR(10000) NOT NULL, template_id UUID, - template VARCHAR(10000), + template VARCHAR(10000000), info VARCHAR(1000), additional_config VARCHAR(1000), originator_entity_id UUID, @@ -609,4 +613,4 @@ BEGIN END $$; --- TTL DROP PARTITIONS FUNCTIONS UPDATE END \ No newline at end of file +-- TTL DROP PARTITIONS FUNCTIONS UPDATE END diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index 26eab30d5d..e9d89ad6a2 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -89,7 +89,7 @@ import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetsBundleService; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; -import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; +import org.thingsboard.server.queue.notification.NotificationRuleProcessor; import org.thingsboard.server.queue.util.DataDecodingEncodingService; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; import org.thingsboard.server.service.component.ComponentDiscoveryService; diff --git a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java index 4669907fbd..84b7757846 100644 --- a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java @@ -96,6 +96,7 @@ public class TenantActor extends RuleChainManagerActor { log.info("[{}] Skip init of the rule chains due to API limits", tenantId); } } catch (Exception e) { + log.info("Failed to check ApiUsage \"ReExecEnabled\"!!!", e); cantFindTenant = true; } } diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 34f065d500..5104fe9d32 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -103,7 +103,6 @@ import org.thingsboard.server.common.data.rpc.Rpc; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.data.rule.RuleNode; -import org.thingsboard.server.common.data.settings.UserDashboardAction; import org.thingsboard.server.common.data.util.ThrowingBiFunction; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetsBundle; @@ -130,12 +129,12 @@ import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.rpc.RpcService; import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.service.ConstraintValidator; import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.user.UserService; -import org.thingsboard.server.dao.user.UserSettingsService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetsBundleService; import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; @@ -164,7 +163,9 @@ import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import javax.mail.MessagingException; import javax.servlet.http.HttpServletResponse; +import javax.validation.ConstraintViolation; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -395,16 +396,19 @@ public abstract class BaseController { * Handles validation error for controller method arguments annotated with @{@link javax.validation.Valid} * */ @ExceptionHandler(MethodArgumentNotValidException.class) - public void handleValidationError(MethodArgumentNotValidException e, HttpServletResponse response) { - String errorMessage = "Validation error: " + e.getFieldErrors().stream() + public void handleValidationError(MethodArgumentNotValidException validationError, HttpServletResponse response) { + List> constraintsViolations = validationError.getFieldErrors().stream() .map(fieldError -> { - String property = fieldError.getField(); - if (property.equals("valid") || StringUtils.endsWith(property, ".valid")) { // when custom @AssertTrue is used - property = ""; + try { + return (ConstraintViolation) fieldError.unwrap(ConstraintViolation.class); + } catch (Exception e) { + log.warn("FieldError source is not of type ConstraintViolation"); + return null; // should not happen } - return (!property.isEmpty() ? (property + " ") : "") + fieldError.getDefaultMessage(); }) - .collect(Collectors.joining(", ")); + .filter(Objects::nonNull) + .collect(Collectors.toList()); + String errorMessage = "Validation error: " + ConstraintValidator.getErrorMessage(constraintsViolations); ThingsboardException thingsboardException = new ThingsboardException(errorMessage, ThingsboardErrorCode.BAD_REQUEST_PARAMS); handleControllerException(thingsboardException, response); } @@ -793,8 +797,10 @@ public abstract class BaseController { protected > void logEntityAction(SecurityUser user, EntityType entityType, E entity, E savedEntity, ActionType actionType, Exception e) { EntityId entityId = savedEntity != null ? savedEntity.getId() : emptyId(entityType); - entityActionService.logEntityAction(user, entityId, savedEntity != null ? savedEntity : entity, - user.getCustomerId(), actionType, e); + if (!user.isSystemAdmin()) { + entityActionService.logEntityAction(user, entityId, savedEntity != null ? savedEntity : entity, + user.getCustomerId(), actionType, e); + } } protected > E doSaveAndLog(EntityType entityType, E entity, BiFunction savingFunction) throws Exception { diff --git a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java index 6c27887e1a..23aaba0e48 100644 --- a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java @@ -64,7 +64,9 @@ import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; import javax.validation.Valid; +import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -201,9 +203,6 @@ public class NotificationController extends BaseController { public NotificationRequestPreview getNotificationRequestPreview(@RequestBody @Valid NotificationRequest request, @RequestParam(defaultValue = "20") int recipientsPreviewSize, @AuthenticationPrincipal SecurityUser user) throws ThingsboardException { - NotificationRequestPreview preview = new NotificationRequestPreview(); - - request.setOriginatorEntityId(user.getId()); NotificationTemplate template; if (request.getTemplateId() != null) { template = checkEntityId(request.getTemplateId(), notificationTemplateService::findNotificationTemplateById, Operation.READ); @@ -213,33 +212,23 @@ public class NotificationController extends BaseController { if (template == null) { throw new IllegalArgumentException("Template is missing"); } - NotificationProcessingContext tmpProcessingCtx = NotificationProcessingContext.builder() - .tenantId(user.getTenantId()) - .request(request) - .template(template) - .settings(null) - .build(); + request.setOriginatorEntityId(user.getId()); + List targets = request.getTargets().stream() + .map(NotificationTargetId::new) + .map(targetId -> notificationTargetService.findNotificationTargetById(user.getTenantId(), targetId)) + .sorted(Comparator.comparing(target -> target.getConfiguration().getType())) + .collect(Collectors.toList()); - Map processedTemplates = tmpProcessingCtx.getDeliveryMethods().stream() - .collect(Collectors.toMap(m -> m, deliveryMethod -> { - NotificationRecipient recipient = null; - if (NotificationTargetType.PLATFORM_USERS.getSupportedDeliveryMethods().contains(deliveryMethod)) { - recipient = userService.findUserById(user.getTenantId(), user.getId()); - } - return tmpProcessingCtx.getProcessedTemplate(deliveryMethod, recipient); - })); - preview.setProcessedTemplates(processedTemplates); + NotificationRequestPreview preview = new NotificationRequestPreview(); - // generic permission Set recipientsPreview = new LinkedHashSet<>(); - Map recipientsCountByTarget = new HashMap<>(); - - List targets = notificationTargetService.findNotificationTargetsByTenantIdAndIds(user.getTenantId(), - request.getTargets().stream().map(NotificationTargetId::new).collect(Collectors.toList())); + Map recipientsCountByTarget = new LinkedHashMap<>(); + Map firstRecipient = new HashMap<>(); for (NotificationTarget target : targets) { int recipientsCount; List recipientsPart; - if (target.getConfiguration().getType() == NotificationTargetType.PLATFORM_USERS) { + NotificationTargetType targetType = target.getConfiguration().getType(); + if (targetType == NotificationTargetType.PLATFORM_USERS) { PageData recipients = notificationTargetService.findRecipientsForNotificationTargetConfig(user.getTenantId(), (PlatformUsersNotificationTargetConfig) target.getConfiguration(), new PageLink(recipientsPreviewSize)); recipientsCount = (int) recipients.getTotalElements(); @@ -248,7 +237,7 @@ public class NotificationController extends BaseController { recipientsCount = 1; recipientsPart = List.of(((SlackNotificationTargetConfig) target.getConfiguration()).getConversation()); } - + firstRecipient.putIfAbsent(targetType, !recipientsPart.isEmpty() ? recipientsPart.get(0) : null); for (NotificationRecipient recipient : recipientsPart) { if (recipientsPreview.size() < recipientsPreviewSize) { recipientsPreview.add(recipient.getTitle()); @@ -258,11 +247,26 @@ public class NotificationController extends BaseController { } recipientsCountByTarget.put(target.getName(), recipientsCount); } - preview.setRecipientsPreview(recipientsPreview); preview.setRecipientsCountByTarget(recipientsCountByTarget); preview.setTotalRecipientsCount(recipientsCountByTarget.values().stream().mapToInt(Integer::intValue).sum()); + Set deliveryMethods = template.getConfiguration().getDeliveryMethodsTemplates().entrySet() + .stream().filter(entry -> entry.getValue().isEnabled()).map(Map.Entry::getKey).collect(Collectors.toSet()); + NotificationProcessingContext ctx = NotificationProcessingContext.builder() + .tenantId(user.getTenantId()) + .request(request) + .deliveryMethods(deliveryMethods) + .template(template) + .settings(null) + .build(); + Map processedTemplates = ctx.getDeliveryMethods().stream() + .collect(Collectors.toMap(m -> m, deliveryMethod -> { + NotificationTargetType targetType = NotificationTargetType.forDeliveryMethod(deliveryMethod); + return ctx.getProcessedTemplate(deliveryMethod, firstRecipient.get(targetType)); + })); + preview.setProcessedTemplates(processedTemplates); + return preview; } diff --git a/application/src/main/java/org/thingsboard/server/controller/NotificationRuleController.java b/application/src/main/java/org/thingsboard/server/controller/NotificationRuleController.java index 4fdb0136f3..75e2b538a6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/NotificationRuleController.java +++ b/application/src/main/java/org/thingsboard/server/controller/NotificationRuleController.java @@ -67,7 +67,11 @@ public class NotificationRuleController extends BaseController { throw new IllegalArgumentException("Trigger type " + triggerType + " is not available"); } - return doSaveAndLog(EntityType.NOTIFICATION_RULE, notificationRule, notificationRuleService::saveNotificationRule); + boolean created = notificationRule.getId() == null; + notificationRule = doSaveAndLog(EntityType.NOTIFICATION_RULE, notificationRule, notificationRuleService::saveNotificationRule); + tbClusterService.broadcastEntityStateChangeEvent(user.getTenantId(), notificationRule.getId(), created ? + ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED); + return notificationRule; } @GetMapping("/rule/{id}") diff --git a/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java b/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java index 4fd6b93022..6374b65710 100644 --- a/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java @@ -71,7 +71,7 @@ public class NotificationTargetController extends BaseController { private final NotificationTargetService notificationTargetService; @ApiOperation(value = "Save notification target (saveNotificationTarget)", - notes = "Create or update notification target.\n\n" + + notes = "Create or update notification target." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PostMapping("/target") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") diff --git a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java index ce6537fd3f..05f56e93bd 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -75,16 +75,15 @@ import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.thingsboard.server.dao.service.ConstraintValidator; import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.exception.InvalidParametersException; +import org.thingsboard.server.exception.UncheckedApiException; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.AccessValidator; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.telemetry.AttributeData; import org.thingsboard.server.service.telemetry.TsData; -import org.thingsboard.server.exception.InvalidParametersException; -import org.thingsboard.server.exception.UncheckedApiException; import javax.annotation.Nullable; import javax.annotation.PostConstruct; @@ -624,7 +623,6 @@ public class TelemetryController extends BaseController { } if (json.isObject()) { List attributes = extractRequestAttributes(json); - attributes.forEach(ConstraintValidator::validateFields); if (attributes.isEmpty()) { return getImmediateDeferredResult("No attributes data found in request body!", HttpStatus.BAD_REQUEST); } diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java index 200a6c71d2..7a396ff795 100644 --- a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java +++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java @@ -84,6 +84,8 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke private long sendTimeout; @Value("${server.ws.ping_timeout:30000}") private long pingTimeout; + @Value("${server.ws.max_queue_messages_per_session:1000}") + private int wsMaxQueueMessagesPerSession; private final ConcurrentMap blacklistedSessions = new ConcurrentHashMap<>(); private final ConcurrentMap perSessionUpdateLimits = new ConcurrentHashMap<>(); @@ -143,9 +145,11 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke return; } var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); + int wsTenantProfileQueueLimit = tenantProfileConfiguration != null ? + tenantProfileConfiguration.getWsMsgQueueLimitPerSession() : wsMaxQueueMessagesPerSession; internalSessionMap.put(internalSessionId, new SessionMetaData(session, sessionRef, - tenantProfileConfiguration != null && tenantProfileConfiguration.getWsMsgQueueLimitPerSession() > 0 ? - tenantProfileConfiguration.getWsMsgQueueLimitPerSession() : 500)); + (wsTenantProfileQueueLimit > 0 && wsTenantProfileQueueLimit < wsMaxQueueMessagesPerSession) ? + wsTenantProfileQueueLimit : wsMaxQueueMessagesPerSession)); externalSessionMap.put(externalSessionId, internalSessionId); processInWebSocketService(sessionRef, SessionEvent.onEstablished()); diff --git a/application/src/main/java/org/thingsboard/server/service/action/EntityActionService.java b/application/src/main/java/org/thingsboard/server/service/action/EntityActionService.java index d39e200337..619df15e14 100644 --- a/application/src/main/java/org/thingsboard/server/service/action/EntityActionService.java +++ b/application/src/main/java/org/thingsboard/server/service/action/EntityActionService.java @@ -41,7 +41,9 @@ import org.thingsboard.server.common.data.relation.EntityRelation; 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.common.msg.notification.trigger.EntitiesLimitTrigger; import org.thingsboard.server.dao.audit.AuditLogService; +import org.thingsboard.server.queue.notification.NotificationRuleProcessor; import java.util.List; import java.util.Map; @@ -53,6 +55,7 @@ import java.util.stream.Collectors; public class EntityActionService { private final TbClusterService tbClusterService; private final AuditLogService auditLogService; + private final NotificationRuleProcessor notificationRuleProcessor; private static final ObjectMapper json = new ObjectMapper(); @@ -187,6 +190,12 @@ public class EntityActionService { AlarmComment comment = extractParameter(AlarmComment.class, 0, additionalInfo); metaData.putValue("comment", json.writeValueAsString(comment)); } + if (actionType == ActionType.ADDED && !tenantId.isSysTenantId()) { + notificationRuleProcessor.process(EntitiesLimitTrigger.builder() + .tenantId(tenantId) + .entityType(entityId.getEntityType()) + .build()); + } ObjectNode entityNode; if (entity != null) { entityNode = json.valueToTree(entity); @@ -247,7 +256,7 @@ public class EntityActionService { } public void logEntityAction(User user, I entityId, E entity, CustomerId customerId, - ActionType actionType, Exception e, Object... additionalInfo) { + ActionType actionType, Exception e, Object... additionalInfo) { if (customerId == null || customerId.isNullUid()) { customerId = user.getCustomerId(); } diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java b/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java index ced982f5e6..db8247bb93 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java @@ -15,8 +15,9 @@ */ package org.thingsboard.server.service.apiusage; +import lombok.Data; import lombok.Getter; -import org.springframework.data.util.Pair; +import lombok.Setter; import org.thingsboard.server.common.data.ApiFeature; import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.ApiUsageState; @@ -26,17 +27,17 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.tools.SchedulerUtils; -import java.util.Arrays; import java.util.HashMap; -import java.util.HashSet; import java.util.Map; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; public abstract class BaseApiUsageState { private final Map currentCycleValues = new ConcurrentHashMap<>(); private final Map currentHourValues = new ConcurrentHashMap<>(); + private final Map> lastGaugesByServiceId = new HashMap<>(); + private final Map gaugesReportCycles = new HashMap<>(); + @Getter private final ApiUsageState apiUsageState; @Getter @@ -46,6 +47,9 @@ public abstract class BaseApiUsageState { @Getter private volatile long currentHourTs; + @Setter + private long gaugeReportInterval; + public BaseApiUsageState(ApiUsageState apiUsageState) { this.apiUsageState = apiUsageState; this.currentCycleTs = SchedulerUtils.getStartOfCurrentMonth(); @@ -53,43 +57,76 @@ public abstract class BaseApiUsageState { this.currentHourTs = SchedulerUtils.getStartOfCurrentHour(); } - public void put(ApiUsageRecordKey key, Long value) { - currentCycleValues.put(key, value); + public StatsCalculationResult calculate(ApiUsageRecordKey key, long value, String serviceId) { + long currentValue = get(key); + long currentHourlyValue = getHourly(key); + + long newValue; + long newHourlyValue; + if (key.isCounter()) { + newValue = currentValue + value; + newHourlyValue = currentHourlyValue + value; + } else { + Long newGaugeValue = calculateGauge(key, value, serviceId); + newValue = newGaugeValue != null ? newGaugeValue : currentValue; + newHourlyValue = newGaugeValue != null ? Math.max(newGaugeValue, currentHourlyValue) : currentHourlyValue; + } + set(key, newValue); + setHourly(key, newHourlyValue); + + return StatsCalculationResult.of(newValue, newHourlyValue); } - public void putHourly(ApiUsageRecordKey key, Long value) { - currentHourValues.put(key, value); + private Long calculateGauge(ApiUsageRecordKey key, long value, String serviceId) { + Map lastByServiceId = lastGaugesByServiceId.computeIfAbsent(key, k -> { + gaugesReportCycles.put(key, System.currentTimeMillis()); + return new HashMap<>(); + }); + lastByServiceId.put(serviceId, value); + + Long gaugeReportCycle = gaugesReportCycles.get(key); + if (gaugeReportCycle <= System.currentTimeMillis() - gaugeReportInterval) { + long newValue = lastByServiceId.values().stream().mapToLong(Long::longValue).sum(); + lastGaugesByServiceId.remove(key); + gaugesReportCycles.remove(key); + return newValue; + } else { + return null; + } } - public long add(ApiUsageRecordKey key, long value) { - long result = currentCycleValues.getOrDefault(key, 0L) + value; - currentCycleValues.put(key, result); - return result; + public void set(ApiUsageRecordKey key, Long value) { + currentCycleValues.put(key, value); } public long get(ApiUsageRecordKey key) { return currentCycleValues.getOrDefault(key, 0L); } - public long addToHourly(ApiUsageRecordKey key, long value) { - long result = currentHourValues.getOrDefault(key, 0L) + value; - currentHourValues.put(key, result); - return result; + public void setHourly(ApiUsageRecordKey key, Long value) { + currentHourValues.put(key, value); + } + + public long getHourly(ApiUsageRecordKey key) { + return currentHourValues.getOrDefault(key, 0L); } public void setHour(long currentHourTs) { this.currentHourTs = currentHourTs; - for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) { - currentHourValues.put(key, 0L); - } + currentHourValues.clear(); + lastGaugesByServiceId.clear(); + gaugesReportCycles.clear(); } public void setCycles(long currentCycleTs, long nextCycleTs) { this.currentCycleTs = currentCycleTs; this.nextCycleTs = nextCycleTs; - for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) { - currentCycleValues.put(key, 0L); - } + currentCycleValues.clear(); + } + + public void onRepartitionEvent() { + lastGaugesByServiceId.clear(); + gaugesReportCycles.clear(); } public ApiUsageStateValue getFeatureValue(ApiFeature feature) { @@ -150,4 +187,11 @@ public abstract class BaseApiUsageState { public EntityId getEntityId() { return getApiUsageState().getEntityId(); } + + @Data(staticConstructor = "of") + public static class StatsCalculationResult { + private final long newValue; + private final long newHourlyValue; + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java index b4a625e7de..9fdb1d0b8e 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java @@ -48,12 +48,12 @@ import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.page.PageDataIterable; import org.thingsboard.server.common.data.tenant.profile.TenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; +import org.thingsboard.server.queue.notification.NotificationRuleProcessor; +import org.thingsboard.server.common.msg.notification.trigger.ApiUsageLimitTrigger; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.msg.tools.SchedulerUtils; -import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; -import org.thingsboard.server.common.msg.notification.trigger.ApiUsageLimitTrigger; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.timeseries.TimeseriesService; @@ -62,6 +62,7 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToUsageStatsServiceM import org.thingsboard.server.gen.transport.TransportProtos.UsageStatsKVProto; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.service.apiusage.BaseApiUsageState.StatsCalculationResult; import org.thingsboard.server.service.executors.DbCallbackExecutorService; import org.thingsboard.server.service.mail.MailExecutorService; import org.thingsboard.server.service.partition.AbstractPartitionBasedService; @@ -128,6 +129,9 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService @Value("${usage.stats.check.cycle:60000}") private long nextCycleCheckInterval; + @Value("${usage.stats.gauge_report_interval:180000}") + private long gaugeReportInterval; + private final Lock updateLock = new ReentrantLock(); @PostConstruct @@ -155,19 +159,19 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService ToUsageStatsServiceMsg statsMsg = msg.getValue(); TenantId tenantId = TenantId.fromUUID(new UUID(statsMsg.getTenantIdMSB(), statsMsg.getTenantIdLSB())); - EntityId entityId; + EntityId ownerId; if (statsMsg.getCustomerIdMSB() != 0 && statsMsg.getCustomerIdLSB() != 0) { - entityId = new CustomerId(new UUID(statsMsg.getCustomerIdMSB(), statsMsg.getCustomerIdLSB())); + ownerId = new CustomerId(new UUID(statsMsg.getCustomerIdMSB(), statsMsg.getCustomerIdLSB())); } else { - entityId = tenantId; + ownerId = tenantId; } - processEntityUsageStats(tenantId, entityId, statsMsg.getValuesList()); + processEntityUsageStats(tenantId, ownerId, statsMsg.getValuesList(), statsMsg.getServiceId()); callback.onSuccess(); } - private void processEntityUsageStats(TenantId tenantId, EntityId entityId, List values) { - if (deletedEntities.contains(entityId)) return; + private void processEntityUsageStats(TenantId tenantId, EntityId ownerId, List values, String serviceId) { + if (deletedEntities.contains(ownerId)) return; BaseApiUsageState usageState; List updatedEntries; @@ -175,7 +179,7 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService updateLock.lock(); try { - usageState = getOrFetchState(tenantId, entityId); + usageState = getOrFetchState(tenantId, ownerId); long ts = usageState.getCurrentCycleTs(); long hourTs = usageState.getCurrentHourTs(); long newHourTs = SchedulerUtils.getStartOfCurrentHour(); @@ -184,13 +188,18 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService } updatedEntries = new ArrayList<>(ApiUsageRecordKey.values().length); Set apiFeatures = new HashSet<>(); - for (UsageStatsKVProto kvProto : values) { - ApiUsageRecordKey recordKey = ApiUsageRecordKey.valueOf(kvProto.getKey()); - long newValue = usageState.add(recordKey, kvProto.getValue()); + for (UsageStatsKVProto statsItem : values) { + ApiUsageRecordKey recordKey = ApiUsageRecordKey.valueOf(statsItem.getKey()); + + StatsCalculationResult calculationResult = usageState.calculate(recordKey, statsItem.getValue(), serviceId); + long newValue = calculationResult.getNewValue(); + long newHourlyValue = calculationResult.getNewHourlyValue(); + updatedEntries.add(new BasicTsKvEntry(ts, new LongDataEntry(recordKey.getApiCountKey(), newValue))); - long newHourlyValue = usageState.addToHourly(recordKey, kvProto.getValue()); updatedEntries.add(new BasicTsKvEntry(newHourTs, new LongDataEntry(recordKey.getApiCountKey() + HOURLY, newHourlyValue))); - apiFeatures.add(recordKey.getApiFeature()); + if (recordKey.getApiFeature() != null) { + apiFeatures.add(recordKey.getApiFeature()); + } } if (usageState.getEntityType() == EntityType.TENANT && !usageState.getEntityId().equals(TenantId.SYS_TENANT_ID)) { result = ((TenantApiUsageState) usageState).checkStateUpdatedDueToThreshold(apiFeatures); @@ -418,26 +427,26 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService tsWsService.saveAndNotifyInternal(state.getTenantId(), state.getApiUsageState().getId(), counts, VOID_CALLBACK); } - BaseApiUsageState getOrFetchState(TenantId tenantId, EntityId entityId) { - if (entityId == null || entityId.isNullUid()) { - entityId = tenantId; + BaseApiUsageState getOrFetchState(TenantId tenantId, EntityId ownerId) { + if (ownerId == null || ownerId.isNullUid()) { + ownerId = tenantId; } - BaseApiUsageState state = myUsageStates.get(entityId); + BaseApiUsageState state = myUsageStates.get(ownerId); if (state != null) { return state; } - ApiUsageState storedState = apiUsageStateService.findApiUsageStateByEntityId(entityId); + ApiUsageState storedState = apiUsageStateService.findApiUsageStateByEntityId(ownerId); if (storedState == null) { try { - storedState = apiUsageStateService.createDefaultApiUsageState(tenantId, entityId); + storedState = apiUsageStateService.createDefaultApiUsageState(tenantId, ownerId); } catch (Exception e) { - storedState = apiUsageStateService.findApiUsageStateByEntityId(entityId); + storedState = apiUsageStateService.findApiUsageStateByEntityId(ownerId); } } - if (entityId.getEntityType() == EntityType.TENANT) { - if (!entityId.equals(TenantId.SYS_TENANT_ID)) { - state = new TenantApiUsageState(tenantProfileCache.get((TenantId) entityId), storedState); + if (ownerId.getEntityType() == EntityType.TENANT) { + if (!ownerId.equals(TenantId.SYS_TENANT_ID)) { + state = new TenantApiUsageState(tenantProfileCache.get((TenantId) ownerId), storedState); } else { state = new TenantApiUsageState(storedState); } @@ -456,26 +465,27 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService cycleEntryFound = true; boolean oldCount = tsKvEntry.getTs() == state.getCurrentCycleTs(); - state.put(key, oldCount ? tsKvEntry.getLongValue().get() : 0L); + state.set(key, oldCount ? tsKvEntry.getLongValue().get() : 0L); if (!oldCount) { newCounts.add(key); } } else if (tsKvEntry.getKey().equals(key.getApiCountKey() + HOURLY)) { hourlyEntryFound = true; - state.putHourly(key, tsKvEntry.getTs() == state.getCurrentHourTs() ? tsKvEntry.getLongValue().get() : 0L); + state.setHourly(key, tsKvEntry.getTs() == state.getCurrentHourTs() ? tsKvEntry.getLongValue().get() : 0L); } if (cycleEntryFound && hourlyEntryFound) { break; } } } - log.debug("[{}] Initialized state: {}", entityId, storedState); - TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId); + state.setGaugeReportInterval(gaugeReportInterval); + log.debug("[{}] Initialized state: {}", ownerId, storedState); + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, ownerId); if (tpi.isMyPartition()) { addEntityState(tpi, state); } else { - otherUsageStates.put(entityId, state.getApiUsageState()); + otherUsageStates.put(ownerId, state.getApiUsageState()); } saveNewCounts(state, newCounts); } catch (InterruptedException | ExecutionException e) { @@ -489,6 +499,12 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService protected void onRepartitionEvent() { otherUsageStates.entrySet().removeIf(entry -> partitionService.resolve(ServiceType.TB_CORE, entry.getValue().getTenantId(), entry.getKey()).isMyPartition()); + updateLock.lock(); + try { + myUsageStates.values().forEach(BaseApiUsageState::onRepartitionEvent); + } finally { + updateLock.unlock(); + } } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/limits/DefaultRateLimitService.java b/application/src/main/java/org/thingsboard/server/service/apiusage/limits/DefaultRateLimitService.java index e995a8bd1f..9a0282c840 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/limits/DefaultRateLimitService.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/limits/DefaultRateLimitService.java @@ -15,54 +15,81 @@ */ package org.thingsboard.server.service.apiusage.limits; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.Data; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.tools.TbRateLimits; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import javax.annotation.PostConstruct; +import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor +@Slf4j public class DefaultRateLimitService implements RateLimitService { private final TbTenantProfileCache tenantProfileCache; + @Value("${cache.rateLimits.timeToLiveInMinutes:60}") + private int rateLimitsTtl; + @Value("${cache.rateLimits.maxSize:100000}") + private int rateLimitsCacheMaxSize; - private final Map> rateLimits = new ConcurrentHashMap<>(); + private Cache rateLimits; + + @PostConstruct + private void init() { + rateLimits = Caffeine.newBuilder() + .expireAfterAccess(rateLimitsTtl, TimeUnit.MINUTES) + .maximumSize(rateLimitsCacheMaxSize) + .build(); + } + + @Override + public boolean checkRateLimit(LimitedApi api, TenantId tenantId) { + return checkRateLimit(api, tenantId, tenantId); + } @Override - public boolean checkRateLimit(TenantId tenantId, LimitedApi api) { + public boolean checkRateLimit(LimitedApi api, TenantId tenantId, EntityId entityId) { if (tenantId.isSysTenantId()) { return true; } + RateLimitKey key = new RateLimitKey(api, entityId); + String rateLimitConfig = tenantProfileCache.get(tenantId).getProfileConfiguration() .map(api::getLimitConfig).orElse(null); - - Map rateLimits = this.rateLimits.get(api); if (StringUtils.isEmpty(rateLimitConfig)) { - if (rateLimits != null) { - rateLimits.remove(tenantId); - if (rateLimits.isEmpty()) { - this.rateLimits.remove(api); - } - } + rateLimits.invalidate(key); return true; } + log.trace("[{}] Checking rate limit for {} ({})", entityId, api, rateLimitConfig); - if (rateLimits == null) { - rateLimits = new ConcurrentHashMap<>(); - this.rateLimits.put(api, rateLimits); - } - TbRateLimits rateLimit = rateLimits.get(tenantId); - if (rateLimit == null || !rateLimit.getConfiguration().equals(rateLimitConfig)) { - rateLimit = new TbRateLimits(rateLimitConfig); - rateLimits.put(tenantId, rateLimit); + TbRateLimits rateLimit = rateLimits.asMap().compute(key, (k, limit) -> { + if (limit == null || !limit.getConfiguration().equals(rateLimitConfig)) { + limit = new TbRateLimits(rateLimitConfig); + log.trace("[{}] Created new rate limit bucket for {} ({})", entityId, api, rateLimitConfig); + } + return limit; + }); + boolean success = rateLimit.tryConsume(); + if (!success) { + log.debug("[{}] Rate limit exceeded for {} ({})", entityId, api, rateLimitConfig); } + return success; + } - return rateLimit.tryConsume(); + @Data(staticConstructor = "of") + private static class RateLimitKey { + private final LimitedApi api; + private final EntityId entityId; } } diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/limits/LimitedApi.java b/application/src/main/java/org/thingsboard/server/service/apiusage/limits/LimitedApi.java index f69ece6661..4f216ffaca 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/limits/LimitedApi.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/limits/LimitedApi.java @@ -25,7 +25,8 @@ public enum LimitedApi { ENTITY_EXPORT(DefaultTenantProfileConfiguration::getTenantEntityExportRateLimit), ENTITY_IMPORT(DefaultTenantProfileConfiguration::getTenantEntityImportRateLimit), - NOTIFICATION_REQUEST(DefaultTenantProfileConfiguration::getTenantNotificationRequestsRateLimit); + NOTIFICATION_REQUESTS(DefaultTenantProfileConfiguration::getTenantNotificationRequestsRateLimit), + NOTIFICATION_REQUESTS_PER_RULE(DefaultTenantProfileConfiguration::getTenantNotificationRequestsPerRuleRateLimit); private final Function configExtractor; diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/limits/RateLimitService.java b/application/src/main/java/org/thingsboard/server/service/apiusage/limits/RateLimitService.java index c984ec8fa5..3fde98618e 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/limits/RateLimitService.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/limits/RateLimitService.java @@ -15,10 +15,13 @@ */ package org.thingsboard.server.service.apiusage.limits; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; public interface RateLimitService { - boolean checkRateLimit(TenantId tenantId, LimitedApi api); + boolean checkRateLimit(LimitedApi api, TenantId tenantId); + + boolean checkRateLimit(LimitedApi api, TenantId tenantId, EntityId entityId); } diff --git a/application/src/main/java/org/thingsboard/server/service/device/ClaimDevicesServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/device/ClaimDevicesServiceImpl.java index a0e6545652..4000e5a09d 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/ClaimDevicesServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/device/ClaimDevicesServiceImpl.java @@ -90,35 +90,32 @@ public class ClaimDevicesServiceImpl implements ClaimDevicesService { @Override public ListenableFuture registerClaimingInfo(TenantId tenantId, DeviceId deviceId, String secretKey, long durationMs) { - ListenableFuture deviceFuture = deviceService.findDeviceByIdAsync(tenantId, deviceId); - return Futures.transformAsync(deviceFuture, device -> { - Cache cache = cacheManager.getCache(CLAIM_DEVICES_CACHE); - List key = constructCacheKey(device.getId()); - - if (isAllowedClaimingByDefault) { - if (device.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { - persistInCache(secretKey, durationMs, cache, key); - return Futures.immediateFuture(null); + Device device = deviceService.findDeviceById(tenantId, deviceId); + Cache cache = cacheManager.getCache(CLAIM_DEVICES_CACHE); + List key = constructCacheKey(device.getId()); + if (isAllowedClaimingByDefault) { + if (device.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { + persistInCache(secretKey, durationMs, cache, key); + return Futures.immediateFuture(null); + } + log.warn("The device [{}] has been already claimed!", device.getName()); + return Futures.immediateFailedFuture(new IllegalArgumentException()); + } else { + ListenableFuture> claimingAllowedFuture = attributesService.find(tenantId, device.getId(), + DataConstants.SERVER_SCOPE, Collections.singletonList(CLAIM_ATTRIBUTE_NAME)); + return Futures.transform(claimingAllowedFuture, list -> { + if (list != null && !list.isEmpty()) { + Optional claimingAllowedOptional = list.get(0).getBooleanValue(); + if (claimingAllowedOptional.isPresent() && claimingAllowedOptional.get() + && device.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { + persistInCache(secretKey, durationMs, cache, key); + return null; + } } - log.warn("The device [{}] has been already claimed!", device.getName()); + log.warn("Failed to find claimingAllowed attribute for device or it is already claimed![{}]", device.getName()); throw new IllegalArgumentException(); - } else { - ListenableFuture> claimingAllowedFuture = attributesService.find(tenantId, device.getId(), - DataConstants.SERVER_SCOPE, Collections.singletonList(CLAIM_ATTRIBUTE_NAME)); - return Futures.transform(claimingAllowedFuture, list -> { - if (list != null && !list.isEmpty()) { - Optional claimingAllowedOptional = list.get(0).getBooleanValue(); - if (claimingAllowedOptional.isPresent() && claimingAllowedOptional.get() - && device.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { - persistInCache(secretKey, durationMs, cache, key); - return null; - } - } - log.warn("Failed to find claimingAllowed attribute for device or it is already claimed![{}]", device.getName()); - throw new IllegalArgumentException(); - }, MoreExecutors.directExecutor()); - } - }, MoreExecutors.directExecutor()); + }, MoreExecutors.directExecutor()); + } } private ListenableFuture getClaimData(Cache cache, Device device) { diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java index 80b51f7ad5..beecebe2ba 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java @@ -20,15 +20,16 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; @@ -36,15 +37,16 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.transport.util.SslUtil; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.device.DeviceCredentialsService; -import org.thingsboard.server.dao.device.DeviceDao; -import org.thingsboard.server.dao.device.DeviceProfileDao; +import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceProvisionService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.device.provision.ProvisionFailedException; @@ -59,12 +61,13 @@ import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.state.DeviceStateService; import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.concurrent.ExecutionException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service @@ -77,35 +80,56 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { private static final String DEVICE_PROVISION_STATE = "provisionState"; private static final String PROVISIONED_STATE = "provisioned"; - @Autowired - TbClusterService clusterService; + private final TbClusterService clusterService; + private final DeviceProfileService deviceProfileService; + private final DeviceService deviceService; + private final DeviceCredentialsService deviceCredentialsService; + private final AttributesService attributesService; + private final AuditLogService auditLogService; + private final PartitionService partitionService; - @Autowired - DeviceDao deviceDao; - - @Autowired - DeviceProfileDao deviceProfileDao; - - @Autowired - DeviceService deviceService; - - @Autowired - DeviceCredentialsService deviceCredentialsService; - - @Autowired - AttributesService attributesService; - - @Autowired - DeviceStateService deviceStateService; - - @Autowired - AuditLogService auditLogService; - - @Autowired - PartitionService partitionService; - - public DeviceProvisionServiceImpl(TbQueueProducerProvider producerProvider) { + public DeviceProvisionServiceImpl(TbQueueProducerProvider producerProvider, TbClusterService clusterService, DeviceProfileService deviceProfileService, DeviceService deviceService, DeviceCredentialsService deviceCredentialsService, AttributesService attributesService, AuditLogService auditLogService, PartitionService partitionService) { ruleEngineMsgProducer = producerProvider.getRuleEngineMsgProducer(); + this.clusterService = clusterService; + this.deviceProfileService = deviceProfileService; + this.deviceService = deviceService; + this.deviceCredentialsService = deviceCredentialsService; + this.attributesService = attributesService; + this.auditLogService = auditLogService; + this.partitionService = partitionService; + } + + @Override + public ProvisionResponse provisionDeviceViaX509Chain(DeviceProfile targetProfile, ProvisionRequest provisionRequest) throws ProvisionFailedException { + if (targetProfile == null) { + throw new ProvisionFailedException("Device profile is not specified!"); + } + if (!DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN.equals(targetProfile.getProfileData().getProvisionConfiguration().getType())) { + throw new ProvisionFailedException("Device profile provision strategy is not X509_CERTIFICATE_CHAIN!"); + } + X509CertificateChainProvisionConfiguration configuration = (X509CertificateChainProvisionConfiguration) targetProfile.getProfileData().getProvisionConfiguration(); + String certificateValue = provisionRequest.getCredentialsData().getX509CertHash(); + String certificateRegEx = configuration.getCertificateRegExPattern(); + String commonName = getCNFromX509Certificate(targetProfile, certificateValue); + String deviceName = extractDeviceNameFromCNByRegEx(targetProfile, commonName, certificateRegEx); + provisionRequest.setDeviceName(deviceName); + Device targetDevice = deviceService.findDeviceByTenantIdAndName(targetProfile.getTenantId(), provisionRequest.getDeviceName()); + X509CertificateChainProvisionConfiguration x509Configuration = (X509CertificateChainProvisionConfiguration) targetProfile.getProfileData().getProvisionConfiguration(); + if (targetDevice != null && targetDevice.getDeviceProfileId().equals(targetProfile.getId())) { + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(targetDevice.getTenantId(), targetDevice.getId()); + if (DeviceCredentialsType.X509_CERTIFICATE.equals(deviceCredentials.getCredentialsType())) { + String updatedDeviceCertificateValue = provisionRequest.getCredentialsData().getX509CertHash(); + deviceCredentials = updateDeviceCredentials(targetDevice.getTenantId(), deviceCredentials, + updatedDeviceCertificateValue, DeviceCredentialsType.X509_CERTIFICATE); + } + return new ProvisionResponse(deviceCredentials, ProvisionResponseStatus.SUCCESS); + } else if (x509Configuration.isAllowCreateNewDevicesByX509Certificate()) { + return createDevice(provisionRequest, targetProfile); + } else { + log.warn("[{}][{}] Device with name {} doesn't exist and cannot be created due incorrect configuration for X509CertificateChainProvisionConfiguration", + targetProfile.getTenantId(), targetProfile.getId(), provisionRequest.getDeviceName()); + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } } @Override @@ -124,14 +148,14 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); } - DeviceProfile targetProfile = deviceProfileDao.findByProvisionDeviceKey(provisionRequestKey); + DeviceProfile targetProfile = deviceProfileService.findDeviceProfileByProvisionDeviceKey(provisionRequestKey); if (targetProfile == null || targetProfile.getProfileData().getProvisionConfiguration() == null || targetProfile.getProfileData().getProvisionConfiguration().getProvisionDeviceSecret() == null) { throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); } - Device targetDevice = deviceDao.findDeviceByTenantIdAndName(targetProfile.getTenantId().getId(), provisionRequest.getDeviceName()).orElse(null); + Device targetDevice = deviceService.findDeviceByTenantIdAndName(targetProfile.getTenantId(), provisionRequest.getDeviceName()); switch (targetProfile.getProvisionType()) { case ALLOW_CREATE_NEW_DEVICES: @@ -155,6 +179,8 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { } } break; + case X509_CERTIFICATE_CHAIN: + throw new ProvisionFailedException("Invalid provision strategy type!"); } throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); } @@ -209,6 +235,14 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { } } + private DeviceCredentials updateDeviceCredentials(TenantId tenantId, DeviceCredentials deviceCredentials, String certificateValue, + DeviceCredentialsType credentialsType) { + log.trace("Updating device credentials [{}] with certificate value [{}]", deviceCredentials, certificateValue); + deviceCredentials.setCredentialsValue(certificateValue); + deviceCredentials.setCredentialsType(credentialsType); + return deviceCredentialsService.updateDeviceCredentials(tenantId, deviceCredentials); + } + private ListenableFuture> saveProvisionStateAttribute(Device device) { return attributesService.save(device.getTenantId(), device.getId(), DataConstants.SERVER_SCOPE, Collections.singletonList(new BaseAttributeKvEntry(new StringDataEntry(DEVICE_PROVISION_STATE, PROVISIONED_STATE), @@ -257,4 +291,27 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { ActionType actionType = success ? ActionType.PROVISION_SUCCESS : ActionType.PROVISION_FAILURE; auditLogService.logEntityAction(tenantId, customerId, new UserId(UserId.NULL_UUID), device.getName(), device.getId(), device, actionType, null, provisionRequest); } + + private String getCNFromX509Certificate(DeviceProfile profile, String x509Value) { + try { + return SslUtil.parseCommonName(SslUtil.readCertFile(x509Value)); + } catch (Exception e) { + log.trace("[{}][{}] Failed to parse CN from X509 certificate {}", profile.getTenantId(), profile.getId(), x509Value); + return null; + } + } + + public String extractDeviceNameFromCNByRegEx(DeviceProfile profile, String commonName, String regex) throws ProvisionFailedException { + try { + log.trace("Extract device name from CN [{}] by regex pattern [{}]", commonName, regex); + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(commonName); + if (matcher.find()) { + return matcher.group(1); + } + } catch (Exception ignored) {} + log.trace("[{}][{}] Failed to match device name using [{}] from CN: [{}]", profile.getTenantId(), profile.getId(), regex, commonName); + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index f695fa1d0e..5171a6037f 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.edge.rpc; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import io.grpc.Server; @@ -25,6 +26,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.DataConstants; @@ -35,6 +37,9 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; +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.common.msg.edge.EdgeEventUpdateMsg; import org.thingsboard.server.common.msg.edge.EdgeSessionMsg; import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse; @@ -66,6 +71,10 @@ import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; +import static org.thingsboard.server.common.data.DataConstants.CONNECT_EVENT; +import static org.thingsboard.server.common.data.DataConstants.DISCONNECT_EVENT; +import static org.thingsboard.server.common.data.DataConstants.SERVER_SCOPE; + @Service @Slf4j @ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true") @@ -89,11 +98,14 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private String privateKeyResource; @Value("${edges.state.persistToTelemetry:false}") private boolean persistToTelemetry; - @Value("${edges.rpc.client_max_keep_alive_time_sec}") + @Value("${edges.rpc.client_max_keep_alive_time_sec:1}") private int clientMaxKeepAliveTimeSec; @Value("${edges.rpc.max_inbound_message_size:4194304}") private int maxInboundMessageSize; - + @Value("${edges.rpc.keep_alive_time_sec:10}") + private int keepAliveTimeSec; + @Value("${edges.rpc.keep_alive_timeout_sec:5}") + private int keepAliveTimeoutSec; @Value("${edges.scheduler_pool_size}") private int schedulerPoolSize; @@ -122,6 +134,9 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i log.info("Initializing Edge RPC service!"); NettyServerBuilder builder = NettyServerBuilder.forPort(rpcPort) .permitKeepAliveTime(clientMaxKeepAliveTimeSec, TimeUnit.SECONDS) + .keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS) + .keepAliveTimeout(keepAliveTimeoutSec, TimeUnit.SECONDS) + .permitKeepAliveWithoutCalls(true) .maxInboundMessageSize(maxInboundMessageSize) .addService(this); if (sslEnabled) { @@ -174,7 +189,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i @Override public StreamObserver handleMsgs(StreamObserver outputStream) { - return new EdgeGrpcSession(ctx, outputStream, this::onEdgeConnect, this::onEdgeDisconnect, sendDownlinkExecutorService).getInputStream(); + return new EdgeGrpcSession(ctx, outputStream, this::onEdgeConnect, this::onEdgeDisconnect, sendDownlinkExecutorService, this.maxInboundMessageSize).getInputStream(); } @Override @@ -261,7 +276,9 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i newEventLock.unlock(); } save(edgeId, DefaultDeviceStateService.ACTIVITY_STATE, true); - save(edgeId, DefaultDeviceStateService.LAST_CONNECT_TIME, System.currentTimeMillis()); + long lastConnectTs = System.currentTimeMillis(); + save(edgeId, DefaultDeviceStateService.LAST_CONNECT_TIME, lastConnectTs); + pushRuleEngineMessage(edgeGrpcSession.getEdge().getTenantId(), edgeId, lastConnectTs, CONNECT_EVENT); cancelScheduleEdgeEventsCheck(edgeId); scheduleEdgeEventsCheck(edgeGrpcSession); } @@ -271,7 +288,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i if (session != null) { boolean success = false; if (session.isConnected()) { - session.startSyncProcess(tenantId, edgeId, true); + session.startSyncProcess(true); success = true; } clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, success)); @@ -365,7 +382,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private void onEdgeDisconnect(EdgeId edgeId) { log.info("[{}] edge disconnected!", edgeId); - sessions.remove(edgeId); + EdgeGrpcSession removed = sessions.remove(edgeId); final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock()); newEventLock.lock(); try { @@ -374,7 +391,9 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i newEventLock.unlock(); } save(edgeId, DefaultDeviceStateService.ACTIVITY_STATE, false); - save(edgeId, DefaultDeviceStateService.LAST_DISCONNECT_TIME, System.currentTimeMillis()); + long lastDisconnectTs = System.currentTimeMillis(); + save(edgeId, DefaultDeviceStateService.LAST_DISCONNECT_TIME, lastDisconnectTs); + pushRuleEngineMessage(removed.getEdge().getTenantId(), edgeId, lastDisconnectTs, DISCONNECT_EVENT); cancelScheduleEdgeEventsCheck(edgeId); } @@ -423,4 +442,26 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i log.warn("[{}] Failed to update attribute [{}] with value [{}]", edgeId, key, value, t); } } + + private void pushRuleEngineMessage(TenantId tenantId, EdgeId edgeId, long ts, String msgType) { + try { + ObjectNode edgeState = JacksonUtil.OBJECT_MAPPER.createObjectNode(); + if (msgType.equals(CONNECT_EVENT)) { + edgeState.put(DefaultDeviceStateService.ACTIVITY_STATE, true); + edgeState.put(DefaultDeviceStateService.LAST_CONNECT_TIME, ts); + } else { + edgeState.put(DefaultDeviceStateService.ACTIVITY_STATE, false); + edgeState.put(DefaultDeviceStateService.LAST_DISCONNECT_TIME, ts); + } + String data = JacksonUtil.toString(edgeState); + TbMsgMetaData md = new TbMsgMetaData(); + if (!persistToTelemetry) { + md.putValue(DataConstants.SCOPE, SERVER_SCOPE); + } + TbMsg tbMsg = TbMsg.newMsg(msgType, edgeId, md, TbMsgDataType.JSON, data); + clusterService.pushMsgToRuleEngine(tenantId, edgeId, tbMsg, null); + } catch (Exception e) { + log.warn("[{}][{}] Failed to push {}", tenantId, edgeId, msgType, e); + } + } } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java index efa0fb4de7..33aed92682 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java @@ -105,16 +105,20 @@ public final class EdgeGrpcSession implements Closeable { private EdgeVersion edgeVersion; + private int maxInboundMessageSize; + private int clientMaxInboundMessageSize; + private ScheduledExecutorService sendDownlinkExecutorService; EdgeGrpcSession(EdgeContextComponent ctx, StreamObserver outputStream, BiConsumer sessionOpenListener, - Consumer sessionCloseListener, ScheduledExecutorService sendDownlinkExecutorService) { + Consumer sessionCloseListener, ScheduledExecutorService sendDownlinkExecutorService, int maxInboundMessageSize) { this.sessionId = UUID.randomUUID(); this.ctx = ctx; this.outputStream = outputStream; this.sessionOpenListener = sessionOpenListener; this.sessionCloseListener = sessionCloseListener; this.sendDownlinkExecutorService = sendDownlinkExecutorService; + this.maxInboundMessageSize = maxInboundMessageSize; initInputStream(); } @@ -130,6 +134,10 @@ public final class EdgeGrpcSession implements Closeable { if (ConnectResponseCode.ACCEPTED != responseMsg.getResponseCode()) { outputStream.onError(new RuntimeException(responseMsg.getErrorMsg())); } else { + if (requestMsg.getConnectRequestMsg().hasMaxInboundMessageSize()) { + log.debug("[{}] Client max inbound message size: {}", sessionId, requestMsg.getConnectRequestMsg().getMaxInboundMessageSize()); + clientMaxInboundMessageSize = requestMsg.getConnectRequestMsg().getMaxInboundMessageSize(); + } connected = true; } } @@ -140,7 +148,7 @@ public final class EdgeGrpcSession implements Closeable { if (requestMsg.getSyncRequestMsg().hasFullSync()) { fullSync = requestMsg.getSyncRequestMsg().getFullSync(); } - startSyncProcess(edge.getTenantId(), edge.getId(), fullSync); + startSyncProcess(fullSync); } else { syncCompleted = true; } @@ -184,10 +192,10 @@ public final class EdgeGrpcSession implements Closeable { }; } - public void startSyncProcess(TenantId tenantId, EdgeId edgeId, boolean fullSync) { - log.trace("[{}][{}] Staring edge sync process", tenantId, edgeId); + public void startSyncProcess(boolean fullSync) { + log.trace("[{}][{}][{}] Staring edge sync process", edge.getTenantId(), edge.getId(), this.sessionId); syncCompleted = false; - interruptGeneralProcessingOnSync(tenantId, edgeId); + interruptGeneralProcessingOnSync(); doSync(new EdgeSyncCursor(ctx, edge, fullSync)); } @@ -213,9 +221,9 @@ public final class EdgeGrpcSession implements Closeable { .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) .setSyncCompletedMsg(SyncCompletedMsg.newBuilder().build()) .build(); - Futures.addCallback(sendDownlinkMsgsPack(Collections.singletonList(syncCompleteDownlinkMsg)), new FutureCallback() { + Futures.addCallback(sendDownlinkMsgsPack(Collections.singletonList(syncCompleteDownlinkMsg)), new FutureCallback<>() { @Override - public void onSuccess(Void result) { + public void onSuccess(Boolean isInterrupted) { syncCompleted = true; ctx.getClusterService().onEdgeEventUpdate(edge.getTenantId(), edge.getId()); } @@ -264,7 +272,7 @@ public final class EdgeGrpcSession implements Closeable { } if (sessionState.getPendingMsgsMap().isEmpty()) { log.debug("[{}] Pending msgs map is empty. Stopping current iteration", edge.getRoutingKey()); - stopCurrentSendDownlinkMsgsTask(null); + stopCurrentSendDownlinkMsgsTask(false); } } catch (Exception e) { log.error("[{}] Can't process downlink response message [{}]", this.sessionId, msg, e); @@ -359,14 +367,19 @@ public final class EdgeGrpcSession implements Closeable { if (isConnected() && !pageData.getData().isEmpty()) { log.trace("[{}] [{}] event(s) are going to be processed.", this.sessionId, pageData.getData().size()); List downlinkMsgsPack = convertToDownlinkMsgsPack(pageData.getData()); - Futures.addCallback(sendDownlinkMsgsPack(downlinkMsgsPack), new FutureCallback() { + Futures.addCallback(sendDownlinkMsgsPack(downlinkMsgsPack), new FutureCallback<>() { @Override - public void onSuccess(@Nullable Void tmp) { - if (isConnected() && pageData.hasNext()) { - processEdgeEvents(fetcher, pageLink.nextPageLink(), result); + public void onSuccess(@Nullable Boolean isInterrupted) { + if (Boolean.TRUE.equals(isInterrupted)) { + log.debug("[{}][{}][{}] Send downlink messages task was interrupted", edge.getTenantId(), edge.getId(), sessionId); + result.set(null); } else { - UUID ifOffset = pageData.getData().get(pageData.getData().size() - 1).getUuidId(); - result.set(ifOffset); + if (isConnected() && pageData.hasNext()) { + processEdgeEvents(fetcher, pageLink.nextPageLink(), result); + } else { + UUID ifOffset = pageData.getData().get(pageData.getData().size() - 1).getUuidId(); + result.set(ifOffset); + } } } @@ -386,7 +399,7 @@ public final class EdgeGrpcSession implements Closeable { } } - private ListenableFuture sendDownlinkMsgsPack(List downlinkMsgsPack) { + private ListenableFuture sendDownlinkMsgsPack(List downlinkMsgsPack) { interruptPreviousSendDownlinkMsgsTask(); sessionState.setSendDownlinkMsgsFuture(SettableFuture.create()); @@ -408,23 +421,31 @@ public final class EdgeGrpcSession implements Closeable { } log.trace("[{}] [{}] downlink msg(s) are going to be send.", this.sessionId, copy.size()); for (DownlinkMsg downlinkMsg : copy) { - sendDownlinkMsg(ResponseMsg.newBuilder() - .setDownlinkMsg(downlinkMsg) - .build()); + if (this.clientMaxInboundMessageSize != 0 && downlinkMsg.getSerializedSize() > this.clientMaxInboundMessageSize) { + log.error("[{}][{}][{}] Downlink msg size [{}] exceeds client max inbound message size [{}]. Skipping this message. " + + "Please increase value of CLOUD_RPC_MAX_INBOUND_MESSAGE_SIZE env variable on the edge and restart it." + + "Message {}", edge.getTenantId(), edge.getId(), this.sessionId, downlinkMsg.getSerializedSize(), + this.clientMaxInboundMessageSize, downlinkMsg); + sessionState.getPendingMsgsMap().remove(downlinkMsg.getDownlinkMsgId()); + } else { + sendDownlinkMsg(ResponseMsg.newBuilder() + .setDownlinkMsg(downlinkMsg) + .build()); + } } if (attempt < MAX_DOWNLINK_ATTEMPTS) { scheduleDownlinkMsgsPackSend(attempt + 1); } else { log.warn("[{}] Failed to deliver the batch after {} attempts. Next messages are going to be discarded {}", this.sessionId, MAX_DOWNLINK_ATTEMPTS, copy); - stopCurrentSendDownlinkMsgsTask(null); + stopCurrentSendDownlinkMsgsTask(false); } } else { - stopCurrentSendDownlinkMsgsTask(null); + stopCurrentSendDownlinkMsgsTask(false); } } catch (Exception e) { log.warn("[{}] Failed to send downlink msgs. Error msg {}", this.sessionId, e.getMessage(), e); - stopCurrentSendDownlinkMsgsTask(e); + stopCurrentSendDownlinkMsgsTask(true); } }; @@ -638,7 +659,9 @@ public final class EdgeGrpcSession implements Closeable { return ConnectResponseMsg.newBuilder() .setResponseCode(ConnectResponseCode.ACCEPTED) .setErrorMsg("") - .setConfiguration(ctx.getEdgeMsgConstructor().constructEdgeConfiguration(edge)).build(); + .setConfiguration(ctx.getEdgeMsgConstructor().constructEdgeConfiguration(edge)) + .setMaxInboundMessageSize(maxInboundMessageSize) + .build(); } return ConnectResponseMsg.newBuilder() .setResponseCode(ConnectResponseCode.BAD_CREDENTIALS) @@ -670,23 +693,18 @@ public final class EdgeGrpcSession implements Closeable { } private void interruptPreviousSendDownlinkMsgsTask() { - String msg = String.format("[%s] Previous send downlink future was not properly completed, stopping it now!", this.sessionId); - stopCurrentSendDownlinkMsgsTask(new RuntimeException(msg)); + log.debug("[{}][{}][{}] Previous send downlink future was not properly completed, stopping it now!", edge.getTenantId(), edge.getId(), this.sessionId); + stopCurrentSendDownlinkMsgsTask(true); } - private void interruptGeneralProcessingOnSync(TenantId tenantId, EdgeId edgeId) { - String msg = String.format("[%s][%s] Sync process started. General processing interrupted!", tenantId, edgeId); - stopCurrentSendDownlinkMsgsTask(new RuntimeException(msg)); + private void interruptGeneralProcessingOnSync() { + log.debug("[{}][{}][{}] Sync process started. General processing interrupted!", edge.getTenantId(), edge.getId(), this.sessionId); + stopCurrentSendDownlinkMsgsTask(true); } - public void stopCurrentSendDownlinkMsgsTask(Exception e) { + public void stopCurrentSendDownlinkMsgsTask(Boolean isInterrupted) { if (sessionState.getSendDownlinkMsgsFuture() != null && !sessionState.getSendDownlinkMsgsFuture().isDone()) { - if (e != null) { - log.debug(e.getMessage()); - sessionState.getSendDownlinkMsgsFuture().setException(e); - } else { - sessionState.getSendDownlinkMsgsFuture().set(null); - } + sessionState.getSendDownlinkMsgsFuture().set(isInterrupted); } if (sessionState.getScheduledSendDownlinkTask() != null) { sessionState.getScheduledSendDownlinkTask().cancel(true); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java index 59910c829f..09a7c5add8 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java @@ -28,6 +28,6 @@ import java.util.concurrent.ScheduledFuture; public class EdgeSessionState { private final Map pendingMsgsMap = Collections.synchronizedMap(new LinkedHashMap<>()); - private SettableFuture sendDownlinkMsgsFuture; + private SettableFuture sendDownlinkMsgsFuture; private ScheduledFuture scheduledSendDownlinkTask; } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/BaseDeviceProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/BaseDeviceProcessor.java index 85eee6b2f2..742acdbcda 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/BaseDeviceProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/BaseDeviceProcessor.java @@ -16,7 +16,6 @@ package org.thingsboard.server.service.edge.rpc.processor.device; import com.datastax.oss.driver.api.core.uuid.Uuids; -import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -108,8 +107,8 @@ public abstract class BaseDeviceProcessor extends BaseEdgeProcessor { public ListenableFuture processDeviceCredentialsMsg(TenantId tenantId, DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg) { log.debug("[{}] Executing processDeviceCredentialsMsg, deviceCredentialsUpdateMsg [{}]", tenantId, deviceCredentialsUpdateMsg); DeviceId deviceId = new DeviceId(new UUID(deviceCredentialsUpdateMsg.getDeviceIdMSB(), deviceCredentialsUpdateMsg.getDeviceIdLSB())); - ListenableFuture deviceFuture = deviceService.findDeviceByIdAsync(tenantId, deviceId); - return Futures.transform(deviceFuture, device -> { + return dbCallbackExecutorService.submit(() -> { + Device device = deviceService.findDeviceById(tenantId, deviceId); if (device != null) { log.debug("Updating device credentials for device [{}]. New device credentials Id [{}], value [{}]", device.getName(), deviceCredentialsUpdateMsg.getCredentialsId(), deviceCredentialsUpdateMsg.getCredentialsValue()); @@ -129,6 +128,6 @@ public abstract class BaseDeviceProcessor extends BaseEdgeProcessor { log.warn("Can't find device by id [{}], deviceCredentialsUpdateMsg [{}]", deviceId, deviceCredentialsUpdateMsg); } return null; - }, dbCallbackExecutorService); + }); } } diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java index bd68aad316..f1183bea8b 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java @@ -508,6 +508,7 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { this.deleteSystemWidgetBundle("entity_admin_widgets"); this.deleteSystemWidgetBundle("navigation_widgets"); this.deleteSystemWidgetBundle("edge_widgets"); + this.deleteSystemWidgetBundle("home_page_widgets"); installScripts.loadSystemWidgets(); } diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java index bd840b2f52..1e54d8d48f 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java @@ -82,6 +82,9 @@ public class DefaultCacheCleanupService implements CacheCleanupService { log.info("Clearing cache to upgrade from version 3.4.2 to 3.4.3 ..."); clearCacheByName("repositorySettings"); break; + case "3.4.4": + log.info("Clearing cache to upgrade from version 3.4.4 to 3.5.0"); + clearCacheByName("deviceProfiles"); default: //Do nothing, since cache cleanup is optional. } diff --git a/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java b/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java index b4fba04212..91bc0863db 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java @@ -107,8 +107,10 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple @Override public NotificationRequest processNotificationRequest(TenantId tenantId, NotificationRequest request, Consumer callback) { - if (!rateLimitService.checkRateLimit(tenantId, LimitedApi.NOTIFICATION_REQUEST)) { - throw new TbRateLimitsException(EntityType.TENANT); + if (request.getRuleId() == null) { + if (!rateLimitService.checkRateLimit(LimitedApi.NOTIFICATION_REQUESTS, tenantId)) { + throw new TbRateLimitsException(EntityType.TENANT); + } } NotificationTemplate notificationTemplate; @@ -119,21 +121,33 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple } if (notificationTemplate == null) throw new IllegalArgumentException("Template is missing"); + Set deliveryMethods = new HashSet<>(); List targets = request.getTargets().stream().map(NotificationTargetId::new) - .map(id -> notificationTargetService.findNotificationTargetById(tenantId, id)).collect(Collectors.toList()); + .map(id -> notificationTargetService.findNotificationTargetById(tenantId, id)) + .collect(Collectors.toList()); NotificationRuleId ruleId = request.getRuleId(); notificationTemplate.getConfiguration().getDeliveryMethodsTemplates().forEach((deliveryMethod, template) -> { if (!template.isEnabled()) return; - if (!channels.get(deliveryMethod).check(tenantId)) { - throw new IllegalArgumentException("Unable to send notification via " + deliveryMethod.getName() + ": not configured or not working"); + try { + channels.get(deliveryMethod).check(tenantId); + } catch (Exception e) { + if (ruleId == null) { + throw new IllegalArgumentException(e.getMessage()); + } else { + return; // if originated by rule - just ignore delivery method + } } if (ruleId == null) { if (targets.stream().noneMatch(target -> target.getConfiguration().getType().getSupportedDeliveryMethods().contains(deliveryMethod))) { - throw new IllegalArgumentException("Target for " + deliveryMethod.getName() + " delivery method is missing"); + throw new IllegalArgumentException("Recipients for " + deliveryMethod.getName() + " delivery method not chosen"); } } + deliveryMethods.add(deliveryMethod); }); + if (deliveryMethods.isEmpty()) { + throw new IllegalArgumentException("No delivery methods to send notification with"); + } if (request.getAdditionalConfig() != null) { NotificationRequestConfig config = request.getAdditionalConfig(); @@ -153,6 +167,7 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple NotificationProcessingContext ctx = NotificationProcessingContext.builder() .tenantId(tenantId) .request(request) + .deliveryMethods(deliveryMethods) .template(notificationTemplate) .settings(settings) .build(); @@ -341,14 +356,20 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple @Override public Set getAvailableDeliveryMethods(TenantId tenantId) { return channels.values().stream() - .filter(channel -> channel.check(tenantId)) + .filter(channel -> { + try { + channel.check(tenantId); + return true; + } catch (Exception e) { + return false; + } + }) .map(NotificationChannel::getDeliveryMethod) .collect(Collectors.toSet()); } @Override - public boolean check(TenantId tenantId) { - return true; + public void check(TenantId tenantId) throws Exception { } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java b/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java index 8f9b7d048b..25ff1dd50e 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java @@ -48,20 +48,21 @@ public class NotificationProcessingContext { private final NotificationSettings settings; @Getter private final NotificationRequest request; - + @Getter + private final Set deliveryMethods; @Getter private final NotificationTemplate notificationTemplate; + private final Map templates; @Getter - private Set deliveryMethods; - @Getter private final NotificationRequestStats stats; - @Builder - public NotificationProcessingContext(TenantId tenantId, NotificationRequest request, NotificationTemplate template, NotificationSettings settings) { + public NotificationProcessingContext(TenantId tenantId, NotificationRequest request, Set deliveryMethods, + NotificationTemplate template, NotificationSettings settings) { this.tenantId = tenantId; this.request = request; + this.deliveryMethods = deliveryMethods; this.settings = settings; this.notificationTemplate = template; this.templates = new EnumMap<>(NotificationDeliveryMethod.class); @@ -77,7 +78,6 @@ public class NotificationProcessingContext { templates.put(deliveryMethod, template); } }); - deliveryMethods = templates.keySet(); } public C getDeliveryMethodConfig(NotificationDeliveryMethod deliveryMethod) { diff --git a/application/src/main/java/org/thingsboard/server/service/notification/channels/EmailNotificationChannel.java b/application/src/main/java/org/thingsboard/server/service/notification/channels/EmailNotificationChannel.java index 61e4c528dc..f5bbc57954 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/channels/EmailNotificationChannel.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/channels/EmailNotificationChannel.java @@ -48,12 +48,11 @@ public class EmailNotificationChannel implements NotificationChannel sendNotification(R recipient, T processedTemplate, NotificationProcessingContext ctx); - boolean check(TenantId tenantId); + void check(TenantId tenantId) throws Exception; NotificationDeliveryMethod getDeliveryMethod(); diff --git a/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java b/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java index 7ff48d4cb9..46afbd7270 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java @@ -22,12 +22,12 @@ import org.thingsboard.rule.engine.api.slack.SlackService; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.settings.NotificationSettings; -import org.thingsboard.server.dao.notification.NotificationSettingsService; -import org.thingsboard.server.service.notification.NotificationProcessingContext; import org.thingsboard.server.common.data.notification.settings.SlackNotificationDeliveryMethodConfig; import org.thingsboard.server.common.data.notification.targets.slack.SlackConversation; import org.thingsboard.server.common.data.notification.template.SlackDeliveryMethodNotificationTemplate; +import org.thingsboard.server.dao.notification.NotificationSettingsService; import org.thingsboard.server.service.executors.ExternalCallExecutorService; +import org.thingsboard.server.service.notification.NotificationProcessingContext; @Component @RequiredArgsConstructor @@ -47,9 +47,11 @@ public class SlackNotificationChannel implements NotificationChannel rules = notificationRuleService.findNotificationRulesByTenantIdAndTriggerType(tenantId, triggerType); - for (NotificationRule rule : rules) { - notificationExecutor.submit(() -> { - try { - processNotificationRule(rule, trigger); - } catch (Throwable e) { - log.error("Failed to process notification rule {} for trigger type {} with trigger object {}", rule.getId(), rule.getTriggerType(), trigger, e); - } - }); + try { + List rules = notificationRulesCache.get(tenantId, triggerType); + for (NotificationRule rule : rules) { + notificationExecutor.submit(() -> { + try { + processNotificationRule(rule, trigger); + } catch (Throwable e) { + log.error("Failed to process notification rule {} for trigger type {} with trigger object {}", rule.getId(), rule.getTriggerType(), trigger, e); + } + }); + } + } catch (Throwable e) { + log.error("Failed to process notification rules for trigger: {}", trigger, e); } } @@ -112,6 +122,11 @@ public class DefaultNotificationRuleProcessor implements NotificationRuleProcess } if (matchesFilter(trigger, triggerConfig)) { + if (!rateLimitService.checkRateLimit(LimitedApi.NOTIFICATION_REQUESTS_PER_RULE, rule.getTenantId(), rule.getId())) { + log.debug("[{}] Rate limit for notification requests per rule was exceeded (rule '{}')", rule.getTenantId(), rule.getName()); + return; + } + NotificationInfo notificationInfo = constructNotificationInfo(trigger, triggerConfig); rule.getRecipientsConfig().getTargetsTable().forEach((delay, targets) -> { submitNotificationRequest(targets, rule, trigger.getOriginatorEntityId(), notificationInfo, delay); @@ -169,12 +184,14 @@ public class DefaultNotificationRuleProcessor implements NotificationRuleProcess TenantId tenantId = componentLifecycleMsg.getTenantId(); NotificationRuleId notificationRuleId = (NotificationRuleId) componentLifecycleMsg.getEntityId(); - notificationExecutor.submit(() -> { - List scheduledForRule = notificationRequestService.findNotificationRequestsIdsByStatusAndRuleId(tenantId, NotificationRequestStatus.SCHEDULED, notificationRuleId); - for (NotificationRequestId notificationRequestId : scheduledForRule) { - notificationCenter.deleteNotificationRequest(tenantId, notificationRequestId); - } - }); + if (partitionService.isMyPartition(ServiceType.TB_CORE, tenantId, notificationRuleId)) { + notificationExecutor.submit(() -> { + List scheduledForRule = notificationRequestService.findNotificationRequestsIdsByStatusAndRuleId(tenantId, NotificationRequestStatus.SCHEDULED, notificationRuleId); + for (NotificationRequestId notificationRequestId : scheduledForRule) { + notificationCenter.deleteNotificationRequest(tenantId, notificationRequestId); + } + }); + } } @Autowired diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/cache/DefaultNotificationRulesCache.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/cache/DefaultNotificationRulesCache.java new file mode 100644 index 0000000000..b0cd1bd557 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/cache/DefaultNotificationRulesCache.java @@ -0,0 +1,116 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.notification.rule.cache; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.notification.rule.NotificationRule; +import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerType; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.dao.notification.NotificationRuleService; + +import javax.annotation.PostConstruct; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DefaultNotificationRulesCache implements NotificationRulesCache { + + private final NotificationRuleService notificationRuleService; + + @Value("${cache.notificationRules.maxSize:1000}") + private int cacheMaxSize; + @Value("${cache.notificationRules.timeToLiveInMinutes:30}") + private int cacheValueTtl; + private Cache> cache; + + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + + @PostConstruct + private void init() { + cache = Caffeine.newBuilder() + .maximumSize(cacheMaxSize) + .expireAfterAccess(cacheValueTtl, TimeUnit.MINUTES) + .build(); + } + + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case NOTIFICATION_RULE: + evict(event.getTenantId()); // TODO: evict by trigger type of the rule + break; + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + lock.writeLock().lock(); // locking in case rules for tenant are fetched while evicting + try { + evict(event.getTenantId()); + } finally { + lock.writeLock().unlock(); + } + } + break; + } + } + + @Override + public List get(TenantId tenantId, NotificationRuleTriggerType triggerType) { + lock.readLock().lock(); + try { + log.trace("Retrieving notification rules of type {} for tenant {} from cache", triggerType, tenantId); + return cache.get(key(tenantId, triggerType), k -> { + List rules = notificationRuleService.findNotificationRulesByTenantIdAndTriggerType(tenantId, triggerType); + log.trace("Fetched notification rules of type {} for tenant {} (count: {})", triggerType, tenantId, rules.size()); + return !rules.isEmpty() ? rules : Collections.emptyList(); + }); + } finally { + lock.readLock().unlock(); + } + } + + private void evict(TenantId tenantId) { + cache.invalidateAll(Arrays.stream(NotificationRuleTriggerType.values()) + .map(triggerType -> key(tenantId, triggerType)) + .collect(Collectors.toList())); + log.trace("Evicted all notification rules for tenant {} from cache", tenantId); + } + + private static CacheKey key(TenantId tenantId, NotificationRuleTriggerType triggerType) { + return new CacheKey(tenantId, triggerType); + } + + @Data + private static class CacheKey { + private final TenantId tenantId; + private final NotificationRuleTriggerType triggerType; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/cache/NotificationRuleCacheValue.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/cache/NotificationRulesCache.java similarity index 62% rename from dao/src/main/java/org/thingsboard/server/dao/notification/cache/NotificationRuleCacheValue.java rename to application/src/main/java/org/thingsboard/server/service/notification/rule/cache/NotificationRulesCache.java index b651ff00ea..43ea3c4cc3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/cache/NotificationRuleCacheValue.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/cache/NotificationRulesCache.java @@ -13,25 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao.notification.cache; +package org.thingsboard.server.service.notification.rule.cache; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.rule.NotificationRule; +import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerType; -import java.io.Serializable; import java.util.List; -@Data -@AllArgsConstructor -@NoArgsConstructor -@Builder -public class NotificationRuleCacheValue implements Serializable { +public interface NotificationRulesCache { - private static final long serialVersionUID = 9503216785105415L; - - private List notificationRules; + List get(TenantId tenantId, NotificationRuleTriggerType triggerType); } diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/EntitiesLimitTriggerProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/EntitiesLimitTriggerProcessor.java index da2ec7042e..95ace87653 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/EntitiesLimitTriggerProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/EntitiesLimitTriggerProcessor.java @@ -21,7 +21,10 @@ import org.thingsboard.server.common.data.notification.info.EntitiesLimitNotific import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; import org.thingsboard.server.common.data.notification.rule.trigger.EntitiesLimitNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerType; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.msg.notification.trigger.EntitiesLimitTrigger; +import org.thingsboard.server.dao.entity.EntityCountService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TenantService; import static org.apache.commons.collections.CollectionUtils.isNotEmpty; @@ -30,6 +33,8 @@ import static org.apache.commons.collections.CollectionUtils.isNotEmpty; @RequiredArgsConstructor public class EntitiesLimitTriggerProcessor implements NotificationRuleTriggerProcessor { + private final EntityCountService entityCountService; + private final TbTenantProfileCache tenantProfileCache; private final TenantService tenantService; @Override @@ -37,7 +42,15 @@ public class EntitiesLimitTriggerProcessor implements NotificationRuleTriggerPro if (isNotEmpty(triggerConfig.getEntityTypes()) && !triggerConfig.getEntityTypes().contains(trigger.getEntityType())) { return false; } - return (int) (trigger.getLimit() * triggerConfig.getThreshold()) == trigger.getCurrentCount(); // strict comparing not to send notification on each new entity + DefaultTenantProfileConfiguration profileConfiguration = tenantProfileCache.get(trigger.getTenantId()).getDefaultProfileConfiguration(); + long limit = profileConfiguration.getEntitiesLimit(trigger.getEntityType()); + if (limit <= 0) { + return false; + } + long currentCount = entityCountService.countByTenantIdAndEntityType(trigger.getTenantId(), trigger.getEntityType()); + trigger.setLimit(limit); + trigger.setCurrentCount(currentCount); + return (int) (limit * triggerConfig.getThreshold()) == currentCount; // strict comparing not to send notification on each new entity } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/NewPlatformVersionTriggerProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/NewPlatformVersionTriggerProcessor.java index 9706862ed4..cb44e7b3ef 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/NewPlatformVersionTriggerProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/NewPlatformVersionTriggerProcessor.java @@ -15,16 +15,15 @@ */ package org.thingsboard.server.service.notification.rule.trigger; -import com.fasterxml.jackson.core.type.TypeReference; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.UpdateMessage; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.info.NewPlatformVersionNotificationInfo; import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; import org.thingsboard.server.common.data.notification.rule.trigger.NewPlatformVersionNotificationRuleTriggerConfig; -import org.thingsboard.server.common.msg.notification.trigger.NewPlatformVersionTrigger; import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerType; +import org.thingsboard.server.common.msg.notification.trigger.NewPlatformVersionTrigger; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.queue.discovery.PartitionService; @@ -36,17 +35,21 @@ public class NewPlatformVersionTriggerProcessor implements NotificationRuleTrigg @Override public boolean matchesFilter(NewPlatformVersionTrigger trigger, NewPlatformVersionNotificationRuleTriggerConfig triggerConfig) { - // todo: don't send repetitive notification after platform restart? - if (!partitionService.resolve(ServiceType.TB_CORE, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID).isMyPartition()) { + if (!partitionService.isMyPartition(ServiceType.TB_CORE, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID)) { return false; } - return trigger.getMessage().isUpdateAvailable(); + return trigger.getUpdateInfo().isUpdateAvailable(); } @Override public RuleOriginatedNotificationInfo constructNotificationInfo(NewPlatformVersionTrigger trigger) { + UpdateMessage updateInfo = trigger.getUpdateInfo(); return NewPlatformVersionNotificationInfo.builder() - .message(JacksonUtil.convertValue(trigger.getMessage(), new TypeReference<>() {})) + .latestVersion(updateInfo.getLatestVersion()) + .latestVersionReleaseNotesUrl(updateInfo.getLatestVersionReleaseNotesUrl()) + .upgradeInstructionsUrl(updateInfo.getUpgradeInstructionsUrl()) + .currentVersion(updateInfo.getCurrentVersion()) + .currentVersionReleaseNotesUrl(updateInfo.getCurrentVersionReleaseNotesUrl()) .build(); } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java index 78ea88b32d..4d4d2414ff 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java @@ -414,7 +414,8 @@ public class DefaultTbClusterService implements TbClusterService { || entityType.equals(EntityType.API_USAGE_STATE) || (entityType.equals(EntityType.DEVICE) && msg.getEvent() == ComponentLifecycleEvent.UPDATED) || entityType.equals(EntityType.ENTITY_VIEW) - || entityType.equals(EntityType.EDGE)) { + || entityType.equals(EntityType.EDGE) + || entityType.equals(EntityType.NOTIFICATION_RULE)) { TbQueueProducer> toCoreNfProducer = producerProvider.getTbCoreNotificationsMsgProducer(); Set tbCoreServices = partitionService.getAllServiceIds(ServiceType.TB_CORE); for (String serviceId : tbCoreServices) { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index ad2a5baebe..6a6612c581 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -35,12 +35,12 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.rpc.RpcError; import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.queue.notification.NotificationRuleProcessor; import org.thingsboard.server.common.msg.notification.trigger.NotificationRuleTrigger; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; import org.thingsboard.server.common.stats.StatsFactory; -import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.DeviceStateServiceMsgProto; @@ -274,9 +274,6 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService notificationRuleTrigger = encodingService.decode(toCoreMsg.getNotificationRuleProcessorMsg().getTrigger().toByteArray()); - notificationRuleTrigger.ifPresent(notificationRuleProcessor::process); } } catch (Throwable e) { log.warn("[{}] Failed to process message: {}", id, msg, e); @@ -361,6 +358,11 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService notificationRuleTrigger = encodingService.decode(toCoreNotification + .getNotificationRuleProcessorMsg().getTrigger().toByteArray()); + notificationRuleTrigger.ifPresent(notificationRuleProcessor::process); + callback.onSuccess(); } if (statsEnabled) { stats.log(toCoreNotification); diff --git a/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java b/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java index 1dae050f58..17af86dbf6 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java +++ b/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java @@ -67,6 +67,7 @@ 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.entityview.EntityViewService; +import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.resource.ResourceService; @@ -263,8 +264,7 @@ public class AccessValidator { if (currentUser.isSystemAdmin()) { callback.onSuccess(ValidationResult.accessDenied(SYSTEM_ADMINISTRATOR_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION)); } else { - ListenableFuture deviceFuture = deviceService.findDeviceByIdAsync(currentUser.getTenantId(), new DeviceId(entityId.getId())); - Futures.addCallback(deviceFuture, getCallback(callback, device -> { + Futures.addCallback(Futures.immediateFuture(deviceService.findDeviceById(currentUser.getTenantId(), new DeviceId(entityId.getId()))), getCallback(callback, device -> { if (device == null) { return ValidationResult.entityNotFound(DEVICE_WITH_REQUESTED_ID_NOT_FOUND); } else { @@ -575,7 +575,7 @@ public class AccessValidator { ResponseEntity responseEntity; if (e instanceof ToErrorResponseEntity) { responseEntity = ((ToErrorResponseEntity) e).toErrorResponseEntity(); - } else if (e instanceof IllegalArgumentException || e instanceof IncorrectParameterException) { + } else if (e instanceof IllegalArgumentException || e instanceof IncorrectParameterException || e instanceof DataValidationException) { responseEntity = new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); } else { responseEntity = new ResponseEntity<>(defaultErrorStatus); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java index bc80a22e1b..1eafe66c8f 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java @@ -20,7 +20,6 @@ import ua_parser.Client; import ua_parser.Parser; import javax.servlet.http.HttpServletRequest; -import java.io.IOException; import java.io.Serializable; @Data @@ -43,11 +42,7 @@ public class RestAuthenticationDetails implements Serializable { } private static Client getUserAgent(HttpServletRequest request) { - try { - Parser uaParser = new Parser(); - return uaParser.parse(request.getHeader("User-Agent")); - } catch (IOException e) { - return new Client(null, null, null); - } + Parser uaParser = new Parser(); + return uaParser.parse(request.getHeader("User-Agent")); } } diff --git a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java index 60c159360b..005cc6ac4c 100644 --- a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java @@ -26,6 +26,7 @@ import com.google.common.util.concurrent.MoreExecutors; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; @@ -33,6 +34,7 @@ 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.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceIdInfo; @@ -61,6 +63,7 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.sql.query.EntityQueryRepository; @@ -85,6 +88,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Random; import java.util.Set; import java.util.UUID; @@ -151,6 +155,7 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService> devicesActivity = new HashMap<>(); partitionedEntities.forEach((tpi, deviceIds) -> { log.debug("Calculating state updates. tpi {} for {} devices", tpi.getFullTopicName(), deviceIds.size()); for (DeviceId deviceId : deviceIds) { + DeviceStateData stateData; try { - updateInactivityStateIfExpired(ts, deviceId); + stateData = getOrFetchDeviceStateData(deviceId); + } catch (Exception e) { + log.error("[{}] Failed to get or fetch device state data", deviceId, e); + continue; + } + try { + updateInactivityStateIfExpired(ts, deviceId, stateData); } catch (Exception e) { log.warn("[{}] Failed to update inactivity state", deviceId, e); } + Pair tenantDevicesActivity = devicesActivity.computeIfAbsent(stateData.getTenantId(), + tenantId -> Pair.of(new AtomicInteger(), new AtomicInteger())); + if (stateData.getState().isActive()) { + tenantDevicesActivity.getLeft().incrementAndGet(); + } else { + tenantDevicesActivity.getRight().incrementAndGet(); + } + } + }); + devicesActivity.forEach((tenantId, tenantDevicesActivity) -> { + int active = tenantDevicesActivity.getLeft().get(); + int inactive = tenantDevicesActivity.getRight().get(); + apiUsageReportClient.report(tenantId, null, ApiUsageRecordKey.ACTIVE_DEVICES, active); + apiUsageReportClient.report(tenantId, null, ApiUsageRecordKey.INACTIVE_DEVICES, inactive); + if (active > 0) { + log.info("[{}] Active devices: {}, inactive devices: {}", tenantId, active, inactive); } }); } catch (Throwable t) { - log.warn("Failed to update inactivity states", t); + log.warn("Failed to check devices states", t); } } - void updateInactivityStateIfExpired(long ts, DeviceId deviceId) { - DeviceStateData stateData = getOrFetchDeviceStateData(deviceId); - updateInactivityStateIfExpired(ts, deviceId, stateData); - } - void updateInactivityStateIfExpired(long ts, DeviceId deviceId, DeviceStateData stateData) { log.trace("Processing state {} for device {}", stateData, deviceId); if (stateData != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java index b646cb7ef6..793c25d625 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java @@ -73,7 +73,7 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS @Override public , I extends EntityId> EntityExportData exportEntity(EntitiesExportCtx ctx, I entityId) throws ThingsboardException { - if (!rateLimitService.checkRateLimit(ctx.getTenantId(), LimitedApi.ENTITY_EXPORT)) { + if (!rateLimitService.checkRateLimit(LimitedApi.ENTITY_EXPORT, ctx.getTenantId())) { throw new ThingsboardException("Rate limit for entities export is exceeded", ThingsboardErrorCode.TOO_MANY_REQUESTS); } @@ -85,7 +85,7 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS @Override public , I extends EntityId> EntityImportResult importEntity(EntitiesImportCtx ctx, EntityExportData exportData) throws ThingsboardException { - if (!rateLimitService.checkRateLimit(ctx.getTenantId(), LimitedApi.ENTITY_IMPORT)) { + if (!rateLimitService.checkRateLimit(LimitedApi.ENTITY_IMPORT, ctx.getTenantId())) { throw new ThingsboardException("Rate limit for entities import is exceeded", ThingsboardErrorCode.TOO_MANY_REQUESTS); } if (exportData.getEntity() == null || exportData.getEntity().getId() == null) { diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java index b34ba3ef66..45805c5939 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java @@ -113,8 +113,9 @@ public abstract class AbstractBulkImportService importedEntityInfo = saveEntity(entityData.getFields(), user); E entity = importedEntityInfo.getEntity(); - saveKvs(user, entity, entityData.getKvs()); - + if (request.getMapping().getUpdate() || !importedEntityInfo.isUpdated()) { + saveKvs(user, entity, entityData.getKvs()); + } return importedEntityInfo; }, importedEntityInfo -> { diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java index f72ac8a11f..610f1ee0b5 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java @@ -23,7 +23,6 @@ import lombok.extern.slf4j.Slf4j; import org.checkerframework.checker.nullness.qual.Nullable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; -import org.springframework.transaction.annotation.Transactional; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.EntityType; @@ -87,7 +86,6 @@ public abstract class BaseEntityImportService importEntity(EntitiesImportCtx ctx, D exportData) throws ThingsboardException { EntityImportResult importResult = new EntityImportResult<>(); @@ -336,27 +334,37 @@ public abstract class BaseEntityImportService externalIdOpt = buildEntityId(entityType, externalUuid); - if (!externalIdOpt.isPresent()) { + Optional externalId = buildEntityId(entityType, externalUuid); + if (externalId.isEmpty()) { continue; } - EntityId internalId = ctx.getInternalId(externalIdOpt.get()); + EntityId internalId = ctx.getInternalId(externalId.get()); if (internalId != null) { return Optional.of(internalId); } } if (fetchAllUUIDs) { - for (EntityType entityType : hints) { - Optional internalId = lookupInDb(externalUuid, entityType); - if (internalId.isPresent()) return internalId; - } + Set processLast = Set.of(EntityType.TENANT); + List entityTypes = new ArrayList<>(hints); for (EntityType entityType : EntityType.values()) { - if (hints.contains(entityType)) { + if (!hints.contains(entityType) && !processLast.contains(entityType)) { + entityTypes.add(entityType); + } + } + entityTypes.addAll(processLast); + + for (EntityType entityType : entityTypes) { + Optional externalId = buildEntityId(entityType, externalUuid); + if (externalId.isEmpty() || ctx.isNotFound(externalId.get())) { continue; } - Optional internalId = lookupInDb(externalUuid, entityType); - if (internalId.isPresent()) return internalId; + EntityId internalId = getInternalId(externalId.get(), false); + if (internalId != null) { + return Optional.of(internalId); + } else { + ctx.registerNotFound(externalId.get()); + } } } @@ -364,20 +372,6 @@ public abstract class BaseEntityImportService lookupInDb(UUID externalUuid, EntityType entityType) { - Optional externalIdOpt = buildEntityId(entityType, externalUuid); - if (externalIdOpt.isEmpty() || ctx.isNotFound(externalIdOpt.get())) { - return Optional.empty(); - } - EntityId internalId = getInternalId(externalIdOpt.get(), false); - if (internalId != null) { - return Optional.of(internalId); - } else { - ctx.registerNotFound(externalIdOpt.get()); - } - return Optional.empty(); - } - private Optional buildEntityId(EntityType entityType, UUID externalUuid) { try { return Optional.of(EntityIdFactory.getByTypeAndUuid(entityType, externalUuid)); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DashboardImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DashboardImportService.java index 2da996953f..250026c71c 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DashboardImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DashboardImportService.java @@ -22,9 +22,6 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ShortCustomerInfo; -import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.edge.EdgeEventActionType; -import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.TenantId; @@ -68,10 +65,10 @@ public class DashboardImportService extends BaseEntityImportService exportData, IdProvider idProvider) { for (JsonNode entityAlias : dashboard.getEntityAliasesConfig()) { - replaceIdsRecursively(ctx, idProvider, entityAlias, Collections.emptySet(), HINTS); + replaceIdsRecursively(ctx, idProvider, entityAlias, Set.of("id"), HINTS); } for (JsonNode widgetConfig : dashboard.getWidgetsConfig()) { - replaceIdsRecursively(ctx, idProvider, JacksonUtil.getSafely(widgetConfig, "config", "actions"), Collections.singleton("id"), HINTS); + replaceIdsRecursively(ctx, idProvider, JacksonUtil.getSafely(widgetConfig, "config", "actions"), Set.of("id"), HINTS); } return dashboard; } diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java index 8ffd4796fe..0e08ee9c48 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java @@ -44,7 +44,6 @@ import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.common.data.util.ThrowingRunnable; import org.thingsboard.server.common.data.sync.ie.EntityExportData; import org.thingsboard.server.common.data.sync.ie.EntityExportSettings; import org.thingsboard.server.common.data.sync.ie.EntityImportResult; @@ -69,6 +68,7 @@ import org.thingsboard.server.common.data.sync.vc.request.load.EntityTypeVersion import org.thingsboard.server.common.data.sync.vc.request.load.SingleEntityVersionLoadRequest; import org.thingsboard.server.common.data.sync.vc.request.load.VersionLoadConfig; import org.thingsboard.server.common.data.sync.vc.request.load.VersionLoadRequest; +import org.thingsboard.server.common.data.util.ThrowingRunnable; import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; @@ -259,7 +259,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont return gitServiceQueue.listEntitiesAtVersion(tenantId, versionId); } - @SuppressWarnings({"UnstableApiUsage", "rawtypes"}) + @SuppressWarnings({"rawtypes"}) @Override public UUID loadEntitiesVersion(User user, VersionLoadRequest request) throws Exception { EntitiesImportCtx ctx = new EntitiesImportCtx(UUID.randomUUID(), user, request.getVersionId()); @@ -402,7 +402,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont ctx.getImportedEntities().computeIfAbsent(entityType, t -> new HashSet<>()) .add(importResult.getSavedEntity().getId()); } - log.debug("Imported {} pack for tenant {}", entityType, ctx.getTenantId()); + log.debug("Imported {} pack ({}) for tenant {}", entityType, entityDataList.size(), ctx.getTenantId()); offset += limit; } while (entityDataList.size() == limit); } diff --git a/application/src/main/java/org/thingsboard/server/service/system/DefaultSystemInfoService.java b/application/src/main/java/org/thingsboard/server/service/system/DefaultSystemInfoService.java index caf3abefa0..80d4cede03 100644 --- a/application/src/main/java/org/thingsboard/server/service/system/DefaultSystemInfoService.java +++ b/application/src/main/java/org/thingsboard/server/service/system/DefaultSystemInfoService.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.service.system; -import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.FutureCallback; import com.google.protobuf.ProtocolStringList; import lombok.RequiredArgsConstructor; @@ -23,6 +22,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.FeaturesInfo; @@ -30,13 +31,13 @@ import org.thingsboard.server.common.data.SystemInfo; import org.thingsboard.server.common.data.SystemInfoData; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.stats.TbApiUsageStateClient; import org.thingsboard.server.dao.oauth2.OAuth2Service; -import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.gen.transport.TransportProtos.ServiceInfo; import org.thingsboard.server.queue.discovery.DiscoveryService; @@ -50,16 +51,17 @@ import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import javax.annotation.Nullable; import javax.annotation.PreDestroy; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import static org.thingsboard.common.util.SystemUtil.getCpuCount; import static org.thingsboard.common.util.SystemUtil.getCpuUsage; -import static org.thingsboard.common.util.SystemUtil.getMemoryUsage; import static org.thingsboard.common.util.SystemUtil.getDiscSpaceUsage; -import static org.thingsboard.common.util.SystemUtil.getCpuCount; +import static org.thingsboard.common.util.SystemUtil.getMemoryUsage; import static org.thingsboard.common.util.SystemUtil.getTotalDiscSpace; import static org.thingsboard.common.util.SystemUtil.getTotalMemory; @@ -87,6 +89,8 @@ public class DefaultSystemInfoService extends TbApplicationEventListener 0; } } return false; } + private boolean isSlackEnabled() { + AdminSettings notifications = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "notifications"); + if (notifications != null) { + return notifications.getJsonValue().get("deliveryMethodsConfigs").has("SLACK"); + } + return false; + } + private void saveCurrentClusterSystemInfo() { long ts = System.currentTimeMillis(); List clusterSystemData = getSystemData(serviceInfoProvider.getServiceInfo()); BasicTsKvEntry clusterDataKv = new BasicTsKvEntry(ts, new JsonDataEntry("clusterSystemData", JacksonUtil.toString(clusterSystemData))); - doSave(Collections.singletonList(clusterDataKv)); + doSave(Arrays.asList(new BasicTsKvEntry(ts, new BooleanDataEntry("clusterMode", true)), clusterDataKv)); } private void saveCurrentMonolithSystemInfo() { long ts = System.currentTimeMillis(); List tsList = new ArrayList<>(); - + tsList.add(new BasicTsKvEntry(ts, new BooleanDataEntry("clusterMode", false))); getCpuUsage().ifPresent(v -> tsList.add(new BasicTsKvEntry(ts, new LongDataEntry("cpuUsage", (long) v)))); getMemoryUsage().ifPresent(v -> tsList.add(new BasicTsKvEntry(ts, new LongDataEntry("memoryUsage", (long) v)))); getDiscSpaceUsage().ifPresent(v -> tsList.add(new BasicTsKvEntry(ts, new LongDataEntry("discUsage", (long) v)))); diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java index 07243711f3..2bb93d3dad 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java @@ -51,7 +51,7 @@ import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.dao.alarm.AlarmOperationResult; import org.thingsboard.server.dao.alarm.AlarmService; -import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; +import org.thingsboard.server.queue.notification.NotificationRuleProcessor; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; import org.thingsboard.server.service.entitiy.alarm.TbAlarmCommentService; import org.thingsboard.server.service.subscription.TbSubscriptionUtils; diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index ffa6d7bf48..b5bb4f054f 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -416,6 +416,7 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer } private void addVoidCallback(ListenableFuture saveFuture, final FutureCallback callback) { + if (callback == null) return; Futures.addCallback(saveFuture, new FutureCallback() { @Override public void onSuccess(@Nullable S result) { diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index 0c0ff3e0c9..57aa1e735e 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.OtaPackage; @@ -66,11 +67,13 @@ 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.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceProvisionService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.device.provision.ProvisionFailedException; import org.thingsboard.server.dao.device.provision.ProvisionRequest; import org.thingsboard.server.dao.device.provision.ProvisionResponse; +import org.thingsboard.server.dao.device.provision.ProvisionResponseStatus; import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.relation.RelationService; @@ -108,6 +111,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Pattern; import java.util.stream.Collectors; import static org.thingsboard.server.service.transport.BasicCredentialsValidationResult.PASSWORD_MISMATCH; @@ -124,10 +128,13 @@ public class DefaultTransportApiService implements TransportApiService { private static final ObjectMapper mapper = new ObjectMapper(); + private static final Pattern X509_CERTIFICATE_TRIM_CHAIN_PATTERN = Pattern.compile("-----BEGIN CERTIFICATE-----\\s*.*?\\s*-----END CERTIFICATE-----"); + private final TbDeviceProfileCache deviceProfileCache; private final TbTenantProfileCache tenantProfileCache; private final TbApiUsageStateService apiUsageStateService; private final DeviceService deviceService; + private final DeviceProfileService deviceProfileService; private final RelationService relationService; private final DeviceCredentialsService deviceCredentialsService; private final DbCallbackExecutorService dbCallbackExecutorService; @@ -159,6 +166,9 @@ public class DefaultTransportApiService implements TransportApiService { } else if (transportApiRequestMsg.hasValidateX509CertRequestMsg()) { ValidateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateX509CertRequestMsg(); result = validateCredentials(msg.getHash(), DeviceCredentialsType.X509_CERTIFICATE); + } else if (transportApiRequestMsg.hasValidateOrCreateX509CertRequestMsg()) { + TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateOrCreateX509CertRequestMsg(); + result = validateOrCreateDeviceX509Certificate(msg.getCertificateChain()); } else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) { result = handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()); } else if (transportApiRequestMsg.hasEntityProfileRequestMsg()) { @@ -226,6 +236,35 @@ public class DefaultTransportApiService implements TransportApiService { } } + protected ListenableFuture validateOrCreateDeviceX509Certificate(String certificateChain) { + List chain = X509_CERTIFICATE_TRIM_CHAIN_PATTERN.matcher(certificateChain).results().map(match -> + EncryptionUtil.certTrimNewLines(match.group())).collect(Collectors.toList()); + for (String certificateValue : chain) { + String certificateHash = EncryptionUtil.getSha3Hash(certificateValue); + DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(certificateHash); + if (credentials != null && DeviceCredentialsType.X509_CERTIFICATE.equals(credentials.getCredentialsType())) { + return getDeviceInfo(credentials); + } + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByProvisionDeviceKey(certificateHash); + if (deviceProfile != null && DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN.equals(deviceProfile.getProvisionType())) { + String updatedDeviceProvisionSecret = chain.get(0); + ProvisionRequest provisionRequest = createProvisionRequest(updatedDeviceProvisionSecret); + try { + ProvisionResponse provisionResponse = deviceProvisionService.provisionDeviceViaX509Chain(deviceProfile, provisionRequest); + if (ProvisionResponseStatus.SUCCESS.equals(provisionResponse.getResponseStatus())) { + return getDeviceInfo(provisionResponse.getDeviceCredentials()); + } + } catch (ProvisionFailedException e) { + log.debug("[{}][{}] Failed to provision device with cert chain: {}", deviceProfile.getTenantId(), deviceProfile.getId(), provisionRequest, e); + return getEmptyTransportApiResponseFuture(); + } + } else if (deviceProfile != null) { + log.warn("[{}][{}] Device Profile provision configuration mismatched: expected {}, actual {}", deviceProfile.getTenantId(), deviceProfile.getId(), DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN, deviceProfile.getProvisionType()); + } + } + return getEmptyTransportApiResponseFuture(); + } + private ListenableFuture validateUserNameCredentials(TransportProtos.ValidateBasicMqttCredRequestMsg mqtt) { DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(mqtt.getUserName()); if (credentials != null) { @@ -665,4 +704,11 @@ public class DefaultTransportApiService implements TransportApiService { private Long checkLong(Long l) { return l != null ? l : 0; } + + private ProvisionRequest createProvisionRequest(String certificateValue) { + return new ProvisionRequest(null, DeviceCredentialsType.X509_CERTIFICATE, + new ProvisionDeviceCredentialsData(null, null, null, null, certificateValue), + null); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/EdgeEventsCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/EdgeEventsCleanUpService.java index 77d59fe069..941416669a 100644 --- a/application/src/main/java/org/thingsboard/server/service/ttl/EdgeEventsCleanUpService.java +++ b/application/src/main/java/org/thingsboard/server/service/ttl/EdgeEventsCleanUpService.java @@ -32,7 +32,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.EDGE_EVENT_COLUMN_ @TbCoreComponent @Slf4j @Service -@ConditionalOnExpression("${sql.ttl.edge_events.enabled:true} && ${sql.ttl.edge_events.edge_event_ttl:0} > 0") +@ConditionalOnExpression("${sql.ttl.edge_events.enabled:true} && ${sql.ttl.edge_events.edge_events_ttl:0} > 0") public class EdgeEventsCleanUpService extends AbstractCleanUpService { public static final String RANDOM_DELAY_INTERVAL_MS_EXPRESSION = diff --git a/application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java b/application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java index c6a80efc53..612c1d6610 100644 --- a/application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java +++ b/application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java @@ -15,22 +15,24 @@ */ package org.thingsboard.server.service.update; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.info.BuildProperties; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.UpdateMessage; import org.thingsboard.server.common.msg.notification.trigger.NewPlatformVersionTrigger; -import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; +import org.thingsboard.server.queue.notification.NotificationRuleProcessor; +import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.queue.util.TbCoreComponent; -import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.io.IOException; import java.nio.file.Files; @@ -65,8 +67,8 @@ public class DefaultUpdateService implements UpdateService { private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1, ThingsBoardThreadFactory.forName("tb-update-service")); - private ScheduledFuture checkUpdatesFuture = null; - private RestTemplate restClient = new RestTemplate(); + private ScheduledFuture checkUpdatesFuture = null; + private final RestTemplate restClient = new RestTemplate(); private UpdateMessage updateMessage; @@ -74,10 +76,12 @@ public class DefaultUpdateService implements UpdateService { private String version; private UUID instanceId = null; - @PostConstruct - private void init() { + @AfterStartUp(order = AfterStartUp.REGULAR_SERVICE) + public void init() { version = buildProperties != null ? buildProperties.getVersion() : "unknown"; - updateMessage = new UpdateMessage(false, version, "", "", "", ""); + updateMessage = new UpdateMessage(false, version, "", "", + "https://thingsboard.io/docs/reference/releases", + "https://thingsboard.io/docs/reference/releases"); if (updatesEnabled) { try { platform = System.getProperty("platform", "unknown"); @@ -94,7 +98,7 @@ public class DefaultUpdateService implements UpdateService { Path instanceIdPath = Paths.get(INSTANCE_ID_FILE); if (instanceIdPath.toFile().exists()) { byte[] data = Files.readAllBytes(instanceIdPath); - if (data != null && data.length > 0) { + if (data.length > 0) { try { result = UUID.fromString(new String(data)); } catch (IllegalArgumentException e) { @@ -124,15 +128,17 @@ public class DefaultUpdateService implements UpdateService { Runnable checkUpdatesRunnable = () -> { try { log.trace("Executing check update method for instanceId [{}], platform [{}] and version [{}]", instanceId, platform, version); + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); ObjectNode request = new ObjectMapper().createObjectNode(); request.put(PLATFORM_PARAM, platform); request.put(VERSION_PARAM, version); request.put(INSTANCE_ID_PARAM, instanceId.toString()); UpdateMessage prevUpdateMessage = updateMessage; - updateMessage = restClient.postForObject(UPDATE_SERVER_BASE_URL + "/api/v2/thingsboard/updates", request, UpdateMessage.class); - if (updateMessage.isUpdateAvailable() && !updateMessage.equals(prevUpdateMessage)) { + updateMessage = restClient.postForObject(UPDATE_SERVER_BASE_URL + "/api/v2/thingsboard/updates", new HttpEntity<>(request.toString(), headers), UpdateMessage.class); + if (updateMessage != null && updateMessage.isUpdateAvailable() && !updateMessage.equals(prevUpdateMessage)) { notificationRuleProcessor.process(NewPlatformVersionTrigger.builder() - .message(updateMessage) + .updateInfo(updateMessage) .build()); } } catch (Exception e) { diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index f745e03ffc..1bc8c19a82 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -65,6 +65,7 @@ server: max_per_user: "${TB_SERVER_WS_DYNAMIC_PAGE_LINK_MAX_PER_USER:10}" max_entities_per_data_subscription: "${TB_SERVER_WS_MAX_ENTITIES_PER_DATA_SUBSCRIPTION:10000}" max_entities_per_alarm_subscription: "${TB_SERVER_WS_MAX_ENTITIES_PER_ALARM_SUBSCRIPTION:10000}" + max_queue_messages_per_session: "${TB_SERVER_WS_DEFAULT_QUEUE_MESSAGES_PER_SESSION:1000}" rest: server_side_rpc: # Minimum value of the server side RPC timeout. May override value provided in the REST API call. @@ -130,6 +131,9 @@ security: loginProcessingUrl: "${SECURITY_OAUTH2_LOGIN_PROCESSING_URL:/login/oauth2/code/}" githubMapper: emailUrl: "${SECURITY_OAUTH2_GITHUB_MAPPER_EMAIL_URL_KEY:https://api.github.com/user/emails}" + java_cacerts: + path: "${SECURITY_JAVA_CACERTS_PATH:${java.home}${file.separator}lib${file.separator}security${file.separator}cacerts}" + password: "${SECURITY_JAVA_CACERTS_PASSWORD:changeit}" # Usage statistics parameters usage: @@ -140,6 +144,7 @@ usage: interval: "${USAGE_STATS_REPORT_INTERVAL:10}" check: cycle: "${USAGE_STATS_CHECK_CYCLE:60000}" + gauge_report_interval: "${USAGE_STATS_GAUGE_REPORT_INTERVAL:180000}" # UI parameters ui: @@ -437,9 +442,6 @@ cache: assetProfiles: timeToLiveInMinutes: "${CACHE_SPECS_ASSET_PROFILES_TTL:1440}" maxSize: "${CACHE_SPECS_ASSET_PROFILES_MAX_SIZE:10000}" - notificationRules: - timeToLiveInMinutes: "${CACHE_SPECS_NOTIFICATION_RULES_TTL:1440}" - maxSize: "${CACHE_SPECS_NOTIFICATION_RULES_MAX_SIZE:10000}" notificationSettings: timeToLiveInMinutes: "${CACHE_SPECS_NOTIFICATION_SETTINGS_TTL:10}" maxSize: "${CACHE_SPECS_NOTIFICATION_SETTINGS_MAX_SIZE:1000}" @@ -481,6 +483,14 @@ cache: timeToLiveInMinutes: "${CACHE_SPECS_ENTITY_COUNT_TTL:1440}" maxSize: "${CACHE_SPECS_ENTITY_COUNT_MAX_SIZE:100000}" + # deliberately placed outside 'specs' group above + notificationRules: + timeToLiveInMinutes: "${CACHE_SPECS_NOTIFICATION_RULES_TTL:30}" + maxSize: "${CACHE_SPECS_NOTIFICATION_RULES_MAX_SIZE:1000}" + rateLimits: + timeToLiveInMinutes: "${CACHE_SPECS_RATE_LIMITS_TTL:60}" + maxSize: "${CACHE_SPECS_RATE_LIMITS_MAX_SIZE:100000}" + #Disable this because it is not required. spring.data.redis.repositories.enabled: false @@ -946,7 +956,9 @@ edges: enabled: "${EDGES_ENABLED:true}" rpc: port: "${EDGES_RPC_PORT:7070}" - client_max_keep_alive_time_sec: "${EDGES_RPC_CLIENT_MAX_KEEP_ALIVE_TIME_SEC:300}" + client_max_keep_alive_time_sec: "${EDGES_RPC_CLIENT_MAX_KEEP_ALIVE_TIME_SEC:1}" + keep_alive_time_sec: "${EDGES_RPC_KEEP_ALIVE_TIME_SEC:10}" + keep_alive_timeout_sec: "${EDGES_RPC_KEEP_ALIVE_TIMEOUT_SEC:5}" ssl: # Enable/disable SSL support enabled: "${EDGES_RPC_SSL_ENABLED:false}" @@ -960,7 +972,6 @@ edges: scheduler_pool_size: "${EDGES_SCHEDULER_POOL_SIZE:1}" send_scheduler_pool_size: "${EDGES_SEND_SCHEDULER_POOL_SIZE:1}" grpc_callback_thread_pool_size: "${EDGES_GRPC_CALLBACK_POOL_SIZE:1}" - edge_events_ttl: "${EDGES_EDGE_EVENTS_TTL:0}" state: persistToTelemetry: "${EDGES_PERSIST_STATE_TO_TELEMETRY:false}" @@ -987,6 +998,13 @@ queue: print-interval-ms: "${TB_QUEUE_IN_MEMORY_STATS_PRINT_INTERVAL_MS:60000}" kafka: bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + ssl: + enabled: "${TB_KAFKA_SSL_ENABLED:false}" + truststore.location: "${TB_KAFKA_SSL_TRUSTSTORE_LOCATION:}" + truststore.password: "${TB_KAFKA_SSL_TRUSTSTORE_PASSWORD:}" + keystore.location: "${TB_KAFKA_SSL_KEYSTORE_LOCATION:}" + keystore.password: "${TB_KAFKA_SSL_KEYSTORE_PASSWORD:}" + key.password: "${TB_KAFKA_SSL_KEY_PASSWORD:}" acks: "${TB_KAFKA_ACKS:all}" retries: "${TB_KAFKA_RETRIES:1}" compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip @@ -1000,6 +1018,8 @@ queue: max_poll_records: "${TB_QUEUE_KAFKA_MAX_POLL_RECORDS:8192}" max_partition_fetch_bytes: "${TB_QUEUE_KAFKA_MAX_PARTITION_FETCH_BYTES:16777216}" fetch_max_bytes: "${TB_QUEUE_KAFKA_FETCH_MAX_BYTES:134217728}" + request.timeout.ms: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + session.timeout.ms: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms use_confluent_cloud: "${TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD:false}" confluent: ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" @@ -1018,11 +1038,12 @@ queue: # tb_rule_engine.sq: # - key: max.poll.records # value: "${TB_QUEUE_KAFKA_SQ_MAX_POLL_RECORDS:1024}" - other: # In this section you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside - - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms - value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) - - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms - value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) + other-inline: "${TB_QUEUE_KAFKA_OTHER_PROPERTIES:}" # In this section you can specify custom parameters (semicolon separated) for Kafka consumer/producer/admin # Example "metrics.recording.level:INFO;metrics.sample.window.ms:30000" + other: # DEPRECATED. In this section you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside + # - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + # value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) + # - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms + # value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) topic-properties: rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" core: "${TB_QUEUE_KAFKA_CORE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index 8f58f01225..f49e6671c9 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -30,10 +30,8 @@ import org.awaitility.Awaitility; import org.hamcrest.Matcher; import org.hibernate.exception.ConstraintViolationException; import org.junit.After; -import org.junit.AfterClass; import org.junit.Assert; import org.junit.Before; -import org.junit.BeforeClass; import org.junit.Rule; import org.junit.rules.TestRule; import org.junit.rules.TestWatcher; @@ -78,6 +76,9 @@ import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration; +import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; +import org.thingsboard.server.common.data.device.data.DeviceData; import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.device.profile.DeviceProfileData; @@ -533,6 +534,17 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return assetProfile; } + protected Device createDevice(String name, String accessToken) throws Exception { + Device device = new Device(); + device.setName(name); + device.setType("default"); + DeviceData deviceData = new DeviceData(); + deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); + deviceData.setConfiguration(new DefaultDeviceConfiguration()); + device.setDeviceData(deviceData); + return doPost("/api/device?accessToken=" + accessToken, device, Device.class); + } + protected MqttDeviceProfileTransportConfiguration createMqttDeviceProfileTransportConfiguration(TransportPayloadTypeConfiguration transportPayloadTypeConfiguration, boolean sendAckOnValidationException) { MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = new MqttDeviceProfileTransportConfiguration(); mqttDeviceProfileTransportConfiguration.setDeviceTelemetryTopic(MqttTopics.DEVICE_TELEMETRY_TOPIC); @@ -897,6 +909,12 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return (T) field.get(target); } + protected void setStaticFieldValue(Class targetCls, String fieldName, Object value) throws Exception { + Field field = targetCls.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(null, value); + } + protected int getDeviceActorSubscriptionCount(DeviceId deviceId, FeatureType featureType) { DeviceActorMessageProcessor processor = getDeviceActorProcessor(deviceId); Map subscriptions = (Map) ReflectionTestUtils.getField(processor, getMapName(featureType)); diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java index 9b05b38a22..6ffc216dd3 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java @@ -17,6 +17,7 @@ package org.thingsboard.server.controller; import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; @@ -33,11 +34,13 @@ import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; import org.springframework.test.context.ContextConfiguration; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.OtaPackageInfo; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.SaveOtaPackageInfoRequest; @@ -68,6 +71,8 @@ import org.thingsboard.server.service.gateway_device.GatewayNotificationsService import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; @@ -1392,6 +1397,73 @@ public abstract class BaseDeviceControllerTest extends AbstractControllerTest { Assert.assertEquals(savedCredentials, updatedCredentials); } + @Test + public void testBulkImportDeviceWithUpdateFalse() throws Exception { + String deviceName = "firstDevice"; + String attributeValue = "testValue"; + BulkImportRequest request = new BulkImportRequest(); + request.setFile(String.format("NAME,TYPE,DATA\n%s,%s,%s", deviceName, "thermostat", attributeValue)); + BulkImportRequest.Mapping mapping = new BulkImportRequest.Mapping(); + BulkImportRequest.ColumnMapping name = new BulkImportRequest.ColumnMapping(); + name.setType(BulkImportColumnType.NAME); + BulkImportRequest.ColumnMapping type = new BulkImportRequest.ColumnMapping(); + type.setType(BulkImportColumnType.TYPE); + BulkImportRequest.ColumnMapping attribute = new BulkImportRequest.ColumnMapping(); + attribute.setType(BulkImportColumnType.SERVER_ATTRIBUTE); + attribute.setKey("DATA"); + List columns = new ArrayList<>(); + columns.add(name); + columns.add(type); + columns.add(attribute); + + mapping.setColumns(columns); + mapping.setDelimiter(','); + mapping.setUpdate(true); + mapping.setHeader(true); + request.setMapping(mapping); + + //import device + doPostWithTypedResponse("/api/device/bulk_import", request, new TypeReference<>() {}); + Device savedDevice = doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class); + + //check server attribute value + List> values = doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + savedDevice.getId() + + "/values/attributes/SERVER_SCOPE", new TypeReference<>() { + }); + Map serverAttribute = values.stream().filter(att -> att.get("key").equals("DATA")).findFirst().get(); + Assert.assertEquals(attributeValue, serverAttribute.get("value")); + + //update server attribute value + String newAttributeValue = "testValue2"; + JsonNode content = JacksonUtil.toJsonNode("{\"DATA\": \"" + newAttributeValue + "\"}"); + doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + savedDevice.getUuidId() + "/SERVER_SCOPE", content) + .andExpect(status().isOk()); + + //reimport devices + String deviceName2 = "secondDevice"; + String attributeValue2 = "testValue3"; + + request.setFile(String.format("NAME,TYPE,DATA\n%s,%s,%s\n%s,%s,%s", deviceName, "thermostat", attributeValue, + deviceName2, "thermostat", attributeValue2)); + mapping.setUpdate(false); + doPostWithTypedResponse("/api/device/bulk_import", request, new TypeReference<>() {}); + Device savedDevice2 = doGet("/api/tenant/devices?deviceName=" + deviceName2, Device.class); + + //check attribute value was not changed after reimport + List> values2 = doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + savedDevice.getId() + + "/values/attributes/SERVER_SCOPE", new TypeReference<>() { + }); + Map retrievedServerAttribute2 = values2.stream().filter(att -> att.get("key").equals("DATA")).findFirst().get(); + Assert.assertEquals(newAttributeValue, retrievedServerAttribute2.get("value")); + + //check attribute for second device + List> values3 = doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + savedDevice2.getId() + + "/values/attributes/SERVER_SCOPE", new TypeReference<>() { + }); + Map retrievedServerAttribute3 = values3.stream().filter(att -> att.get("key").equals("DATA")).findFirst().get(); + Assert.assertEquals(attributeValue2, retrievedServerAttribute3.get("value")); + } + private Device createDevice(String name) { Device device = new Device(); device.setName(name); diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java index 0df044cb26..fb4f9bd322 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java @@ -299,6 +299,28 @@ public abstract class BaseDeviceProfileControllerTest extends AbstractController tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); } + @Test + public void testSaveDeviceProfileWithSameCertificateHash() throws Exception { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + deviceProfile.setProvisionDeviceKey("Certificate hash"); + + doPost("/api/deviceProfile", deviceProfile) + .andExpect(status().isOk()); + + DeviceProfile deviceProfile2 = this.createDeviceProfile("Device Profile 2"); + deviceProfile2.setProvisionDeviceKey("Certificate hash"); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = "Device profile with such provision device key already exists!"; + doPost("/api/deviceProfile", deviceProfile2) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + @Test public void testChangeDeviceProfileTypeNull() throws Exception { DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityQueryControllerTest.java index 53960ed220..f5d8303a80 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityQueryControllerTest.java @@ -107,7 +107,7 @@ public abstract class BaseEntityQueryControllerTest extends AbstractControllerTe Thread.sleep(1); } DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter(""); EntityCountQuery countQuery = new EntityCountQuery(filter); @@ -115,11 +115,11 @@ public abstract class BaseEntityQueryControllerTest extends AbstractControllerTe Long count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); Assert.assertEquals(97, count.longValue()); - filter.setDeviceType("unknown"); + filter.setDeviceTypes(List.of("unknown")); count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); Assert.assertEquals(0, count.longValue()); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter("Device1"); count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); @@ -341,7 +341,7 @@ public abstract class BaseEntityQueryControllerTest extends AbstractControllerTe } DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter(""); EntityDataSortOrder sortOrder = new EntityDataSortOrder( @@ -442,7 +442,7 @@ public abstract class BaseEntityQueryControllerTest extends AbstractControllerTe Thread.sleep(1000); DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter(""); EntityDataSortOrder sortOrder = new EntityDataSortOrder( @@ -521,7 +521,7 @@ public abstract class BaseEntityQueryControllerTest extends AbstractControllerTe DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter(""); KeyFilter highTemperatureFilter = new KeyFilter(); @@ -576,7 +576,7 @@ public abstract class BaseEntityQueryControllerTest extends AbstractControllerTe @Test public void givenInvalidEntityDataPageLink_thenReturnError() throws Exception { DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter(""); String invalidSortProperty = "created(Time)"; diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index d29f572758..75283b63f1 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -585,7 +585,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes @Test public void testGetTelemetryWhenEntityViewTimeRangeInsideTimestampRange() throws Exception { - DeviceTypeFilter dtf = new DeviceTypeFilter(testDevice.getType(), testDevice.getName()); + DeviceTypeFilter dtf = new DeviceTypeFilter(List.of(testDevice.getType()), testDevice.getName()); List tsKeys = List.of("tsKey1", "tsKey2", "tsKey3"); DeviceCredentials deviceCredentials = doGet("/api/device/" + testDevice.getId().getId() + "/credentials", DeviceCredentials.class); @@ -684,7 +684,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes } private Set putAttributesAndWait(String stringKV, Set expectedKeySet) throws Exception { - DeviceTypeFilter dtf = new DeviceTypeFilter(testDevice.getType(), testDevice.getName()); + DeviceTypeFilter dtf = new DeviceTypeFilter(List.of(testDevice.getType()), testDevice.getName()); List keysToSubscribe = expectedKeySet.stream() .map(key -> new EntityKey(EntityKeyType.CLIENT_ATTRIBUTE, key)) .collect(Collectors.toList()); diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseHomePageApiTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseHomePageApiTest.java index 6dec1f0313..b71be1c3b6 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseHomePageApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseHomePageApiTest.java @@ -21,8 +21,12 @@ import com.google.common.collect.Lists; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; import org.junit.Test; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.Customer; @@ -53,6 +57,7 @@ import org.thingsboard.server.common.data.query.TsValue; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.stats.TbApiUsageStateClient; +import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityCountCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityCountUpdate; @@ -76,6 +81,15 @@ public abstract class BaseHomePageApiTest extends AbstractControllerTest { @Autowired private TbTenantProfileCache tenantProfileCache; + @Autowired + private AdminSettingsService adminSettingsService; + + @MockBean + private MailService mailService; + + @MockBean + private SmsService smsService; + //For system administrator @Test public void testTenantsCountWsCmd() throws Exception { @@ -266,6 +280,19 @@ public abstract class BaseHomePageApiTest extends AbstractControllerTest { @Test public void testGetFeaturesInfo() throws Exception { + String mail = "test@thingsboard.org"; + Mockito.doAnswer(invocation -> { + AdminSettings mailSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail"); + JsonNode mailFrom = mailSettings.getJsonValue().get("mailFrom"); + if (!mailFrom.asText().equals(mail)) { + throw new Exception(); + } + return null; + }).when(mailService).testConnection(TenantId.SYS_TENANT_ID); + + Mockito.when(smsService.isConfigured(TenantId.SYS_TENANT_ID)) + .then(a -> adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "sms") != null); + loginSysAdmin(); FeaturesInfo featuresInfo = doGet("/api/admin/featuresInfo", FeaturesInfo.class); @@ -279,7 +306,7 @@ public abstract class BaseHomePageApiTest extends AbstractControllerTest { AdminSettings mailSettings = doGet("/api/admin/settings/mail", AdminSettings.class); JsonNode jsonValue = mailSettings.getJsonValue(); - ((ObjectNode) jsonValue).put("mailFrom", "test@thingsboard.org"); + ((ObjectNode) jsonValue).put("mailFrom", mail); mailSettings.setJsonValue(jsonValue); doPost("/api/admin/settings", mailSettings).andExpect(status().isOk()); @@ -305,7 +332,12 @@ public abstract class BaseHomePageApiTest extends AbstractControllerTest { AdminSettings twoFaSettingsSettings = new AdminSettings(); twoFaSettingsSettings.setKey("twoFaSettings"); - twoFaSettingsSettings.setJsonValue(JacksonUtil.newObjectNode()); + + var twoFaSettings = JacksonUtil.newObjectNode(); + var providers = JacksonUtil.newArrayNode(); + providers.add(JacksonUtil.newObjectNode()); + twoFaSettings.set("providers", providers); + twoFaSettingsSettings.setJsonValue(twoFaSettings); doPost("/api/admin/settings", twoFaSettingsSettings).andExpect(status().isOk()); featuresInfo = doGet("/api/admin/featuresInfo", FeaturesInfo.class); @@ -317,7 +349,13 @@ public abstract class BaseHomePageApiTest extends AbstractControllerTest { AdminSettings notificationsSettings = new AdminSettings(); notificationsSettings.setKey("notifications"); - notificationsSettings.setJsonValue(JacksonUtil.newObjectNode()); + + var notificationSettings = JacksonUtil.newObjectNode(); + var deliveryMethodsConfigs = JacksonUtil.newObjectNode(); + deliveryMethodsConfigs.set("SLACK", JacksonUtil.newObjectNode()); + notificationSettings.set("deliveryMethodsConfigs", deliveryMethodsConfigs); + + notificationsSettings.setJsonValue(notificationSettings); doPost("/api/admin/settings", notificationsSettings).andExpect(status().isOk()); featuresInfo = doGet("/api/admin/featuresInfo", FeaturesInfo.class); @@ -338,6 +376,10 @@ public abstract class BaseHomePageApiTest extends AbstractControllerTest { Assert.assertTrue(featuresInfo.isTwoFaEnabled()); Assert.assertTrue(featuresInfo.isNotificationEnabled()); Assert.assertTrue(featuresInfo.isOauthEnabled()); + + adminSettingsService.deleteAdminSettingsByTenantIdAndKey(TenantId.SYS_TENANT_ID, "notifications"); + adminSettingsService.deleteAdminSettingsByTenantIdAndKey(TenantId.SYS_TENANT_ID, "twoFaSettings"); + adminSettingsService.deleteAdminSettingsByTenantIdAndKey(TenantId.SYS_TENANT_ID, "sms"); } @Test diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseTelemetryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseTelemetryControllerTest.java new file mode 100644 index 0000000000..50ad731a79 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseTelemetryControllerTest.java @@ -0,0 +1,56 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 org.junit.Test; +import org.stringtemplate.v4.ST; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public abstract class BaseTelemetryControllerTest extends AbstractControllerTest { + + @Test + public void testConstraintValidator() throws Exception { + loginTenantAdmin(); + Device device = createDevice(); + String correctRequestBody = "{\"data\": \"value\"}"; + doPostAsync("/api/plugins/telemetry/" + device.getId() + "/SHARED_SCOPE", correctRequestBody, String.class, status().isOk()); + doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", correctRequestBody, String.class, status().isOk()); + String invalidRequestBody = "{\"alert(document)\\\">\": \"data\"}"; + doPostAsync("/api/plugins/telemetry/" + device.getId() + "/SHARED_SCOPE", invalidRequestBody, String.class, status().isBadRequest()); + doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", invalidRequestBody, String.class, status().isBadRequest()); + } + + private Device createDevice() throws Exception { + String testToken = "TEST_TOKEN"; + + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + + DeviceCredentials deviceCredentials = new DeviceCredentials(); + deviceCredentials.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + deviceCredentials.setCredentialsId(testToken); + + SaveDeviceWithCredentialsRequest saveRequest = new SaveDeviceWithCredentialsRequest(device, deviceCredentials); + + return readResponse(doPost("/api/device-with-credentials", saveRequest).andExpect(status().isOk()), Device.class); + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseWebsocketApiTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseWebsocketApiTest.java index a452cbdce4..6de1bc5b35 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseWebsocketApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseWebsocketApiTest.java @@ -16,6 +16,8 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FutureCallback; import lombok.extern.slf4j.Slf4j; import org.checkerframework.checker.nullness.qual.Nullable; @@ -87,7 +89,7 @@ public abstract class BaseWebsocketApiTest extends AbstractControllerTest { device.setType("default"); device.setLabel("testLabel" + (int) (Math.random() * 1000)); device = doPost("/api/device", device, Device.class); - dtf = new DeviceTypeFilter(device.getType(), device.getName()); + dtf = new DeviceTypeFilter(List.of(device.getType()), device.getName()); } @After @@ -195,7 +197,7 @@ public abstract class BaseWebsocketApiTest extends AbstractControllerTest { Assert.assertEquals(1, update1.getCmdId()); Assert.assertEquals(1, update1.getCount()); - DeviceTypeFilter dtf2 = new DeviceTypeFilter("non-existing-device-type", "D"); + DeviceTypeFilter dtf2 = new DeviceTypeFilter(List.of("non-existing-device-type"), "D"); EntityCountQuery edq2 = new EntityCountQuery(dtf2, Collections.emptyList()); EntityCountCmd cmd2 = new EntityCountCmd(2, edq2); @@ -214,7 +216,7 @@ public abstract class BaseWebsocketApiTest extends AbstractControllerTest { highTemperatureFilter.setPredicate(predicate); highTemperatureFilter.setValueType(EntityKeyValueType.NUMERIC); - DeviceTypeFilter dtf3 = new DeviceTypeFilter("default", "D"); + DeviceTypeFilter dtf3 = new DeviceTypeFilter(List.of("default"), "D"); EntityCountQuery edq3 = new EntityCountQuery(dtf3, Collections.singletonList(highTemperatureFilter)); EntityCountCmd cmd3 = new EntityCountCmd(3, edq3); getWsClient().send(cmd3); @@ -231,7 +233,7 @@ public abstract class BaseWebsocketApiTest extends AbstractControllerTest { highTemperatureFilter2.setPredicate(predicate2); highTemperatureFilter2.setValueType(EntityKeyValueType.NUMERIC); - DeviceTypeFilter dtf4 = new DeviceTypeFilter("default", "D"); + DeviceTypeFilter dtf4 = new DeviceTypeFilter(List.of("default"), "D"); EntityCountQuery edq4 = new EntityCountQuery(dtf4, Collections.singletonList(highTemperatureFilter2)); EntityCountCmd cmd4 = new EntityCountCmd(4, edq4); @@ -637,6 +639,34 @@ public abstract class BaseWebsocketApiTest extends AbstractControllerTest { assertThat(update.get("data").get("attr").get(0).get(1).asText()).isEqualTo(expectedAttrValue); } + @Test + public void testEntityCountCmd_filterTypeSingularCompatibilityTest() { + ObjectNode oldFormatDeviceTypeFilterSingular = JacksonUtil.OBJECT_MAPPER.createObjectNode(); + oldFormatDeviceTypeFilterSingular.put("type", "deviceType"); + oldFormatDeviceTypeFilterSingular.put("deviceType", "default"); + oldFormatDeviceTypeFilterSingular.put("deviceNameFilter", "Device"); + + ObjectNode query = JacksonUtil.OBJECT_MAPPER.createObjectNode(); + query.set("entityFilter", oldFormatDeviceTypeFilterSingular); + + ObjectNode entityCountCmd = JacksonUtil.OBJECT_MAPPER.createObjectNode(); + entityCountCmd.put("cmdId", 1); + entityCountCmd.set("query", query); + + ArrayNode entityCountCmds = JacksonUtil.OBJECT_MAPPER.createArrayNode(); + entityCountCmds.add(entityCountCmd); + + ObjectNode wrapperNode = JacksonUtil.OBJECT_MAPPER.createObjectNode(); + wrapperNode.set("entityCountCmds", entityCountCmds); + + getWsClient().send(JacksonUtil.toString(wrapperNode)); + + EntityCountUpdate update = getWsClient().parseCountReply(getWsClient().waitForReply()); + Assert.assertEquals(1, update.getCmdId()); + Assert.assertEquals(1, update.getCount()); + + } + private void sendTelemetry(Device device, List tsData) throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); tsService.saveAndNotify(device.getTenantId(), null, device.getId(), tsData, 0, new FutureCallback() { diff --git a/application/src/test/java/org/thingsboard/server/controller/sql/TelemetryControllerSqlTest.java b/application/src/test/java/org/thingsboard/server/controller/sql/TelemetryControllerSqlTest.java new file mode 100644 index 0000000000..b256f4fe1e --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/sql/TelemetryControllerSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.sql; + +import org.thingsboard.server.controller.BaseTelemetryControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class TelemetryControllerSqlTest extends BaseTelemetryControllerTest { +} diff --git a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java index 26dd49b90e..8f05e6810f 100644 --- a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java +++ b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java @@ -104,13 +104,15 @@ public class EdgeImitator { ignoredTypes = new ArrayList<>(); this.routingKey = routingKey; this.routingSecret = routingSecret; - setEdgeCredentials("rpcHost", host); - setEdgeCredentials("rpcPort", port); - setEdgeCredentials("timeoutSecs", 3); - setEdgeCredentials("keepAliveTimeSec", 300); + updateEdgeClientFields("rpcHost", host); + updateEdgeClientFields("rpcPort", port); + updateEdgeClientFields("timeoutSecs", 3); + updateEdgeClientFields("keepAliveTimeSec", 300); + updateEdgeClientFields("keepAliveTimeoutSec", 5); + updateEdgeClientFields("maxInboundMessageSize", 4194304); } - private void setEdgeCredentials(String fieldName, Object value) throws NoSuchFieldException, IllegalAccessException { + private void updateEdgeClientFields(String fieldName, Object value) throws NoSuchFieldException, IllegalAccessException { Field fieldToSet = edgeRpcClient.getClass().getDeclaredField(fieldName); fieldToSet.setAccessible(true); fieldToSet.set(edgeRpcClient, value); diff --git a/application/src/test/java/org/thingsboard/server/service/device/provision/DeviceProvisionServiceTest.java b/application/src/test/java/org/thingsboard/server/service/device/provision/DeviceProvisionServiceTest.java new file mode 100644 index 0000000000..388058eef4 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/device/provision/DeviceProvisionServiceTest.java @@ -0,0 +1,266 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.device.provision; + + +import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.Assertions; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.device.credentials.ProvisionDeviceCredentialsData; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.msg.EncryptionUtil; +import org.thingsboard.server.common.transport.util.SslUtil; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.audit.AuditLogService; +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.device.provision.ProvisionFailedException; +import org.thingsboard.server.dao.device.provision.ProvisionRequest; +import org.thingsboard.server.dao.device.provision.ProvisionResponse; +import org.thingsboard.server.dao.device.provision.ProvisionResponseStatus; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.service.device.DeviceProvisionServiceImpl;;import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Slf4j +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = DeviceProvisionServiceImpl.class) +public class DeviceProvisionServiceTest { + + @MockBean + protected TbQueueProducerProvider producerProvider; + @MockBean + protected TbQueueProducer> ruleEngineMsgProducer; + @MockBean + protected TbClusterService clusterService; + @MockBean + protected DeviceProfileService deviceProfileService; + @MockBean + protected DeviceService deviceService; + @MockBean + protected DeviceCredentialsService deviceCredentialsService; + @MockBean + protected AttributesService attributesService; + @MockBean + protected AuditLogService auditLogService; + @MockBean + protected PartitionService partitionService; + @SpyBean + DeviceProvisionServiceImpl service; + + private String[] chain; + + @Before + public void setUp() { + String filePath = "src/test/resources/provision/x509ChainProvisionTest.pem"; + try { + String certificateChain = Files.readString(Paths.get(filePath)); + certificateChain = certTrimNewLinesForChainInDeviceProfile(certificateChain); + chain = fetchLeafCertificateFromChain(certificateChain); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + + @Test + public void provisionDeviceViaX509Certificate() { + var tenant = createTenant(); + var deviceProfile = createDeviceProfile(tenant.getId(), chain[1], true); + + var device = createDevice(tenant.getId(), deviceProfile.getId()); + when(deviceService.findDeviceByTenantIdAndName(any(), any())).thenReturn(device); + + var deviceCredentials = createDeviceCredentials(chain[0], device.getId()); + when(deviceCredentialsService.findDeviceCredentialsByDeviceId(any(), any())).thenReturn(deviceCredentials); + when(deviceCredentialsService.updateDeviceCredentials(any(), any())).thenReturn(deviceCredentials); + + ProvisionResponse response = service.provisionDeviceViaX509Chain(deviceProfile, createProvisionRequest(chain[0])); + + verify(deviceService, times(1)).findDeviceByTenantIdAndName(any(), any()); + verify(deviceCredentialsService, times(1)).findDeviceCredentialsByDeviceId(any(), any()); + verify(deviceCredentialsService, times(1)).updateDeviceCredentials(any(), any()); + + Assertions.assertThat(response.getResponseStatus()).isEqualTo(ProvisionResponseStatus.SUCCESS); + Assertions.assertThat(response.getDeviceCredentials()).isEqualTo(deviceCredentials); + } + + @Test + public void provisionDeviceWithIncorrectConfiguration() { + var tenant = createTenant(); + var deviceProfile = createDeviceProfile(tenant.getId(), chain[1], false); + + Assertions.assertThatThrownBy(() -> + service.provisionDeviceViaX509Chain(deviceProfile, createProvisionRequest(chain[0]))) + .isInstanceOf(ProvisionFailedException.class); + + verify(deviceService, times(1)).findDeviceByTenantIdAndName(any(), any()); + } + + @Test + public void matchDeviceNameFromX509CNCertificateByRegex() { + var tenant = createTenant(); + var deviceProfile = createDeviceProfile(tenant.getId(), chain[1], true); + X509CertificateChainProvisionConfiguration configuration = (X509CertificateChainProvisionConfiguration) deviceProfile.getProfileData().getProvisionConfiguration(); + String CN = getCNFromX509Certificate(chain[0]); + String deviceName = service.extractDeviceNameFromCNByRegEx(deviceProfile, CN, configuration.getCertificateRegExPattern()); + + Assertions.assertThat(deviceName).isNotBlank(); + Assertions.assertThat(deviceName).isEqualTo("deviceCertificate"); + } + + @Test + public void matchDeviceNameFromCNByRegex() { + var CN = "DeviceA.company.com"; + var regex = "(.*)\\.company.com"; + var result = service.extractDeviceNameFromCNByRegEx(null, CN, regex); + Assertions.assertThat(result).isNotBlank(); + Assertions.assertThat(result).isEqualTo("DeviceA"); + + CN = "DeviceA@company.com"; + regex = "(.*)@company.com"; + result = service.extractDeviceNameFromCNByRegEx(null, CN, regex); + Assertions.assertThat(result).isNotBlank(); + Assertions.assertThat(result).isEqualTo("DeviceA"); + + CN = "prefixDeviceAsuffix@company.com"; + regex = "prefix(.*)suffix@company.com"; + result = service.extractDeviceNameFromCNByRegEx(null, CN, regex); + Assertions.assertThat(result).isNotBlank(); + Assertions.assertThat(result).isEqualTo("DeviceA"); + + CN = "prefixDeviceAsufix@company.com"; + regex = "prefix(.*)sufix@company.com"; + result = service.extractDeviceNameFromCNByRegEx(null, CN, regex); + Assertions.assertThat(result).isNotBlank(); + Assertions.assertThat(result).isEqualTo("DeviceA"); + + CN = "region.DeviceA.220423@company.com"; + regex = "\\D+\\.(.*)\\.\\d+@company.com"; + result = service.extractDeviceNameFromCNByRegEx(null, CN, regex); + Assertions.assertThat(result).isNotBlank(); + Assertions.assertThat(result).isEqualTo("DeviceA"); + } + + private DeviceProfile createDeviceProfile(TenantId tenantId, String certificateValue, boolean isAllowToCreateNewDevices) { + X509CertificateChainProvisionConfiguration provision = new X509CertificateChainProvisionConfiguration(); + provision.setProvisionDeviceSecret(certificateValue); + provision.setCertificateRegExPattern("([^@]+)"); + provision.setAllowCreateNewDevicesByX509Certificate(isAllowToCreateNewDevices); + + DeviceProfileData deviceProfileData = new DeviceProfileData(); + deviceProfileData.setProvisionConfiguration(provision); + + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setId(new DeviceProfileId(UUID.randomUUID())); + deviceProfile.setProfileData(deviceProfileData); + deviceProfile.setProvisionDeviceKey(EncryptionUtil.getSha3Hash(certificateValue)); + deviceProfile.setProvisionType(DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN); + deviceProfile.setTenantId(tenantId); + return deviceProfile; + } + + private Device createDevice(TenantId tenantId, DeviceProfileId deviceProfileId) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setId(new DeviceId(UUID.randomUUID())); + device.setDeviceProfileId(deviceProfileId); + device.setCustomerId(new CustomerId(UUID.randomUUID())); + return device; + } + + private Tenant createTenant() { + Tenant tenant = new Tenant(); + tenant.setId(new TenantId(UUID.randomUUID())); + return tenant; + } + + private DeviceCredentials createDeviceCredentials(String certificateValue, DeviceId deviceId) { + DeviceCredentials deviceCredentials = new DeviceCredentials(); + deviceCredentials.setDeviceId(deviceId); + deviceCredentials.setCredentialsValue(certificateValue); + deviceCredentials.setCredentialsId(EncryptionUtil.getSha3Hash(certificateValue)); + deviceCredentials.setCredentialsType(DeviceCredentialsType.X509_CERTIFICATE); + return deviceCredentials; + } + + private ProvisionRequest createProvisionRequest(String certificateValue) { + return new ProvisionRequest(null, DeviceCredentialsType.X509_CERTIFICATE, + new ProvisionDeviceCredentialsData(null, null, null, null, certificateValue), + null); + } + + public static String certTrimNewLinesForChainInDeviceProfile(String input) { + return input.replaceAll("\n", "") + .replaceAll("\r", "") + .replaceAll("-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----\n") + .replaceAll("-----END CERTIFICATE-----", "\n-----END CERTIFICATE-----\n") + .trim(); + } + + private String[] fetchLeafCertificateFromChain(String value) { + List chain = new ArrayList<>(); + String regex = "-----BEGIN CERTIFICATE-----\\s*.*?\\s*-----END CERTIFICATE-----"; + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(value); + while (matcher.find()) { + chain.add(matcher.group(0)); + } + return chain.toArray(new String[0]); + } + + private String getCNFromX509Certificate(String x509Value) { + try { + return SslUtil.parseCommonName(SslUtil.readCertFile(x509Value)); + } catch (Exception e) { + return null; + } + } +} diff --git a/application/src/test/java/org/thingsboard/server/service/notification/MockNotificationSettingsService.java b/application/src/test/java/org/thingsboard/server/service/notification/MockNotificationSettingsService.java new file mode 100644 index 0000000000..a1cc82fd5a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/notification/MockNotificationSettingsService.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.notification; + +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.notification.DefaultNotificationSettingsService; +import org.thingsboard.server.dao.settings.AdminSettingsService; + +@Service +@Primary +public class MockNotificationSettingsService extends DefaultNotificationSettingsService { + + public MockNotificationSettingsService(AdminSettingsService adminSettingsService) { + super(adminSettingsService, null, null); + } + + @Override + public void createDefaultNotificationConfigs(TenantId tenantId) { + // do nothing + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java index 9dd34296ef..f6a296ab7e 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java @@ -40,6 +40,7 @@ import org.thingsboard.server.common.data.notification.targets.platform.Customer import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; import org.thingsboard.server.common.data.notification.targets.platform.UserListFilter; import org.thingsboard.server.common.data.notification.targets.slack.SlackConversation; +import org.thingsboard.server.common.data.notification.targets.slack.SlackConversationType; import org.thingsboard.server.common.data.notification.targets.slack.SlackNotificationTargetConfig; import org.thingsboard.server.common.data.notification.template.DeliveryMethodNotificationTemplate; import org.thingsboard.server.common.data.notification.template.EmailDeliveryMethodNotificationTemplate; @@ -49,7 +50,6 @@ import org.thingsboard.server.common.data.notification.template.SlackDeliveryMet import org.thingsboard.server.common.data.notification.template.SmsDeliveryMethodNotificationTemplate; import org.thingsboard.server.common.data.notification.template.WebDeliveryMethodNotificationTemplate; import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.notification.NotificationDao; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.service.executors.DbCallbackExecutorService; @@ -226,13 +226,13 @@ public class NotificationApiTest extends AbstractNotificationApiTest { NotificationRequest notificationRequest = submitNotificationRequest(notificationTarget.getId(), notificationText, 5); assertThat(notificationRequest.getStatus()).isEqualTo(NotificationRequestStatus.SCHEDULED); await().atLeast(4, TimeUnit.SECONDS) - .atMost(6, TimeUnit.SECONDS) + .atMost(15, TimeUnit.SECONDS) .until(() -> wsClient.getLastMsg() != null); Notification delayedNotification = wsClient.getLastDataUpdate().getUpdate(); assertThat(delayedNotification).extracting(Notification::getText).isEqualTo(notificationText); assertThat(delayedNotification.getCreatedTime() - notificationRequest.getCreatedTime()) - .isCloseTo(TimeUnit.SECONDS.toMillis(5), Offset.offset(500L)); + .isCloseTo(TimeUnit.SECONDS.toMillis(5), Offset.offset(10000L)); assertThat(findNotificationRequest(notificationRequest.getId()).getStatus()).isEqualTo(NotificationRequestStatus.SENT); } @@ -324,16 +324,17 @@ public class NotificationApiTest extends AbstractNotificationApiTest { @Test public void testNotificationRequestPreview() throws Exception { - NotificationTarget target1 = new NotificationTarget(); - target1.setName("Me"); - PlatformUsersNotificationTargetConfig target1Config = new PlatformUsersNotificationTargetConfig(); + NotificationTarget tenantAdminTarget = new NotificationTarget(); + tenantAdminTarget.setName("Me"); + PlatformUsersNotificationTargetConfig tenantAdminTargetConfig = new PlatformUsersNotificationTargetConfig(); UserListFilter userListFilter = new UserListFilter(); - userListFilter.setUsersIds(DaoUtil.toUUIDs(List.of(tenantAdminUserId))); - target1Config.setUsersFilter(userListFilter); - target1.setConfiguration(target1Config); - target1 = saveNotificationTarget(target1); + userListFilter.setUsersIds(List.of(tenantAdminUserId.getId())); + tenantAdminTargetConfig.setUsersFilter(userListFilter); + tenantAdminTarget.setConfiguration(tenantAdminTargetConfig); + tenantAdminTarget = saveNotificationTarget(tenantAdminTarget); List recipients = new ArrayList<>(); recipients.add(TENANT_ADMIN_EMAIL); + String firstRecipientEmail = TENANT_ADMIN_EMAIL; createDifferentCustomer(); loginTenantAdmin(); @@ -347,21 +348,32 @@ public class NotificationApiTest extends AbstractNotificationApiTest { customerUser = createUser(customerUser, "12345678"); recipients.add(customerUser.getEmail()); } - NotificationTarget target2 = new NotificationTarget(); - target2.setName("Other customer users"); - PlatformUsersNotificationTargetConfig target2Config = new PlatformUsersNotificationTargetConfig(); + NotificationTarget customerUsersTarget = new NotificationTarget(); + customerUsersTarget.setName("Other customer users"); + PlatformUsersNotificationTargetConfig customerUsersTargetConfig = new PlatformUsersNotificationTargetConfig(); CustomerUsersFilter customerUsersFilter = new CustomerUsersFilter(); customerUsersFilter.setCustomerId(differentCustomerId.getId()); - target2Config.setUsersFilter(customerUsersFilter); - target2.setConfiguration(target2Config); - target2 = saveNotificationTarget(target2); - + customerUsersTargetConfig.setUsersFilter(customerUsersFilter); + customerUsersTarget.setConfiguration(customerUsersTargetConfig); + customerUsersTarget = saveNotificationTarget(customerUsersTarget); + + NotificationTarget slackTarget = new NotificationTarget(); + slackTarget.setName("Slack user"); + SlackNotificationTargetConfig slackTargetConfig = new SlackNotificationTargetConfig(); + slackTargetConfig.setConversationType(SlackConversationType.DIRECT); + SlackConversation slackConversation = new SlackConversation(); + slackConversation.setId("U1234567"); + slackConversation.setTitle("@jdoe (John Doe)"); + slackConversation.setWholeName("John Doe"); + slackTargetConfig.setConversation(slackConversation); + slackTarget.setConfiguration(slackTargetConfig); + slackTarget = saveNotificationTarget(slackTarget); + recipients.add(slackConversation.getTitle()); NotificationTemplate notificationTemplate = new NotificationTemplate(); notificationTemplate.setNotificationType(NotificationType.GENERAL); notificationTemplate.setName("Test template"); - String requestorEmail = TENANT_ADMIN_EMAIL; NotificationTemplateConfig templateConfig = new NotificationTemplateConfig(); HashMap templates = new HashMap<>(); templateConfig.setDeliveryMethodsTemplates(templates); @@ -369,69 +381,59 @@ public class NotificationApiTest extends AbstractNotificationApiTest { WebDeliveryMethodNotificationTemplate webNotificationTemplate = new WebDeliveryMethodNotificationTemplate(); webNotificationTemplate.setEnabled(true); - webNotificationTemplate.setBody("Message for WEB: ${recipientEmail} ${unknownParam}"); - webNotificationTemplate.setSubject("Subject for WEB: ${recipientEmail}"); + webNotificationTemplate.setSubject("WEB SUBJECT: ${recipientEmail}"); + webNotificationTemplate.setBody("WEB: ${recipientEmail} ${unknownParam}"); templates.put(NotificationDeliveryMethod.WEB, webNotificationTemplate); SmsDeliveryMethodNotificationTemplate smsNotificationTemplate = new SmsDeliveryMethodNotificationTemplate(); smsNotificationTemplate.setEnabled(true); - smsNotificationTemplate.setBody("Message for SMS: ${recipientEmail}"); + smsNotificationTemplate.setBody("SMS: ${recipientEmail}"); templates.put(NotificationDeliveryMethod.SMS, smsNotificationTemplate); EmailDeliveryMethodNotificationTemplate emailNotificationTemplate = new EmailDeliveryMethodNotificationTemplate(); emailNotificationTemplate.setEnabled(true); - emailNotificationTemplate.setSubject("Subject for EMAIL: ${recipientEmail}"); - emailNotificationTemplate.setBody("Message for EMAIL: ${recipientEmail}"); + emailNotificationTemplate.setSubject("EMAIL SUBJECT: ${recipientEmail}"); + emailNotificationTemplate.setBody("EMAIL: ${recipientEmail}"); templates.put(NotificationDeliveryMethod.EMAIL, emailNotificationTemplate); SlackDeliveryMethodNotificationTemplate slackNotificationTemplate = new SlackDeliveryMethodNotificationTemplate(); slackNotificationTemplate.setEnabled(true); - slackNotificationTemplate.setBody("Message for SLACK: ${recipientEmail}"); + slackNotificationTemplate.setBody("SLACK: ${recipientFirstName} ${recipientLastName}"); templates.put(NotificationDeliveryMethod.SLACK, slackNotificationTemplate); notificationTemplate = saveNotificationTemplate(notificationTemplate); - NotificationRequest notificationRequest = new NotificationRequest(); - notificationRequest.setTargets(List.of(target1.getUuidId(), target2.getUuidId())); + notificationRequest.setTargets(List.of(tenantAdminTarget.getUuidId(), customerUsersTarget.getUuidId(), slackTarget.getUuidId())); notificationRequest.setTemplateId(notificationTemplate.getId()); notificationRequest.setAdditionalConfig(new NotificationRequestConfig()); NotificationRequestPreview preview = doPost("/api/notification/request/preview", notificationRequest, NotificationRequestPreview.class); - assertThat(preview.getRecipientsCountByTarget().get(target1.getName())).isEqualTo(1); - assertThat(preview.getRecipientsCountByTarget().get(target2.getName())).isEqualTo(customerUsersCount); - assertThat(preview.getTotalRecipientsCount()).isEqualTo(1 + customerUsersCount); + assertThat(preview.getRecipientsCountByTarget().get(tenantAdminTarget.getName())).isEqualTo(1); + assertThat(preview.getRecipientsCountByTarget().get(customerUsersTarget.getName())).isEqualTo(customerUsersCount); + assertThat(preview.getRecipientsCountByTarget().get(slackTarget.getName())).isEqualTo(1); + + assertThat(preview.getTotalRecipientsCount()).isEqualTo(2 + customerUsersCount); assertThat(preview.getRecipientsPreview()).containsAll(recipients); Map processedTemplates = preview.getProcessedTemplates(); assertThat(processedTemplates.get(NotificationDeliveryMethod.WEB)).asInstanceOf(type(WebDeliveryMethodNotificationTemplate.class)) .satisfies(template -> { - assertThat(template.getBody()) - .startsWith("Message for WEB") - .endsWith(requestorEmail + " ${unknownParam}"); - assertThat(template.getSubject()) - .startsWith("Subject for WEB") - .endsWith(requestorEmail); + assertThat(template.getSubject()).isEqualTo("WEB SUBJECT: " + firstRecipientEmail); + assertThat(template.getBody()).isEqualTo("WEB: " + firstRecipientEmail + " ${unknownParam}"); }); assertThat(processedTemplates.get(NotificationDeliveryMethod.SMS)).asInstanceOf(type(SmsDeliveryMethodNotificationTemplate.class)) .satisfies(template -> { - assertThat(template.getBody()) - .startsWith("Message for SMS") - .endsWith(requestorEmail); + assertThat(template.getBody()).isEqualTo("SMS: " + firstRecipientEmail); }); assertThat(processedTemplates.get(NotificationDeliveryMethod.EMAIL)).asInstanceOf(type(EmailDeliveryMethodNotificationTemplate.class)) .satisfies(template -> { - assertThat(template.getBody()) - .startsWith("Message for EMAIL") - .endsWith(requestorEmail); - assertThat(template.getSubject()) - .startsWith("Subject for EMAIL") - .endsWith(requestorEmail); + assertThat(template.getSubject()).isEqualTo("EMAIL SUBJECT: " + firstRecipientEmail); + assertThat(template.getBody()).isEqualTo("EMAIL: " + firstRecipientEmail); }); assertThat(processedTemplates.get(NotificationDeliveryMethod.SLACK)).asInstanceOf(type(SlackDeliveryMethodNotificationTemplate.class)) .satisfies(template -> { - assertThat(template.getBody()) - .isEqualTo("Message for SLACK: ${recipientEmail}"); // ${recipientEmail} should not be processed + assertThat(template.getBody()).isEqualTo("SLACK: John Doe"); }); } diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java index 3e12cb6d05..6b21809874 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java @@ -20,22 +20,22 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.BooleanNode; import org.junit.Before; import org.junit.Test; +import org.junit.function.ThrowingRunnable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.data.util.Pair; -import org.springframework.test.context.TestPropertySource; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.rule.engine.debug.TbMsgGeneratorNode; -import org.thingsboard.rule.engine.debug.TbMsgGeneratorNodeConfiguration; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSeverity; import org.thingsboard.server.common.data.alarm.AlarmStatus; +import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.device.profile.AlarmCondition; import org.thingsboard.server.common.data.device.profile.AlarmConditionFilter; import org.thingsboard.server.common.data.device.profile.AlarmConditionFilterKey; @@ -43,8 +43,8 @@ import org.thingsboard.server.common.data.device.profile.AlarmConditionKeyType; import org.thingsboard.server.common.data.device.profile.AlarmRule; import org.thingsboard.server.common.data.device.profile.DeviceProfileAlarm; import org.thingsboard.server.common.data.device.profile.SimpleAlarmConditionSpec; -import org.thingsboard.server.common.data.id.NotificationRuleId; -import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.NotificationTargetId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationRequest; @@ -57,37 +57,44 @@ import org.thingsboard.server.common.data.notification.rule.NotificationRule; import org.thingsboard.server.common.data.notification.rule.NotificationRuleInfo; import org.thingsboard.server.common.data.notification.rule.trigger.AlarmNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.AlarmNotificationRuleTriggerConfig.AlarmAction; +import org.thingsboard.server.common.data.notification.rule.trigger.EntitiesLimitNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.EntityActionNotificationRuleTriggerConfig; +import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerType; -import org.thingsboard.server.common.data.notification.rule.trigger.RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.targets.NotificationTarget; import org.thingsboard.server.common.data.notification.template.NotificationTemplate; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.EntityKeyValueType; import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; -import org.thingsboard.server.common.data.rule.RuleNode; -import org.thingsboard.server.common.data.script.ScriptLanguage; import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; +import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.notification.NotificationRequestService; -import org.thingsboard.server.dao.notification.NotificationRuleService; -import org.thingsboard.server.dao.notification.NotificationTemplateService; +import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.tenant.TenantProfileService; +import org.thingsboard.server.service.apiusage.limits.LimitedApi; +import org.thingsboard.server.service.apiusage.limits.RateLimitService; +import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService; import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; import java.util.UUID; +import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.offset; @@ -96,9 +103,6 @@ import static org.awaitility.Awaitility.await; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @DaoSqlTest -@TestPropertySource(properties = { - "js.evaluator=local" -}) public class NotificationRuleApiTest extends AbstractNotificationApiTest { @SpyBean @@ -106,72 +110,48 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { @Autowired private NotificationRequestService notificationRequestService; @Autowired - private NotificationRuleService notificationRuleService; + private TenantProfileService tenantProfileService; @Autowired - private NotificationTemplateService notificationTemplateService; - - @SpyBean - private AlarmService alarmService; + private TbTenantProfileService tbTenantProfileService; + @Autowired + private RateLimitService rateLimitService; + @Autowired + private RuleChainService ruleChainService; @Before public void beforeEach() throws Exception { loginTenantAdmin(); - notificationRuleService.deleteNotificationRulesByTenantId(tenantId); - notificationTemplateService.deleteNotificationTemplatesByTenantId(tenantId); } @Test public void testNotificationRuleProcessing_entityActionTrigger() throws Exception { - String notificationSubject = "${actionType}: ${entityType} [${entityId}]"; - String notificationText = "User: ${userEmail}"; - NotificationTemplate notificationTemplate = createNotificationTemplate(NotificationType.GENERAL, notificationSubject, notificationText, NotificationDeliveryMethod.WEB); - - NotificationRule notificationRule = new NotificationRule(); - notificationRule.setName("Web notification when any device is created, updated or deleted"); - notificationRule.setTemplateId(notificationTemplate.getId()); - notificationRule.setTriggerType(NotificationRuleTriggerType.ENTITY_ACTION); - EntityActionNotificationRuleTriggerConfig triggerConfig = new EntityActionNotificationRuleTriggerConfig(); triggerConfig.setEntityTypes(Set.of(EntityType.DEVICE)); triggerConfig.setCreated(true); triggerConfig.setUpdated(true); triggerConfig.setDeleted(true); + createNotificationRule(triggerConfig, "${actionType}: ${entityType} [${entityId}]", + "User: ${userEmail}", createNotificationTarget(tenantAdminUserId).getId()); + + Device device = checkNotificationAfter(() -> { + return createDevice("DEVICE!!!", "default", "12345"); + }, (notification, newDevice) -> { + assertThat(notification.getSubject()).isEqualTo("added: Device [" + newDevice.getId() + "]"); + assertThat(notification.getText()).isEqualTo("User: " + TENANT_ADMIN_EMAIL); + }); - DefaultNotificationRuleRecipientsConfig recipientsConfig = new DefaultNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(NotificationRuleTriggerType.ENTITY_ACTION); - recipientsConfig.setTargets(List.of(createNotificationTarget(tenantAdminUserId).getUuidId())); - - notificationRule.setTriggerConfig(triggerConfig); - notificationRule.setRecipientsConfig(recipientsConfig); - notificationRule = saveNotificationRule(notificationRule); - - getWsClient().subscribeForUnreadNotifications(10).waitForReply(true); - - - getWsClient().registerWaitForUpdate(); - Device device = createDevice("DEVICE!!!", "default", "12345"); - getWsClient().waitForUpdate(true); - - Notification notification = getWsClient().getLastDataUpdate().getUpdate(); - assertThat(notification.getSubject()).isEqualTo("added: Device [" + device.getId() + "]"); - assertThat(notification.getText()).isEqualTo("User: " + TENANT_ADMIN_EMAIL); - - - getWsClient().registerWaitForUpdate(); - device.setName("Updated name"); - device = doPost("/api/device", device, Device.class); - getWsClient().waitForUpdate(true); - - notification = getWsClient().getLastDataUpdate().getUpdate(); - assertThat(notification.getSubject()).isEqualTo("updated: Device [" + device.getId() + "]"); - - - getWsClient().registerWaitForUpdate(); - doDelete("/api/device/" + device.getId()).andExpect(status().isOk()); - getWsClient().waitForUpdate(true); + checkNotificationAfter(() -> { + device.setName("Updated name"); + doPost("/api/device", device, Device.class); + }, notification -> { + assertThat(notification.getSubject()).isEqualTo("updated: Device [" + device.getId() + "]"); + }); - notification = getWsClient().getLastDataUpdate().getUpdate(); - assertThat(notification.getSubject()).isEqualTo("deleted: Device [" + device.getId() + "]"); + checkNotificationAfter(() -> { + doDelete("/api/device/" + device.getId()).andExpect(status().isOk()); + }, notification -> { + assertThat(notification.getSubject()).isEqualTo("deleted: Device [" + device.getId() + "]"); + }); } @Test @@ -208,7 +188,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { String alarmType = "myBoolIsTrue"; - DeviceProfile deviceProfile = createDeviceProfileWithAlarmRules(notificationRule.getId(), alarmType); + DeviceProfile deviceProfile = createDeviceProfileWithAlarmRules(alarmType); Device device = createDevice("Device 1", deviceProfile.getName(), "1234"); clients.values().forEach(wsClient -> { @@ -230,7 +210,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { clients.forEach((expectedDelay, wsClient) -> { Notification notification = wsClient.getLastDataUpdate().getUpdate(); double actualDelay = (double) (notification.getCreatedTime() - ts) / 1000; - assertThat(actualDelay).isCloseTo(expectedDelay, offset(0.5)); + assertThat(actualDelay).isCloseTo(expectedDelay, offset(2.0)); assertThat(notification.getSubject()).isEqualTo("Alarm type: " + alarmType + ", status: " + AlarmStatus.ACTIVE_UNACK + ", " + "severity: " + AlarmSeverity.CRITICAL.toString().toLowerCase() + ", deviceId: " + device.getId()); @@ -272,7 +252,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { notificationRule.setTriggerType(NotificationRuleTriggerType.ALARM); String alarmType = "myBoolIsTrue"; - DeviceProfile deviceProfile = createDeviceProfileWithAlarmRules(notificationRule.getId(), alarmType); + DeviceProfile deviceProfile = createDeviceProfileWithAlarmRules(alarmType); Device device = createDevice("Device 1", deviceProfile.getName(), "1234"); AlarmNotificationRuleTriggerConfig triggerConfig = new AlarmNotificationRuleTriggerConfig(); @@ -327,6 +307,79 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { assertThat(findNotificationRequests(EntityType.ALARM).getData()).filteredOn(NotificationRequest::isScheduled).isEmpty(); } + @Test + public void testNotificationRuleProcessing_entitiesLimit() throws Exception { + TenantProfile tenantProfile = tenantProfileService.findDefaultTenantProfile(TenantId.SYS_TENANT_ID); + TenantProfileData profileData = tenantProfile.getProfileData(); + DefaultTenantProfileConfiguration profileConfiguration = (DefaultTenantProfileConfiguration) profileData.getConfiguration(); + int limit = 5; + profileConfiguration.setMaxDevices(limit); + profileConfiguration.setMaxAssets(limit); + profileConfiguration.setMaxCustomers(limit); + profileConfiguration.setMaxUsers(limit); + profileConfiguration.setMaxDashboards(limit); + profileConfiguration.setMaxRuleChains(limit); + tenantProfile.setProfileData(profileData); + tbTenantProfileService.save(TenantId.SYS_TENANT_ID, tenantProfile, null); + + EntitiesLimitNotificationRuleTriggerConfig triggerConfig = EntitiesLimitNotificationRuleTriggerConfig.builder() + .entityTypes(null).threshold(0.8f) + .build(); + loginSysAdmin(); + NotificationRule rule = createNotificationRule(triggerConfig, "${entityType}s limit will be reached soon", + "${entityType}s usage: ${currentCount}/${limit} (${percents}%)", createNotificationTarget(tenantAdminUserId).getId()); + int threshold = (int) (limit * 0.8); + loginTenantAdmin(); + + checkNotificationAfter(() -> { + for (int i = 1; i <= threshold; i++) { + createDevice(i + "", i + ""); + } + }, notification -> { + assertThat(notification.getText()).isEqualTo("Devices usage: " + threshold + "/" + limit + " (80%)"); + }); + + checkNotificationAfter(() -> { + for (int i = 1; i <= threshold; i++) { + Asset asset = new Asset(); + asset.setType("Test"); + asset.setName(i + ""); + doPost("/api/asset", asset); + } + }, notification -> { + assertThat(notification.getText()).isEqualTo("Assets usage: " + threshold + "/" + limit + " (80%)"); + }); + + checkNotificationAfter(() -> { + long present = ruleChainService.countByTenantId(tenantId); + for (int i = 1; i <= threshold - present; i++) { + RuleChain ruleChain = new RuleChain(); + ruleChain.setName(i + ""); + ruleChain.setRoot(false); + ruleChain.setDebugMode(false); + ruleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); + RuleChainMetaData metaData = new RuleChainMetaData(); + metaData.setRuleChainId(ruleChain.getId()); + metaData.setNodes(List.of()); + doPost("/api/ruleChain/metadata", metaData); + } + }, notification -> { + assertThat(notification.getText()).isEqualTo("Rule chains usage: " + threshold + "/" + limit + " (80%)"); + }); + + triggerConfig.setThreshold(1.0f); + rule.setTriggerConfig(triggerConfig); + loginSysAdmin(); + saveNotificationRule(rule); + loginTenantAdmin(); + + checkNotificationAfter(() -> { + createDevice(limit + "", limit + ""); + }, notification -> { + assertThat(notification.getText()).isEqualTo("Devices usage: " + limit + "/" + limit + " (100%)"); + }); + } + @Test public void testNotificationRuleInfo() throws Exception { NotificationDeliveryMethod[] deliveryMethods = {NotificationDeliveryMethod.WEB, NotificationDeliveryMethod.EMAIL}; @@ -352,7 +405,93 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { assertThat(ruleInfo.getDeliveryMethods()).containsOnly(deliveryMethods); } - private DeviceProfile createDeviceProfileWithAlarmRules(NotificationRuleId notificationRuleId, String alarmType) { + @Test + public void testNotificationRequestsPerRuleRateLimits() throws Exception { + int notificationRequestsLimit = 10; + TenantProfile tenantProfile = tenantProfileService.findDefaultTenantProfile(TenantId.SYS_TENANT_ID); + TenantProfileData profileData = tenantProfile.getProfileData(); + DefaultTenantProfileConfiguration profileConfiguration = (DefaultTenantProfileConfiguration) profileData.getConfiguration(); + profileConfiguration.setTenantNotificationRequestsPerRuleRateLimit(notificationRequestsLimit + ":300"); + tenantProfile.setProfileData(profileData); + tbTenantProfileService.save(TenantId.SYS_TENANT_ID, tenantProfile, null); + + NotificationRule rule = new NotificationRule(); + rule.setName("Device created"); + rule.setTriggerType(NotificationRuleTriggerType.ENTITY_ACTION); + NotificationTemplate template = createNotificationTemplate(NotificationType.ENTITY_ACTION, "Device created", "Device created", + NotificationDeliveryMethod.WEB, NotificationDeliveryMethod.SMS); + rule.setTemplateId(template.getId()); + EntityActionNotificationRuleTriggerConfig triggerConfig = new EntityActionNotificationRuleTriggerConfig(); + triggerConfig.setEntityTypes(Set.of(EntityType.DEVICE)); + triggerConfig.setCreated(true); + rule.setTriggerConfig(triggerConfig); + NotificationTarget target = createNotificationTarget(tenantAdminUserId); + DefaultNotificationRuleRecipientsConfig recipientsConfig = new DefaultNotificationRuleRecipientsConfig(); + recipientsConfig.setTriggerType(NotificationRuleTriggerType.ENTITY_ACTION); + recipientsConfig.setTargets(List.of(target.getUuidId())); + rule.setRecipientsConfig(recipientsConfig); + rule = saveNotificationRule(rule); + + for (int i = 0; i < notificationRequestsLimit; i++) { + String name = "device " + i; + createDevice(name, name); + } + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> { + assertThat(getMyNotifications(false, 100)).size().isEqualTo(notificationRequestsLimit); + }); + for (int i = 0; i < 5; i++) { + String name = "device " + (notificationRequestsLimit + i); + createDevice(name, name); + } + + boolean rateLimitExceeded = !rateLimitService.checkRateLimit(LimitedApi.NOTIFICATION_REQUESTS_PER_RULE, tenantId, rule.getId()); + assertThat(rateLimitExceeded).isTrue(); + + TimeUnit.SECONDS.sleep(3); + assertThat(getMyNotifications(false, 100)).size().isEqualTo(notificationRequestsLimit); + } + + private R checkNotificationAfter(Callable action, BiConsumer check) throws Exception { + if (getWsClient().getLastDataUpdate() == null) { + getWsClient().subscribeForUnreadNotifications(10).waitForReply(true); + } + getWsClient().registerWaitForUpdate(); + R result = action.call(); + getWsClient().waitForUpdate(true); + check.accept(getWsClient().getLastDataUpdate().getUpdate(), result); + return result; + } + + private void checkNotificationAfter(ThrowingRunnable action, Consumer check) throws Exception { + checkNotificationAfter(() -> { + try { + action.run(); + return null; + } catch (Throwable e) { + throw new Exception(e); + } + }, (notification, r) -> check.accept(notification)); + } + + private NotificationRule createNotificationRule(NotificationRuleTriggerConfig triggerConfig, String subject, String text, NotificationTargetId... targets) { + NotificationTemplate template = createNotificationTemplate(NotificationType.valueOf(triggerConfig.getTriggerType().toString()), subject, text, NotificationDeliveryMethod.WEB); + + NotificationRule rule = new NotificationRule(); + rule.setName(triggerConfig.getTriggerType() + " [" + Arrays.toString(targets) + "]"); + rule.setTemplateId(template.getId()); + rule.setTriggerType(triggerConfig.getTriggerType()); + rule.setTriggerConfig(triggerConfig); + + DefaultNotificationRuleRecipientsConfig recipientsConfig = new DefaultNotificationRuleRecipientsConfig(); + recipientsConfig.setTriggerType(triggerConfig.getTriggerType()); + recipientsConfig.setTargets(DaoUtil.toUUIDs(List.of(targets))); + rule.setRecipientsConfig(recipientsConfig); + + return saveNotificationRule(rule); + } + + private DeviceProfile createDeviceProfileWithAlarmRules(String alarmType) { DeviceProfile deviceProfile = createDeviceProfile("For notification rule test"); deviceProfile.setTenantId(tenantId); @@ -387,41 +526,6 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { return deviceProfile; } - private RuleChain createEmptyRuleChain(String name) { - RuleChain ruleChain = new RuleChain(); - ruleChain.setName(name); - ruleChain.setTenantId(tenantId); - ruleChain.setRoot(false); - ruleChain.setDebugMode(false); - ruleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); - - RuleChainMetaData metaData = new RuleChainMetaData(); - metaData.setRuleChainId(ruleChain.getId()); - metaData.setNodes(List.of()); - metaData = doPost("/api/ruleChain/metadata", metaData, RuleChainMetaData.class); - return ruleChain; - } - - private RuleNode addRuleNodeWithError(RuleChainId ruleChainId, String name) { - RuleChainMetaData metaData = new RuleChainMetaData(); - metaData.setRuleChainId(ruleChainId); - - RuleNode generatorNodeWithError = new RuleNode(); - generatorNodeWithError.setName(name); - generatorNodeWithError.setType(TbMsgGeneratorNode.class.getName()); - TbMsgGeneratorNodeConfiguration generatorNodeConfiguration = new TbMsgGeneratorNodeConfiguration(); - generatorNodeConfiguration.setScriptLang(ScriptLanguage.JS); - generatorNodeConfiguration.setPeriodInSeconds(1000); - generatorNodeConfiguration.setMsgCount(1); - generatorNodeConfiguration.setJsScript("[return"); - generatorNodeWithError.setConfiguration(mapper.valueToTree(generatorNodeConfiguration)); - - metaData.setNodes(List.of(generatorNodeWithError)); - metaData.setFirstNodeIndex(0); - metaData = doPost("/api/ruleChain/metadata", metaData, RuleChainMetaData.class); - return metaData.getNodes().get(0); - } - private NotificationRule saveNotificationRule(NotificationRule notificationRule) { return doPost("/api/notification/rule", notificationRule, NotificationRule.class); } diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java index 1b843d61b4..ce42298d9f 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java @@ -19,7 +19,6 @@ import com.fasterxml.jackson.core.type.TypeReference; import org.apache.commons.lang3.StringUtils; import org.junit.Before; import org.junit.Test; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultMatcher; import org.thingsboard.server.common.data.id.IdBased; @@ -30,8 +29,6 @@ import org.thingsboard.server.common.data.notification.template.NotificationTemp import org.thingsboard.server.common.data.notification.template.NotificationTemplateConfig; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.dao.notification.NotificationRuleService; -import org.thingsboard.server.dao.notification.NotificationTemplateService; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.List; @@ -43,16 +40,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @DaoSqlTest public class NotificationTemplateApiTest extends AbstractNotificationApiTest { - @Autowired - private NotificationTemplateService templateService; - @Autowired - private NotificationRuleService notificationRuleService; - @Before public void beforeEach() throws Exception { loginTenantAdmin(); - notificationRuleService.deleteNotificationRulesByTenantId(tenantId); - templateService.deleteNotificationTemplatesByTenantId(tenantId); } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java index f6111ea4b8..a8a68762d8 100644 --- a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java @@ -73,7 +73,7 @@ public class DefaultDeviceStateServiceTest { @Before public void setUp() { - service = spy(new DefaultDeviceStateService(tenantService, deviceService, attributesService, tsService, clusterService, partitionService, serviceInfoProvider, null, null)); + service = spy(new DefaultDeviceStateService(tenantService, deviceService, attributesService, tsService, clusterService, partitionService, serviceInfoProvider, null, null, null)); } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/stats/DevicesStatisticsTest.java b/application/src/test/java/org/thingsboard/server/service/stats/DevicesStatisticsTest.java new file mode 100644 index 0000000000..d31d6639ac --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/stats/DevicesStatisticsTest.java @@ -0,0 +1,110 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.stats; + +import lombok.SneakyThrows; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.id.ApiUsageStateId; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.service.apiusage.TbApiUsageStateService; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +@TestPropertySource(properties = { + "usage.stats.report.enabled=true", + "transport.http.enabled=true", + "usage.stats.report.interval=2", + "usage.stats.gauge_report_interval=1", + "state.defaultStateCheckIntervalInSec=3", + "state.defaultInactivityTimeoutInSec=10" + +}) +public class DevicesStatisticsTest extends AbstractControllerTest { + + @Autowired + private TbApiUsageStateService apiUsageStateService; + @Autowired + private TimeseriesService timeseriesService; + + private ApiUsageStateId apiUsageStateId; + + @Before + public void beforeEach() throws Exception { + loginTenantAdmin(); + apiUsageStateId = apiUsageStateService.getApiUsageState(tenantId).getId(); + } + + @Test + public void testDevicesActivityStats() throws Exception { + int activeDevicesCount = 5; + List activeDevices = new ArrayList<>(); + for (int i = 1; i <= activeDevicesCount; i++) { + String name = "active_device_" + i; + Device device = createDevice(name, name); + activeDevices.add(device); + } + int inactiveDevicesCount = 10; + List inactiveDevices = new ArrayList<>(); + for (int i = 1; i <= inactiveDevicesCount; i++) { + String name = "inactive_device_" + i; + Device device = createDevice(name, name); + inactiveDevices.add(device); + } + + await().atMost(15, TimeUnit.SECONDS) + .untilAsserted(() -> { + assertThat(getLatestStats(ApiUsageRecordKey.ACTIVE_DEVICES, false)).isZero(); + assertThat(getLatestStats(ApiUsageRecordKey.INACTIVE_DEVICES, false)).isEqualTo(activeDevicesCount + inactiveDevicesCount); + }); + + for (Device device : activeDevices) { + postTelemetry(device.getName(), "{\"dp\":1}"); + } + + await().atMost(40, TimeUnit.SECONDS) + .untilAsserted(() -> { + assertThat(getLatestStats(ApiUsageRecordKey.ACTIVE_DEVICES, false)).isEqualTo(activeDevicesCount); + assertThat(getLatestStats(ApiUsageRecordKey.INACTIVE_DEVICES, false)).isEqualTo(inactiveDevicesCount); + }); + } + + @SneakyThrows + private Long getLatestStats(ApiUsageRecordKey key, boolean hourly) { + return timeseriesService.findLatest(tenantId, apiUsageStateId, List.of(key.getApiCountKey() + (hourly ? "Hourly" : ""))) + .get().stream().findFirst().flatMap(KvEntry::getLongValue).orElse(null); + } + + @SneakyThrows + private void postTelemetry(String accessToken, String json) { + doPost("/api/v1/" + accessToken + "/telemetry", json, new String[0]).andExpect(status().isOk()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java index 26e8b3e92a..e493916b8a 100644 --- a/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java @@ -68,7 +68,6 @@ import org.thingsboard.server.service.ota.OtaPackageStateService; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -254,21 +253,25 @@ public class ExportImportServiceSqlTest extends BaseExportImportServiceTest { Asset asset1 = createAsset(tenantId1, null, assetProfile.getId(), "Asset 1"); Asset asset2 = createAsset(tenantId1, null, assetProfile.getId(), "Asset 2"); Dashboard dashboard = createDashboard(tenantId1, null, "Dashboard 1"); + DeviceProfile existingDeviceProfile = createDeviceProfile(tenantId2, null, null, "Existing"); + String aliasId = "23c4185d-1497-9457-30b2-6d91e69a5b2c"; + String unknownUuid = "ea0dc8b0-3d85-11ed-9200-77fc04fa14fa"; String entityAliases = "{\n" + - "\t\"23c4185d-1497-9457-30b2-6d91e69a5b2c\": {\n" + - "\t\t\"alias\": \"assets\",\n" + - "\t\t\"filter\": {\n" + - "\t\t\t\"entityList\": [\n" + - "\t\t\t\t\"" + asset1.getId().toString() + "\",\n" + - "\t\t\t\t\"" + asset2.getId().toString() + "\"\n" + - "\t\t\t],\n" + - "\t\t\t\"entityType\": \"ASSET\",\n" + - "\t\t\t\"resolveMultiple\": true,\n" + - "\t\t\t\"type\": \"entityList\"\n" + - "\t\t},\n" + - "\t\t\"id\": \"23c4185d-1497-9457-30b2-6d91e69a5b2c\"\n" + - "\t}\n" + + "\"" + aliasId + "\": {\n" + + "\"alias\": \"assets\",\n" + + "\"filter\": {\n" + + "\"entityList\": [\n" + + "\"" + asset1.getId().toString() + "\",\n" + + "\"" + asset2.getId().toString() + "\",\n" + + "\"" + tenantId1.getId().toString() + "\",\n" + + "\"" + existingDeviceProfile.getId().toString() + "\",\n" + + "\"" + unknownUuid + "\"\n" + + "],\n" + + "\"resolveMultiple\": true\n" + + "},\n" + + "\"id\": \"" + aliasId + "\"\n" + + "}\n" + "}"; ObjectNode dashboardConfiguration = JacksonUtil.newObjectNode(); dashboardConfiguration.set("entityAliases", JacksonUtil.toJsonNode(entityAliases)); @@ -287,11 +290,23 @@ public class ExportImportServiceSqlTest extends BaseExportImportServiceTest { Asset importedAsset2 = importEntity(tenantAdmin2, asset2ExportData).getSavedEntity(); Dashboard importedDashboard = importEntity(tenantAdmin2, dashboardExportData).getSavedEntity(); - Set entityAliasEntitiesIds = Streams.stream(importedDashboard.getConfiguration() - .get("entityAliases").elements().next().get("filter").get("entityList").elements()) - .map(JsonNode::asText).collect(Collectors.toSet()); - assertThat(entityAliasEntitiesIds).doesNotContain(asset1.getId().toString(), asset2.getId().toString()); - assertThat(entityAliasEntitiesIds).contains(importedAsset1.getId().toString(), importedAsset2.getId().toString()); + Map.Entry entityAlias = importedDashboard.getConfiguration().get("entityAliases").fields().next(); + assertThat(entityAlias.getKey()).isEqualTo(aliasId); + assertThat(entityAlias.getValue().get("id").asText()).isEqualTo(aliasId); + + List aliasEntitiesIds = Streams.stream(entityAlias.getValue().get("filter").get("entityList").elements()) + .map(JsonNode::asText).collect(Collectors.toList()); + assertThat(aliasEntitiesIds).size().isEqualTo(5); + assertThat(aliasEntitiesIds).element(0).as("external asset 1 was replaced with imported one") + .isEqualTo(importedAsset1.getId().toString()); + assertThat(aliasEntitiesIds).element(1).as("external asset 2 was replaced with imported one") + .isEqualTo(importedAsset2.getId().toString()); + assertThat(aliasEntitiesIds).element(2).as("external tenant id was replaced with new tenant id") + .isEqualTo(tenantId2.toString()); + assertThat(aliasEntitiesIds).element(3).as("existing device profile id was left as is") + .isEqualTo(existingDeviceProfile.getId().toString()); + assertThat(aliasEntitiesIds).element(4).as("unresolved uuid was replaced with tenant id") + .isEqualTo(tenantId2.toString()); } @@ -469,7 +484,7 @@ public class ExportImportServiceSqlTest extends BaseExportImportServiceTest { Device device = createDevice(tenantId1, null, deviceProfile.getId(), "Device 1"); Map entitiesExportData = Stream.of(customer.getId(), asset.getId(), device.getId(), - ruleChain.getId(), dashboard.getId(), assetProfile.getId(), deviceProfile.getId()) + ruleChain.getId(), dashboard.getId(), assetProfile.getId(), deviceProfile.getId()) .map(entityId -> { try { return exportEntity(tenantAdmin1, entityId, EntityExportSettings.builder() diff --git a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java new file mode 100644 index 0000000000..9687225bd1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java @@ -0,0 +1,211 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.transport; + + +import com.google.common.util.concurrent.Futures; +import lombok.extern.slf4j.Slf4j; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.server.cache.ota.OtaPackageDataCache; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.msg.EncryptionUtil; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceProvisionService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.device.provision.ProvisionResponse; +import org.thingsboard.server.dao.device.provision.ProvisionResponseStatus; +import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; +import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; +import org.thingsboard.server.service.resource.TbResourceService; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Slf4j +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = DefaultTransportApiService.class) +public class DefaultTransportApiServiceTest { + + @MockBean + protected TbDeviceProfileCache deviceProfileCache; + @MockBean + protected TbTenantProfileCache tenantProfileCache; + @MockBean + protected TbApiUsageStateService apiUsageStateService; + @MockBean + protected DeviceService deviceService; + @MockBean + protected DeviceProfileService deviceProfileService; + @MockBean + protected RelationService relationService; + @MockBean + protected DeviceCredentialsService deviceCredentialsService; + @MockBean + protected DbCallbackExecutorService dbCallbackExecutorService; + @MockBean + protected TbClusterService tbClusterService; + @MockBean + protected DataDecodingEncodingService dataDecodingEncodingService; + @MockBean + protected DeviceProvisionService deviceProvisionService; + @MockBean + protected TbResourceService resourceService; + @MockBean + protected OtaPackageService otaPackageService; + @MockBean + protected OtaPackageDataCache otaPackageDataCache; + @MockBean + protected QueueService queueService; + @SpyBean + DefaultTransportApiService service; + + private String certificateChain; + private String[] chain; + + @Before + public void setUp() { + + String filePath = "src/test/resources/provision/x509ChainProvisionTest.pem"; + try { + certificateChain = Files.readString(Paths.get(filePath)); + certificateChain = certTrimNewLinesForChainInDeviceProfile(certificateChain); + chain = fetchLeafCertificateFromChain(certificateChain); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Test + public void validateExistingDeviceByX509CertificateStrategy() { + var device = createDevice(); + when(deviceService.findDeviceByIdAsync(any(), any())).thenReturn(Futures.immediateFuture(device)); + + var deviceCredentials = createDeviceCredentials(chain[0], device.getId()); + when(deviceCredentialsService.findDeviceCredentialsByCredentialsId(any())).thenReturn(deviceCredentials); + + service.validateOrCreateDeviceX509Certificate(certificateChain); + verify(deviceCredentialsService, times(1)).findDeviceCredentialsByCredentialsId(any()); + } + + @Test + public void provisionDeviceX509Certificate() { + var deviceProfile = createDeviceProfile(chain[1]); + when(deviceProfileService.findDeviceProfileByProvisionDeviceKey(any())).thenReturn(deviceProfile); + + var device = createDevice(); + when(deviceService.findDeviceByTenantIdAndName(any(), any())).thenReturn(device); + when(deviceService.findDeviceByIdAsync(any(), any())).thenReturn(Futures.immediateFuture(device)); + + var deviceCredentials = createDeviceCredentials(chain[0], device.getId()); + when(deviceCredentialsService.findDeviceCredentialsByCredentialsId(any())).thenReturn(null); + when(deviceCredentialsService.updateDeviceCredentials(any(), any())).thenReturn(deviceCredentials); + + var provisionResponse = createProvisionResponse(deviceCredentials); + when(deviceProvisionService.provisionDeviceViaX509Chain(any(), any())).thenReturn(provisionResponse); + + service.validateOrCreateDeviceX509Certificate(certificateChain); + verify(deviceProfileService, times(1)).findDeviceProfileByProvisionDeviceKey(any()); + verify(deviceService, times(1)).findDeviceByIdAsync(any(), any()); + verify(deviceCredentialsService, times(1)).findDeviceCredentialsByCredentialsId(any()); + verify(deviceProvisionService, times(1)).provisionDeviceViaX509Chain(any(), any()); + } + + private DeviceProfile createDeviceProfile(String certificateValue) { + X509CertificateChainProvisionConfiguration provision = new X509CertificateChainProvisionConfiguration(); + provision.setProvisionDeviceSecret(certificateValue); + provision.setCertificateRegExPattern("([^@]+)"); + provision.setAllowCreateNewDevicesByX509Certificate(true); + + DeviceProfileData deviceProfileData = new DeviceProfileData(); + deviceProfileData.setProvisionConfiguration(provision); + + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setProfileData(deviceProfileData); + deviceProfile.setProvisionDeviceKey(EncryptionUtil.getSha3Hash(certificateValue)); + deviceProfile.setProvisionType(DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN); + return deviceProfile; + } + + private DeviceCredentials createDeviceCredentials(String certificateValue, DeviceId deviceId) { + DeviceCredentials deviceCredentials = new DeviceCredentials(); + deviceCredentials.setDeviceId(deviceId); + deviceCredentials.setCredentialsValue(certificateValue); + deviceCredentials.setCredentialsId(EncryptionUtil.getSha3Hash(certificateValue)); + deviceCredentials.setCredentialsType(DeviceCredentialsType.X509_CERTIFICATE); + return deviceCredentials; + } + + private Device createDevice() { + Device device = new Device(); + device.setId(new DeviceId(UUID.randomUUID())); + return device; + } + + private ProvisionResponse createProvisionResponse(DeviceCredentials deviceCredentials) { + return new ProvisionResponse(deviceCredentials, ProvisionResponseStatus.SUCCESS); + } + + public static String certTrimNewLinesForChainInDeviceProfile(String input) { + return input.replaceAll("\n", "") + .replaceAll("\r", "") + .replaceAll("-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----\n") + .replaceAll("-----END CERTIFICATE-----", "\n-----END CERTIFICATE-----\n") + .trim(); + } + + private String[] fetchLeafCertificateFromChain(String value) { + List chain = new ArrayList<>(); + String regex = "-----BEGIN CERTIFICATE-----\\s*.*?\\s*-----END CERTIFICATE-----"; + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(value); + while (matcher.find()) { + chain.add(matcher.group(0)); + } + return chain.toArray(new String[0]); + } +} diff --git a/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java b/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java new file mode 100644 index 0000000000..6a8491304d --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.system; + +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.springframework.util.ClassUtils; +import org.springframework.web.client.RestTemplate; + + +@Slf4j +public class RestTemplateConvertersTest { + + @Test + public void testJacksonXmlConverter() { + ClassLoader classLoader = RestTemplate.class.getClassLoader(); + boolean jackson2XmlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", classLoader); + Assertions.assertFalse(jackson2XmlPresent, "XmlMapper must not be present in classpath, please, exclude \"jackson-dataformat-xml\" dependency!"); + //If this xml mapper will be present in classpath then we will get "Unsupported Media Type" in RestTemplate + } + +} diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java index b483084258..941e97e500 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java @@ -370,7 +370,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt protected void processProtoTestRequestAttributesValuesFromTheServer(String attrPubTopic, String attrSubTopic, String attrReqTopicPrefix) throws Exception { MqttTestClient client = new MqttTestClient(); client.connectAndWait(accessToken); - DeviceTypeFilter dtf = new DeviceTypeFilter(savedDevice.getType(), savedDevice.getName()); + DeviceTypeFilter dtf = new DeviceTypeFilter(List.of(savedDevice.getType()), savedDevice.getName()); String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; List clientKeysList = List.of(clientKeysStr.split(",")); diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/credentials/BasicMqttCredentialsTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/credentials/BasicMqttCredentialsTest.java index 3880141c36..87337545fc 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/credentials/BasicMqttCredentialsTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/credentials/BasicMqttCredentialsTest.java @@ -190,7 +190,7 @@ public class BasicMqttCredentialsTest extends AbstractMqttIntegrationTest { return device; } - private Device createDevice(String deviceName, String accessToken) throws Exception { + protected Device createDevice(String deviceName, String accessToken) throws Exception { Device device = new Device(); device.setName(deviceName); device.setType("default"); diff --git a/application/src/test/resources/provision/x509ChainProvisionTest.pem b/application/src/test/resources/provision/x509ChainProvisionTest.pem new file mode 100644 index 0000000000..b2ec300f78 --- /dev/null +++ b/application/src/test/resources/provision/x509ChainProvisionTest.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIICMTCCAdegAwIBAgIUI9dBuwN6pTtK6uZ03rkiCwV4wEYwCgYIKoZIzj0EAwIw +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlQ2VydGlmaWNhdGVAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MB4XDTIzMDMyOTE0NTYxN1oXDTI0MDMyODE0NTYxN1ow +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlQ2VydGlmaWNhdGVAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE9Zo791qK +QiGNBm11r4ZGxh+w+ossZL3xc46ufq5QckQHP7zkD2XDAcmP5GvdkM1sBFN9AWaC +kQfNnWmfERsOOKNTMFEwHQYDVR0OBBYEFFFc5uyCyglQoZiKhzXzMcQ3BKORMB8G +A1UdIwQYMBaAFFFc5uyCyglQoZiKhzXzMcQ3BKORMA8GA1UdEwEB/wQFMAMBAf8w +CgYIKoZIzj0EAwIDSAAwRQIhANbA9CuhoOifZMMmqkpuld+65CR+ItKdXeRAhLMZ +uccuAiB0FSQB34zMutXrZj1g8Gl5OkE7YryFHbei1z0SveHR8g== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICMTCCAdegAwIBAgIUUEKxS9hTz4l+oLUMF0LV6TC/gCIwCgYIKoZIzj0EAwIw +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlUHJvZmlsZUNlcnRAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MB4XDTIzMDMyOTE0NTczNloXDTI0MDMyODE0NTczNlow +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlUHJvZmlsZUNlcnRAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECMlWO72k +rDoUL9FQjUmSCetkhaEGJUfQkdSfkLSNa0GyAEIMbfmzI4zITeapunu4rGet3EMy +LydQzuQanBicp6NTMFEwHQYDVR0OBBYEFHpZ78tPnztNii4Da/yCw6mhEIL3MB8G +A1UdIwQYMBaAFHpZ78tPnztNii4Da/yCw6mhEIL3MA8GA1UdEwEB/wQFMAMBAf8w +CgYIKoZIzj0EAwIDSAAwRQIgJ7qyMFqNcwSYkH6o+UlQXzLWfwZbNjVk+aR7foAZ +NGsCIQDsd7v3WQIGHiArfZeDs1DLEDuV/2h6L+ZNoGNhEKL+1A== +-----END CERTIFICATE----- diff --git a/common/cluster-api/src/main/proto/queue.proto b/common/cluster-api/src/main/proto/queue.proto index 7553d97b16..5198c6fc8d 100644 --- a/common/cluster-api/src/main/proto/queue.proto +++ b/common/cluster-api/src/main/proto/queue.proto @@ -179,6 +179,10 @@ message ValidateDeviceX509CertRequestMsg { string hash = 1; } +message ValidateOrCreateDeviceX509CertRequestMsg { + string certificateChain = 1; +} + message ValidateBasicMqttCredRequestMsg { string clientId = 1; string userName = 2; @@ -942,6 +946,7 @@ message TransportApiRequestMsg { GetDeviceRequestMsg deviceRequestMsg = 12; GetDeviceCredentialsRequestMsg deviceCredentialsRequestMsg = 13; GetAllQueueRoutingInfoRequestMsg getAllQueueRoutingInfoRequestMsg = 14; + ValidateOrCreateDeviceX509CertRequestMsg validateOrCreateX509CertRequestMsg = 15; } /* Response from ThingsBoard Core Service to Transport Service */ @@ -968,7 +973,6 @@ message ToCoreMsg { EdgeNotificationMsgProto edgeNotificationMsg = 5; DeviceActivityProto deviceActivityMsg = 6; NotificationSchedulerServiceMsg notificationSchedulerServiceMsg = 7; - NotificationRuleProcessorMsg notificationRuleProcessorMsg = 8; } /* High priority messages with low latency are handled by ThingsBoard Core Service separately */ @@ -983,6 +987,7 @@ message ToCoreNotificationMsg { bytes toEdgeSyncRequestMsg = 8; bytes fromEdgeSyncResponseMsg = 9; SubscriptionMgrMsgProto toSubscriptionMgrMsg = 10; + NotificationRuleProcessorMsg notificationRuleProcessorMsg = 11; } /* Messages that are handled by ThingsBoard RuleEngine Service */ @@ -1034,6 +1039,7 @@ message ToUsageStatsServiceMsg { repeated UsageStatsKVProto values = 5; int64 customerIdMSB = 6; int64 customerIdLSB = 7; + string serviceId = 8; } message ToOtaPackageStateServiceMsg { diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java index 8d17e84b01..c57b316a44 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java @@ -15,10 +15,10 @@ */ package org.thingsboard.server.dao.device; +import com.fasterxml.jackson.databind.JsonNode; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.DeviceCredentials; -import com.fasterxml.jackson.databind.JsonNode; public interface DeviceCredentialsService { diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java index 5fc45afb57..e765cc5030 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java @@ -39,6 +39,8 @@ public interface DeviceProfileService extends EntityDaoService { PageData findDeviceProfileInfos(TenantId tenantId, PageLink pageLink, String transportType); + DeviceProfile findDeviceProfileByProvisionDeviceKey(String provisionDeviceKey); + DeviceProfile findOrCreateDeviceProfile(TenantId tenantId, String profileName); DeviceProfile createDefaultDeviceProfile(TenantId tenantId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProvisionService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProvisionService.java index 77a8d3edbc..5fb6462c80 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProvisionService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProvisionService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.device; +import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.dao.device.provision.ProvisionFailedException; import org.thingsboard.server.dao.device.provision.ProvisionRequest; import org.thingsboard.server.dao.device.provision.ProvisionResponse; @@ -22,4 +23,6 @@ import org.thingsboard.server.dao.device.provision.ProvisionResponse; public interface DeviceProvisionService { ProvisionResponse provisionDevice(ProvisionRequest provisionRequest) throws ProvisionFailedException; + + ProvisionResponse provisionDeviceViaX509Chain(DeviceProfile deviceProfile, ProvisionRequest provisionRequest) throws ProvisionFailedException; } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionFailedException.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionFailedException.java index 2579b1f9cf..f3fc6f97fc 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionFailedException.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionFailedException.java @@ -16,7 +16,11 @@ package org.thingsboard.server.dao.device.provision; public class ProvisionFailedException extends RuntimeException { + + private static final long serialVersionUID = 1673991117668477401L; + public ProvisionFailedException(String errorMsg) { super(errorMsg); } + } diff --git a/common/data/pom.xml b/common/data/pom.xml index f6fd744a02..677c3dfd25 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -100,6 +100,10 @@ org.apache.commons commons-lang3 + + commons-codec + commons-codec + io.swagger swagger-annotations diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageRecordKey.java b/common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageRecordKey.java index dcad5c908a..64eabfdd16 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageRecordKey.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageRecordKey.java @@ -26,7 +26,9 @@ public enum ApiUsageRecordKey { JS_EXEC_COUNT(ApiFeature.JS, "jsExecutionCount", "jsExecutionLimit", "JavaScript execution"), EMAIL_EXEC_COUNT(ApiFeature.EMAIL, "emailCount", "emailLimit", "email message"), SMS_EXEC_COUNT(ApiFeature.SMS, "smsCount", "smsLimit", "SMS message"), - CREATED_ALARMS_COUNT(ApiFeature.ALARM, "createdAlarmsCount", "createdAlarmsLimit", "alarm"); + CREATED_ALARMS_COUNT(ApiFeature.ALARM, "createdAlarmsCount", "createdAlarmsLimit", "alarm"), + ACTIVE_DEVICES("activeDevicesCount"), + INACTIVE_DEVICES("inactiveDevicesCount"); private static final ApiUsageRecordKey[] JS_RECORD_KEYS = {JS_EXEC_COUNT}; private static final ApiUsageRecordKey[] RE_RECORD_KEYS = {RE_EXEC_COUNT}; @@ -44,12 +46,23 @@ public enum ApiUsageRecordKey { private final String apiLimitKey; @Getter private final String unitLabel; + @Getter + private final boolean counter; ApiUsageRecordKey(ApiFeature apiFeature, String apiCountKey, String apiLimitKey, String unitLabel) { + this(apiFeature, apiCountKey, apiLimitKey, unitLabel, true); + } + + ApiUsageRecordKey(String apiCountKey) { + this(null, apiCountKey, null, null, false); + } + + ApiUsageRecordKey(ApiFeature apiFeature, String apiCountKey, String apiLimitKey, String unitLabel, boolean counter) { this.apiFeature = apiFeature; this.apiCountKey = apiCountKey; this.apiLimitKey = apiLimitKey; this.unitLabel = unitLabel; + this.counter = counter; } public static ApiUsageRecordKey[] getKeys(ApiFeature feature) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index df2375fd03..80d9c4a873 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -29,7 +29,6 @@ public class CacheConstants { public static final String TENANTS_CACHE = "tenants"; public static final String TENANTS_EXIST_CACHE = "tenantsExist"; public static final String DEVICE_PROFILE_CACHE = "deviceProfiles"; - public static final String NOTIFICATION_RULES_CACHE = "notificationRules"; public static final String NOTIFICATION_SETTINGS_CACHE = "notificationSettings"; public static final String ASSET_PROFILE_CACHE = "assetProfiles"; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java index 7af4361698..f5ef1319a8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java @@ -18,5 +18,6 @@ package org.thingsboard.server.common.data; public enum DeviceProfileProvisionType { DISABLED, ALLOW_CREATE_NEW_DEVICES, - CHECK_PRE_PROVISIONED_DEVICES + CHECK_PRE_PROVISIONED_DEVICES, + X509_CERTIFICATE_CHAIN } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java index 274882d60e..3f4aa11c1f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java @@ -46,6 +46,10 @@ public class StringUtils { return source != null && !source.isEmpty() && !source.trim().isEmpty(); } + public static String notBlankOrDefault(String src, String def) { + return isNotBlank(src) ? src : def; + } + public static String removeStart(final String str, final String remove) { if (isEmpty(str) || isEmpty(remove)) { return str; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/UpdateMessage.java b/common/data/src/main/java/org/thingsboard/server/common/data/UpdateMessage.java index 287d3f3b71..43210de9d0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/UpdateMessage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/UpdateMessage.java @@ -38,5 +38,4 @@ public class UpdateMessage implements Serializable { @ApiModelProperty(position = 6, value = "Latest ThingsBoard version release notes URL.") private final String latestVersionReleaseNotesUrl; - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java index 4a825ab96f..c5882dc187 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java @@ -31,7 +31,8 @@ import java.io.Serializable; @JsonSubTypes({ @JsonSubTypes.Type(value = DisabledDeviceProfileProvisionConfiguration.class, name = "DISABLED"), @JsonSubTypes.Type(value = AllowCreateNewDevicesDeviceProfileProvisionConfiguration.class, name = "ALLOW_CREATE_NEW_DEVICES"), - @JsonSubTypes.Type(value = CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.class, name = "CHECK_PRE_PROVISIONED_DEVICES")}) + @JsonSubTypes.Type(value = CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.class, name = "CHECK_PRE_PROVISIONED_DEVICES"), + @JsonSubTypes.Type(value = X509CertificateChainProvisionConfiguration.class, name = "X509_CERTIFICATE_CHAIN")}) public interface DeviceProfileProvisionConfiguration extends Serializable { String getProvisionDeviceSecret(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java index fa7c8df271..ccdee12a0d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java @@ -17,8 +17,10 @@ package org.thingsboard.server.common.data.device.profile; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.validation.NoXss; +import java.util.Objects; import java.util.Set; @Data @@ -42,12 +44,19 @@ public class MqttDeviceProfileTransportConfiguration implements DeviceProfileTra } public TransportPayloadTypeConfiguration getTransportPayloadTypeConfiguration() { - if (transportPayloadTypeConfiguration != null) { - return transportPayloadTypeConfiguration; - } else { - return new JsonTransportPayloadConfiguration(); - } + return Objects.requireNonNullElseGet(transportPayloadTypeConfiguration, JsonTransportPayloadConfiguration::new); } + public String getDeviceTelemetryTopic() { + return StringUtils.notBlankOrDefault(deviceTelemetryTopic, MqttTopics.DEVICE_TELEMETRY_TOPIC); + } + + public String getDeviceAttributesTopic() { + return StringUtils.notBlankOrDefault(deviceAttributesTopic, MqttTopics.DEVICE_ATTRIBUTES_TOPIC); + } + + public String getDeviceAttributesSubscribeTopic() { + return StringUtils.notBlankOrDefault(deviceAttributesSubscribeTopic, MqttTopics.DEVICE_ATTRIBUTES_TOPIC); + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/cache/NotificationRuleCacheKey.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java similarity index 54% rename from dao/src/main/java/org/thingsboard/server/dao/notification/cache/NotificationRuleCacheKey.java rename to common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java index 27beef3a9a..d5edafca69 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/cache/NotificationRuleCacheKey.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java @@ -13,31 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao.notification.cache; -import lombok.AllArgsConstructor; -import lombok.Builder; +package org.thingsboard.server.common.data.device.profile; + import lombok.Data; import lombok.NoArgsConstructor; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerType; - -import java.io.Serializable; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; @Data -@AllArgsConstructor @NoArgsConstructor -@Builder -public class NotificationRuleCacheKey implements Serializable { - - private static final long serialVersionUID = 5987113265482170L; +public class X509CertificateChainProvisionConfiguration implements DeviceProfileProvisionConfiguration { - private TenantId tenantId; - private NotificationRuleTriggerType triggerType; + private String provisionDeviceSecret; + private String certificateRegExPattern; + private boolean allowCreateNewDevicesByX509Certificate; @Override - public String toString() { - return tenantId + "_" + triggerType; + public DeviceProfileProvisionType getType() { + return DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicKvEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicKvEntry.java index bdb8c69621..0f80f5ef89 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicKvEntry.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicKvEntry.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.kv; import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoXss; import java.util.Objects; import java.util.Optional; @@ -23,6 +24,7 @@ import java.util.Optional; public abstract class BasicKvEntry implements KvEntry { @Length(fieldName = "attribute key") + @NoXss private final String key; protected BasicKvEntry(String key) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicTsKvEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicTsKvEntry.java index fc64eed1ff..3c612d3b78 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicTsKvEntry.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicTsKvEntry.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.kv; +import javax.validation.Valid; import java.util.Objects; import java.util.Optional; public class BasicTsKvEntry implements TsKvEntry { private static final int MAX_CHARS_PER_DATA_POINT = 512; protected final long ts; + @Valid private final KvEntry kv; public BasicTsKvEntry(long ts, KvEntry kv) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/JsonDataEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/JsonDataEntry.java index 95712bdd70..0736b50b84 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/JsonDataEntry.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/JsonDataEntry.java @@ -19,6 +19,7 @@ import java.util.Objects; import java.util.Optional; public class JsonDataEntry extends BasicKvEntry { + private final String value; public JsonDataEntry(String key, String value) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/StringDataEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/StringDataEntry.java index 06a2b5cac6..18a54327a2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/StringDataEntry.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/StringDataEntry.java @@ -65,7 +65,7 @@ public class StringDataEntry extends BasicKvEntry { public String toString() { return "StringDataEntry{" + "value='" + value + '\'' + "} " + super.toString(); } - + @Override public String getValueAsString() { return value; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/NewPlatformVersionNotificationInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/NewPlatformVersionNotificationInfo.java index cc7133d5f4..a7edeecde4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/NewPlatformVersionNotificationInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/NewPlatformVersionNotificationInfo.java @@ -19,7 +19,6 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import org.thingsboard.server.common.data.UpdateMessage; import java.util.Map; @@ -31,11 +30,22 @@ import static org.thingsboard.server.common.data.util.CollectionsUtil.mapOf; @Builder public class NewPlatformVersionNotificationInfo implements RuleOriginatedNotificationInfo { - private Map message; + private String latestVersion; + private String latestVersionReleaseNotesUrl; + private String upgradeInstructionsUrl; + + private String currentVersion; + private String currentVersionReleaseNotesUrl; @Override public Map getTemplateData() { - return message; + return mapOf( + "latestVersion", latestVersion, + "latestVersionReleaseNotesUrl", latestVersionReleaseNotesUrl, + "upgradeInstructionsUrl", upgradeInstructionsUrl, + "currentVersion", currentVersion, + "currentVersionReleaseNotesUrl", currentVersionReleaseNotesUrl + ); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java index 529bc9a3ab..450fbdb23f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.id.NotificationTemplateId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerType; +import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; import javax.validation.Valid; @@ -43,6 +44,7 @@ public class NotificationRule extends BaseData implements Ha private TenantId tenantId; @NotBlank @NoXss + @Length(max = 255, message = "cannot be longer than 255 chars") private String name; @NotNull private NotificationTemplateId templateId; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmAssignmentNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmAssignmentNotificationRuleTriggerConfig.java index 8bcb6387e2..59ad9e5316 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmAssignmentNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmAssignmentNotificationRuleTriggerConfig.java @@ -15,7 +15,10 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSeverity; @@ -23,6 +26,9 @@ import javax.validation.constraints.NotEmpty; import java.util.Set; @Data +@AllArgsConstructor +@NoArgsConstructor +@Builder public class AlarmAssignmentNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { private Set alarmTypes; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmCommentNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmCommentNotificationRuleTriggerConfig.java index 237dce5282..c34127b611 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmCommentNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmCommentNotificationRuleTriggerConfig.java @@ -15,13 +15,19 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSeverity; import java.util.Set; @Data +@AllArgsConstructor +@NoArgsConstructor +@Builder public class AlarmCommentNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { private Set alarmTypes; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmNotificationRuleTriggerConfig.java index 4b17c1da8f..6118e9da00 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmNotificationRuleTriggerConfig.java @@ -15,7 +15,10 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSeverity; @@ -24,6 +27,9 @@ import java.io.Serializable; import java.util.Set; @Data +@AllArgsConstructor +@NoArgsConstructor +@Builder public class AlarmNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { private Set alarmTypes; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/ApiUsageLimitNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/ApiUsageLimitNotificationRuleTriggerConfig.java index ca2fa01cae..d5ce9dff68 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/ApiUsageLimitNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/ApiUsageLimitNotificationRuleTriggerConfig.java @@ -15,13 +15,19 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.ApiFeature; import org.thingsboard.server.common.data.ApiUsageStateValue; import java.util.Set; @Data +@AllArgsConstructor +@NoArgsConstructor +@Builder public class ApiUsageLimitNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { private Set apiFeatures; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/DeviceActivityNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/DeviceActivityNotificationRuleTriggerConfig.java index c55ebc2f6e..8deeada7c0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/DeviceActivityNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/DeviceActivityNotificationRuleTriggerConfig.java @@ -15,13 +15,19 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import javax.validation.constraints.NotEmpty; import java.util.Set; import java.util.UUID; @Data +@AllArgsConstructor +@NoArgsConstructor +@Builder public class DeviceActivityNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { private Set devices; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EntitiesLimitNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EntitiesLimitNotificationRuleTriggerConfig.java index b799d4b81c..a40ddfb081 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EntitiesLimitNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EntitiesLimitNotificationRuleTriggerConfig.java @@ -15,13 +15,19 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.EntityType; import javax.validation.constraints.Max; import java.util.Set; @Data +@AllArgsConstructor +@NoArgsConstructor +@Builder public class EntitiesLimitNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { private Set entityTypes; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EntityActionNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EntityActionNotificationRuleTriggerConfig.java index e597d390b8..3a627a9e56 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EntityActionNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EntityActionNotificationRuleTriggerConfig.java @@ -15,12 +15,18 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.EntityType; import java.util.Set; @Data +@AllArgsConstructor +@NoArgsConstructor +@Builder public class EntityActionNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { private Set entityTypes; // maybe add name filter ? diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/NewPlatformVersionNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/NewPlatformVersionNotificationRuleTriggerConfig.java index 5473288f94..230be3b9d5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/NewPlatformVersionNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/NewPlatformVersionNotificationRuleTriggerConfig.java @@ -20,8 +20,6 @@ import lombok.Data; @Data public class NewPlatformVersionNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { - // TODO: don't forget to create default notification configs - @Override public NotificationRuleTriggerType getTriggerType() { return NotificationRuleTriggerType.NEW_PLATFORM_VERSION; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig.java index 130f6d4f61..c28e190cc9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig.java @@ -15,13 +15,19 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import java.util.Set; import java.util.UUID; @Data +@AllArgsConstructor +@NoArgsConstructor +@Builder public class RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { private Set ruleChains; // if empty - all rule chains diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java index 9f267a514d..9a2f9a5306 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.id.NotificationTargetId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; import javax.validation.Valid; @@ -35,6 +36,7 @@ public class NotificationTarget extends BaseData implement private TenantId tenantId; @NotBlank @NoXss + @Length(max = 255, message = "cannot be longer than 255 chars") private String name; @NotNull @Valid diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java index 2333062f65..231e419fb0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java @@ -23,6 +23,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; import lombok.Data; import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; import org.thingsboard.server.common.data.notification.targets.slack.SlackNotificationTargetConfig; +import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; @JsonIgnoreProperties(ignoreUnknown = true) @@ -35,6 +36,7 @@ import org.thingsboard.server.common.data.validation.NoXss; public abstract class NotificationTargetConfig { @NoXss + @Length(max = 500, message = "cannot be longer than 500 chars") private String description; @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetType.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetType.java index 0c2441e37d..1254654ecf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetType.java @@ -19,6 +19,7 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import java.util.Arrays; import java.util.Set; @RequiredArgsConstructor @@ -30,4 +31,10 @@ public enum NotificationTargetType { @Getter private final Set supportedDeliveryMethods; + public static NotificationTargetType forDeliveryMethod(NotificationDeliveryMethod deliveryMethod) { + return Arrays.stream(values()) + .filter(targetType -> targetType.getSupportedDeliveryMethods().contains(deliveryMethod)) + .findFirst().orElse(null); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java index bf8aca7913..fe909a104e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java @@ -21,6 +21,8 @@ import lombok.NoArgsConstructor; import lombok.ToString; import org.apache.commons.lang3.StringUtils; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoXss; import javax.validation.constraints.NotEmpty; @@ -30,6 +32,8 @@ import javax.validation.constraints.NotEmpty; @ToString(callSuper = true) public class EmailDeliveryMethodNotificationTemplate extends DeliveryMethodNotificationTemplate implements HasSubject { + @NoXss(fieldName = "email subject") + @Length(fieldName = "email subject", max = 250, message = "cannot be longer than 250 chars") @NotEmpty private String subject; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplate.java index 2eaff12a9b..71da2f862b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplate.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.id.NotificationTemplateId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.NotificationType; +import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; import javax.validation.Valid; @@ -36,6 +37,7 @@ public class NotificationTemplate extends BaseData imple private TenantId tenantId; @NoXss @NotEmpty + @Length(max = 255, message = "cannot be longer than 255 chars") private String name; @NoXss @NotNull diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SlackDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SlackDeliveryMethodNotificationTemplate.java index 735eb3660e..8457677308 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SlackDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SlackDeliveryMethodNotificationTemplate.java @@ -20,6 +20,7 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.ToString; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import org.thingsboard.server.common.data.validation.NoXss; @Data @NoArgsConstructor @@ -31,6 +32,12 @@ public class SlackDeliveryMethodNotificationTemplate extends DeliveryMethodNotif super(other); } + @NoXss(fieldName = "Slack message") + @Override + public String getBody() { + return super.getBody(); + } + @Override public NotificationDeliveryMethod getMethod() { return NotificationDeliveryMethod.SLACK; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SmsDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SmsDeliveryMethodNotificationTemplate.java index 55d2582284..7dc2e494f3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SmsDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SmsDeliveryMethodNotificationTemplate.java @@ -20,6 +20,8 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.ToString; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoXss; @Data @NoArgsConstructor @@ -31,6 +33,13 @@ public class SmsDeliveryMethodNotificationTemplate extends DeliveryMethodNotific super(other); } + @NoXss(fieldName = "SMS message") + @Length(fieldName = "SMS message", max = 320, message = "cannot be longer than 320 chars") + @Override + public String getBody() { + return super.getBody(); + } + @Override public NotificationDeliveryMethod getMethod() { return NotificationDeliveryMethod.SMS; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java index ac1e0cfd8c..f26ed00568 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java @@ -25,6 +25,8 @@ import lombok.NoArgsConstructor; import lombok.ToString; import org.apache.commons.lang3.StringUtils; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoXss; import javax.validation.constraints.NotEmpty; import java.util.Optional; @@ -35,6 +37,8 @@ import java.util.Optional; @ToString(callSuper = true) public class WebDeliveryMethodNotificationTemplate extends DeliveryMethodNotificationTemplate implements HasSubject { + @NoXss(fieldName = "web notification subject") + @Length(fieldName = "web notification subject", max = 150, message = "cannot be longer than 150 chars") @NotEmpty private String subject; private JsonNode additionalConfig; @@ -45,6 +49,15 @@ public class WebDeliveryMethodNotificationTemplate extends DeliveryMethodNotific this.additionalConfig = other.additionalConfig != null ? other.additionalConfig.deepCopy() : null; } + @NoXss(fieldName = "web notification message") + @Length(fieldName = "web notification message", max = 250, message = "cannot be longer than 250 chars") + @Override + public String getBody() { + return super.getBody(); + } + + @NoXss(fieldName = "web notification button text") + @Length(fieldName = "web notification button text", max = 50, message = "cannot be longer than 50 chars") @JsonIgnore public String getButtonText() { return getButtonConfigProperty("text"); @@ -57,6 +70,8 @@ public class WebDeliveryMethodNotificationTemplate extends DeliveryMethodNotific }); } + @NoXss(fieldName = "web notification button link") + @Length(fieldName = "web notification button link", max = 300, message = "cannot be longer than 300 chars") @JsonIgnore public String getButtonLink() { return getButtonConfigProperty("link"); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AssetTypeFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AssetTypeFilter.java index 9b875cf42c..f666c02777 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AssetTypeFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AssetTypeFilter.java @@ -15,18 +15,38 @@ */ package org.thingsboard.server.common.data.query; -import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.springframework.util.CollectionUtils; -@Data +import java.util.Collections; +import java.util.List; + +@ToString +@EqualsAndHashCode +@Setter public class AssetTypeFilter implements EntityFilter { + /** + * Replaced by {@link AssetTypeFilter#getAssetTypes()} instead. + */ + @Deprecated(since = "3.5", forRemoval = true) + private String assetType; + + private List assetTypes; + + public List getAssetTypes() { + return !CollectionUtils.isEmpty(assetTypes) ? assetTypes : Collections.singletonList(assetType); + } + + @Getter + private String assetNameFilter; + @Override public EntityFilterType getType() { return EntityFilterType.ASSET_TYPE; } - private String assetType; - - private String assetNameFilter; - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/DeviceTypeFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/DeviceTypeFilter.java index 0c502dff8b..bc25d1d158 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/DeviceTypeFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/DeviceTypeFilter.java @@ -16,21 +16,45 @@ package org.thingsboard.server.common.data.query; import lombok.AllArgsConstructor; -import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.springframework.util.CollectionUtils; + +import java.util.Collections; +import java.util.List; @NoArgsConstructor @AllArgsConstructor -@Data +@ToString +@EqualsAndHashCode +@Setter public class DeviceTypeFilter implements EntityFilter { - @Override - public EntityFilterType getType() { - return EntityFilterType.DEVICE_TYPE; - } - + /** + * Replaced by {@link DeviceTypeFilter#getDeviceTypes()} instead. + */ + @Deprecated(since = "3.5", forRemoval = true) private String deviceType; + private List deviceTypes; + + public List getDeviceTypes() { + return !CollectionUtils.isEmpty(deviceTypes) ? deviceTypes : Collections.singletonList(deviceType); + } + + @Getter private String deviceNameFilter; + public DeviceTypeFilter(List deviceTypes, String deviceNameFilter) { + this.deviceTypes = deviceTypes; + this.deviceNameFilter = deviceNameFilter; + } + + @Override + public EntityFilterType getType() { + return EntityFilterType.DEVICE_TYPE; + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EdgeTypeFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EdgeTypeFilter.java index 8a1f325354..306aae43cc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EdgeTypeFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EdgeTypeFilter.java @@ -15,18 +15,38 @@ */ package org.thingsboard.server.common.data.query; -import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.springframework.util.CollectionUtils; -@Data +import java.util.Collections; +import java.util.List; + +@ToString +@EqualsAndHashCode +@Setter public class EdgeTypeFilter implements EntityFilter { + /** + * Replaced by {@link EdgeTypeFilter#getEdgeTypes()} instead. + */ + @Deprecated(since = "3.5", forRemoval = true) + private String edgeType; + + private List edgeTypes; + + public List getEdgeTypes() { + return !CollectionUtils.isEmpty(edgeTypes) ? edgeTypes : Collections.singletonList(edgeType); + } + + @Getter + private String edgeNameFilter; + @Override public EntityFilterType getType() { return EntityFilterType.EDGE_TYPE; } - private String edgeType; - - private String edgeNameFilter; - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityViewTypeFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityViewTypeFilter.java index b9c5e00767..8025cf43b5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityViewTypeFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityViewTypeFilter.java @@ -15,18 +15,38 @@ */ package org.thingsboard.server.common.data.query; -import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.springframework.util.CollectionUtils; -@Data +import java.util.Collections; +import java.util.List; + +@ToString +@EqualsAndHashCode +@Setter public class EntityViewTypeFilter implements EntityFilter { + /** + * Replaced by {@link EntityViewTypeFilter#getEntityViewTypes()} instead. + */ + @Deprecated(since = "3.5", forRemoval = true) + private String entityViewType; + + private List entityViewTypes; + + public List getEntityViewTypes() { + return !CollectionUtils.isEmpty(entityViewTypes) ? entityViewTypes : Collections.singletonList(entityViewType); + } + + @Getter + private String entityViewNameFilter; + @Override public EntityFilterType getType() { return EntityFilterType.ENTITY_VIEW_TYPE; } - private String entityViewType; - - private String entityViewNameFilter; - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettingsType.java b/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettingsType.java index 97ef85a5f2..b19dbbbaee 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettingsType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettingsType.java @@ -19,7 +19,7 @@ import lombok.Getter; public enum UserSettingsType { - GENERAL, VISITED_DASHBOARDS(true), QUICK_LINKS, DOC_LINKS, DASHBOARDS; + GENERAL, VISITED_DASHBOARDS(true), QUICK_LINKS, DOC_LINKS, DASHBOARDS, GETTING_STARTED; @Getter private final boolean reserved; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index 030587f50f..66fba5f832 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -48,6 +48,7 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private String tenantEntityExportRateLimit; private String tenantEntityImportRateLimit; private String tenantNotificationRequestsRateLimit; + private String tenantNotificationRequestsPerRuleRateLimit; private long maxTransportMessages; private long maxTransportDataPoints; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/validation/Length.java b/common/data/src/main/java/org/thingsboard/server/common/data/validation/Length.java index 2b412d0ad7..cb7b9b1693 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/validation/Length.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/validation/Length.java @@ -23,12 +23,12 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.FIELD) +@Target({ElementType.FIELD, ElementType.METHOD}) @Constraint(validatedBy = {}) public @interface Length { String message() default "length must be equal or less than {max}"; - String fieldName(); + String fieldName() default ""; int max() default 255; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoXss.java b/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoXss.java index c99502ed7b..fb7b048f7e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoXss.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoXss.java @@ -23,12 +23,16 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.FIELD) +@Target({ElementType.FIELD, ElementType.METHOD}) @Constraint(validatedBy = {}) public @interface NoXss { + String message() default "is malformed"; + String fieldName() default ""; + Class[] groups() default {}; Class[] payload() default {}; + } diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java index 750c285317..4e8548aa33 100644 --- a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java +++ b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java @@ -20,6 +20,7 @@ import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; import io.grpc.stub.StreamObserver; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -56,12 +57,18 @@ public class EdgeGrpcClient implements EdgeRpcClient { private int rpcPort; @Value("${cloud.rpc.timeout}") private int timeoutSecs; - @Value("${cloud.rpc.keep_alive_time_sec}") + @Value("${cloud.rpc.keep_alive_time_sec:10}") private int keepAliveTimeSec; + @Value("${cloud.rpc.keep_alive_timeout_sec:5}") + private int keepAliveTimeoutSec; @Value("${cloud.rpc.ssl.enabled}") private boolean sslEnabled; @Value("${cloud.rpc.ssl.cert:}") private String certResource; + @Value("${cloud.rpc.max_inbound_message_size:4194304}") + private int maxInboundMessageSize; + @Getter + private int serverMaxInboundMessageSize; private ManagedChannel channel; @@ -77,7 +84,10 @@ public class EdgeGrpcClient implements EdgeRpcClient { Consumer onDownlink, Consumer onError) { NettyChannelBuilder builder = NettyChannelBuilder.forAddress(rpcHost, rpcPort) - .keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS); + .maxInboundMessageSize(maxInboundMessageSize) + .keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS) + .keepAliveTimeout(keepAliveTimeoutSec, TimeUnit.SECONDS) + .keepAliveWithoutCalls(true); if (sslEnabled) { try { SslContextBuilder sslContextBuilder = GrpcSslContexts.forClient(); @@ -101,7 +111,8 @@ public class EdgeGrpcClient implements EdgeRpcClient { .setConnectRequestMsg(ConnectRequestMsg.newBuilder() .setEdgeRoutingKey(edgeKey) .setEdgeSecret(edgeSecret) - .setEdgeVersion(EdgeVersion.V_3_3_3) + .setEdgeVersion(EdgeVersion.V_3_4_0) + .setMaxInboundMessageSize(maxInboundMessageSize) .build()) .build()); } @@ -117,6 +128,10 @@ public class EdgeGrpcClient implements EdgeRpcClient { if (responseMsg.hasConnectResponseMsg()) { ConnectResponseMsg connectResponseMsg = responseMsg.getConnectResponseMsg(); if (connectResponseMsg.getResponseCode().equals(ConnectResponseCode.ACCEPTED)) { + if (connectResponseMsg.hasMaxInboundMessageSize()) { + log.debug("[{}] Server max inbound message size: {}", edgeKey, connectResponseMsg.getMaxInboundMessageSize()); + serverMaxInboundMessageSize = connectResponseMsg.getMaxInboundMessageSize(); + } log.info("[{}] Configuration received: {}", edgeKey, connectResponseMsg.getConfiguration()); onEdgeUpdate.accept(connectResponseMsg.getConfiguration()); } else { diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeRpcClient.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeRpcClient.java index 5fc4e86c60..44d00e22a8 100644 --- a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeRpcClient.java +++ b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeRpcClient.java @@ -41,4 +41,6 @@ public interface EdgeRpcClient { void sendUplinkMsg(UplinkMsg uplinkMsg); void sendDownlinkResponseMsg(DownlinkResponseMsg downlinkResponseMsg); + + int getServerMaxInboundMessageSize(); } diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index 76fc1be7d1..afcf4056c4 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -68,6 +68,7 @@ message ConnectRequestMsg { string edgeRoutingKey = 1; string edgeSecret = 2; EdgeVersion edgeVersion = 3; + optional int32 maxInboundMessageSize = 4; } enum ConnectResponseCode { @@ -80,6 +81,7 @@ message ConnectResponseMsg { ConnectResponseCode responseCode = 1; string errorMsg = 2; EdgeConfiguration configuration = 3; + optional int32 maxInboundMessageSize = 4; } message SyncRequestMsg { diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java index 2c01e11712..e944a8dd92 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java @@ -35,6 +35,14 @@ public class EncryptionUtil { .replaceAll("-----END CERTIFICATE-----", ""); } + public static String certTrimNewLinesForChainInDeviceProfile(String input) { + return input.replaceAll("\n", "") + .replaceAll("\r", "") + .replaceAll("-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----\n") + .replaceAll("-----END CERTIFICATE-----", "\n-----END CERTIFICATE-----\n") + .trim(); + } + public static String pubkTrimNewLines(String input) { return input.replaceAll("-----BEGIN PUBLIC KEY-----", "") .replaceAll("\n", "") diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/notification/trigger/EntitiesLimitTrigger.java b/common/message/src/main/java/org/thingsboard/server/common/msg/notification/trigger/EntitiesLimitTrigger.java index ce6505fe13..e6ce143fd0 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/notification/trigger/EntitiesLimitTrigger.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/notification/trigger/EntitiesLimitTrigger.java @@ -28,8 +28,9 @@ public class EntitiesLimitTrigger implements NotificationRuleTrigger { private final TenantId tenantId; private final EntityType entityType; - private final long currentCount; - private final long limit; + + private long limit; + private long currentCount; @Override public NotificationRuleTriggerType getType() { diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/notification/trigger/NewPlatformVersionTrigger.java b/common/message/src/main/java/org/thingsboard/server/common/msg/notification/trigger/NewPlatformVersionTrigger.java index d6428b7467..0da2f0c57b 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/notification/trigger/NewPlatformVersionTrigger.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/notification/trigger/NewPlatformVersionTrigger.java @@ -26,7 +26,7 @@ import org.thingsboard.server.common.data.notification.rule.trigger.Notification @Builder public class NewPlatformVersionTrigger implements NotificationRuleTrigger { - private final UpdateMessage message; + private final UpdateMessage updateInfo; @Override public NotificationRuleTriggerType getType() { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/azure/servicebus/TbServiceBusQueueConfigs.java b/common/queue/src/main/java/org/thingsboard/server/queue/azure/servicebus/TbServiceBusQueueConfigs.java index 6c56d55d36..5e404bc928 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/azure/servicebus/TbServiceBusQueueConfigs.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/azure/servicebus/TbServiceBusQueueConfigs.java @@ -19,10 +19,9 @@ import lombok.Getter; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.queue.util.PropertyUtils; import javax.annotation.PostConstruct; -import java.util.HashMap; import java.util.Map; @Component @@ -55,24 +54,12 @@ public class TbServiceBusQueueConfigs { @PostConstruct private void init() { - coreConfigs = getConfigs(coreProperties); - ruleEngineConfigs = getConfigs(ruleEngineProperties); - transportApiConfigs = getConfigs(transportApiProperties); - notificationsConfigs = getConfigs(notificationsProperties); - jsExecutorConfigs = getConfigs(jsExecutorProperties); - vcConfigs = getConfigs(vcProperties); + coreConfigs = PropertyUtils.getProps(coreProperties); + ruleEngineConfigs = PropertyUtils.getProps(ruleEngineProperties); + transportApiConfigs = PropertyUtils.getProps(transportApiProperties); + notificationsConfigs = PropertyUtils.getProps(notificationsProperties); + jsExecutorConfigs = PropertyUtils.getProps(jsExecutorProperties); + vcConfigs = PropertyUtils.getProps(vcProperties); } - private Map getConfigs(String properties) { - Map configs = new HashMap<>(); - if (StringUtils.isNotEmpty(properties)) { - for (String property : properties.split(";")) { - int delimiterPosition = property.indexOf(":"); - String key = property.substring(0, delimiterPosition); - String value = property.substring(delimiterPosition + 1); - configs.put(key, value); - } - } - return configs; - } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java index 92c4202a79..7ed188491b 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java @@ -19,6 +19,7 @@ import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.CommonClientConfigs; +import org.apache.kafka.common.config.SslConfigs; import org.apache.kafka.clients.admin.AdminClientConfig; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.clients.producer.ProducerConfig; @@ -31,6 +32,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.TbProperty; +import org.thingsboard.server.queue.util.PropertyUtils; import java.util.Collections; import java.util.List; @@ -49,19 +51,37 @@ public class TbKafkaSettings { @Value("${queue.kafka.bootstrap.servers}") private String servers; - @Value("${queue.kafka.acks}") + @Value("${queue.kafka.ssl.enabled:false}") + private boolean sslEnabled; + + @Value("${queue.kafka.ssl.truststore.location:}") + private String sslTruststoreLocation; + + @Value("${queue.kafka.ssl.truststore.password:}") + private String sslTruststorePassword; + + @Value("${queue.kafka.ssl.keystore.location:}") + private String sslKeystoreLocation; + + @Value("${queue.kafka.ssl.keystore.password:}") + private String sslKeystorePassword; + + @Value("${queue.kafka.ssl.key.password:}") + private String sslKeyPassword; + + @Value("${queue.kafka.acks:all}") private String acks; - @Value("${queue.kafka.retries}") + @Value("${queue.kafka.retries:1}") private int retries; @Value("${queue.kafka.compression.type:none}") private String compressionType; - @Value("${queue.kafka.batch.size}") + @Value("${queue.kafka.batch.size:16384}") private int batchSize; - @Value("${queue.kafka.linger.ms}") + @Value("${queue.kafka.linger.ms:1}") private long lingerMs; @Value("${queue.kafka.max.request.size:1048576}") @@ -70,10 +90,10 @@ public class TbKafkaSettings { @Value("${queue.kafka.max.in.flight.requests.per.connection:5}") private int maxInFlightRequestsPerConnection; - @Value("${queue.kafka.buffer.memory}") + @Value("${queue.kafka.buffer.memory:33554432}") private long bufferMemory; - @Value("${queue.kafka.replication_factor}") + @Value("${queue.kafka.replication_factor:1}") @Getter private short replicationFactor; @@ -89,21 +109,31 @@ public class TbKafkaSettings { @Value("${queue.kafka.fetch_max_bytes:134217728}") private int fetchMaxBytes; + @Value("${queue.kafka.request.timeout.ms:30000}") + private int requestTimeoutMs; + + @Value("${queue.kafka.session.timeout.ms:10000}") + private int sessionTimeoutMs; + @Value("${queue.kafka.use_confluent_cloud:false}") private boolean useConfluent; - @Value("${queue.kafka.confluent.ssl.algorithm}") + @Value("${queue.kafka.confluent.ssl.algorithm:}") private String sslAlgorithm; - @Value("${queue.kafka.confluent.sasl.mechanism}") + @Value("${queue.kafka.confluent.sasl.mechanism:}") private String saslMechanism; - @Value("${queue.kafka.confluent.sasl.config}") + @Value("${queue.kafka.confluent.sasl.config:}") private String saslConfig; - @Value("${queue.kafka.confluent.security.protocol}") + @Value("${queue.kafka.confluent.security.protocol:}") private String securityProtocol; + @Value("${queue.kafka.other-inline:}") + private String otherInline; + + @Deprecated @Setter private List other; @@ -151,7 +181,7 @@ public class TbKafkaSettings { return props; } - private Properties toProps() { + Properties toProps() { Properties props = new Properties(); if (useConfluent) { @@ -161,10 +191,29 @@ public class TbKafkaSettings { props.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, securityProtocol); } + props.put(CommonClientConfigs.REQUEST_TIMEOUT_MS_CONFIG, requestTimeoutMs); + props.put(CommonClientConfigs.SESSION_TIMEOUT_MS_CONFIG, sessionTimeoutMs); + + props.putAll(PropertyUtils.getProps(otherInline)); + if (other != null) { other.forEach(kv -> props.put(kv.getKey(), kv.getValue())); } + + configureSSL(props); + return props; } + void configureSSL(Properties props) { + if (sslEnabled) { + props.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "SSL"); + props.put(SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG, sslTruststoreLocation); + props.put(SslConfigs.SSL_TRUSTSTORE_PASSWORD_CONFIG, sslTruststorePassword); + props.put(SslConfigs.SSL_KEYSTORE_LOCATION_CONFIG, sslKeystoreLocation); + props.put(SslConfigs.SSL_KEYSTORE_PASSWORD_CONFIG, sslKeystorePassword); + props.put(SslConfigs.SSL_KEY_PASSWORD_CONFIG, sslKeyPassword); + } + } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaTopicConfigs.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaTopicConfigs.java index 64dd6f2720..9888066ee7 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaTopicConfigs.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaTopicConfigs.java @@ -19,10 +19,9 @@ import lombok.Getter; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.queue.util.PropertyUtils; import javax.annotation.PostConstruct; -import java.util.HashMap; import java.util.Map; @Component @@ -66,29 +65,17 @@ public class TbKafkaTopicConfigs { @PostConstruct private void init() { - coreConfigs = getConfigs(coreProperties); - ruleEngineConfigs = getConfigs(ruleEngineProperties); - transportApiRequestConfigs = getConfigs(transportApiProperties); - transportApiResponseConfigs = getConfigs(transportApiProperties); + coreConfigs = PropertyUtils.getProps(coreProperties); + ruleEngineConfigs = PropertyUtils.getProps(ruleEngineProperties); + transportApiRequestConfigs = PropertyUtils.getProps(transportApiProperties); + transportApiResponseConfigs = PropertyUtils.getProps(transportApiProperties); transportApiResponseConfigs.put(NUM_PARTITIONS_SETTING, "1"); - notificationsConfigs = getConfigs(notificationsProperties); - jsExecutorRequestConfigs = getConfigs(jsExecutorProperties); - jsExecutorResponseConfigs = getConfigs(jsExecutorProperties); + notificationsConfigs = PropertyUtils.getProps(notificationsProperties); + jsExecutorRequestConfigs = PropertyUtils.getProps(jsExecutorProperties); + jsExecutorResponseConfigs = PropertyUtils.getProps(jsExecutorProperties); jsExecutorResponseConfigs.put(NUM_PARTITIONS_SETTING, "1"); - fwUpdatesConfigs = getConfigs(fwUpdatesProperties); - vcConfigs = getConfigs(vcProperties); + fwUpdatesConfigs = PropertyUtils.getProps(fwUpdatesProperties); + vcConfigs = PropertyUtils.getProps(vcProperties); } - private Map getConfigs(String properties) { - Map configs = new HashMap<>(); - if (StringUtils.isNotEmpty(properties)) { - for (String property : properties.split(";")) { - int delimiterPosition = property.indexOf(":"); - String key = property.substring(0, delimiterPosition); - String value = property.substring(delimiterPosition + 1); - configs.put(key, value); - } - } - return configs; - } } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/notification/NotificationRuleProcessor.java b/common/queue/src/main/java/org/thingsboard/server/queue/notification/NotificationRuleProcessor.java similarity index 93% rename from common/message/src/main/java/org/thingsboard/server/common/msg/notification/NotificationRuleProcessor.java rename to common/queue/src/main/java/org/thingsboard/server/queue/notification/NotificationRuleProcessor.java index 380773c1b0..1fa9d82863 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/notification/NotificationRuleProcessor.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/notification/NotificationRuleProcessor.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.notification; +package org.thingsboard.server.queue.notification; import org.thingsboard.server.common.msg.notification.trigger.NotificationRuleTrigger; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/notification/RemoteNotificationRuleProcessor.java b/common/queue/src/main/java/org/thingsboard/server/queue/notification/RemoteNotificationRuleProcessor.java index 2e38266046..3c3988389f 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/notification/RemoteNotificationRuleProcessor.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/notification/RemoteNotificationRuleProcessor.java @@ -17,14 +17,15 @@ package org.thingsboard.server.queue.notification; import com.google.protobuf.ByteString; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.stereotype.Service; -import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; import org.thingsboard.server.common.msg.notification.trigger.NotificationRuleTrigger; 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.NotificationsTopicService; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.util.DataDecodingEncodingService; @@ -32,24 +33,33 @@ import org.thingsboard.server.queue.util.DataDecodingEncodingService; import java.util.UUID; @Service -@ConditionalOnMissingBean(NotificationRuleProcessor.class) +@ConditionalOnMissingBean(value = NotificationRuleProcessor.class, ignored = RemoteNotificationRuleProcessor.class) @RequiredArgsConstructor +@Slf4j public class RemoteNotificationRuleProcessor implements NotificationRuleProcessor { private final TbQueueProducerProvider producerProvider; + private final NotificationsTopicService notificationsTopicService; private final PartitionService partitionService; private final DataDecodingEncodingService encodingService; @Override public void process(NotificationRuleTrigger trigger) { - TransportProtos.NotificationRuleProcessorMsg.Builder msg = TransportProtos.NotificationRuleProcessorMsg.newBuilder() - .setTrigger(ByteString.copyFrom(encodingService.encode(trigger))); + try { + log.trace("Submitting notification rule trigger: {}", trigger); + TransportProtos.NotificationRuleProcessorMsg.Builder msg = TransportProtos.NotificationRuleProcessorMsg.newBuilder() + .setTrigger(ByteString.copyFrom(encodingService.encode(trigger))); - TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, trigger.getTenantId(), trigger.getOriginatorEntityId()); - producerProvider.getTbCoreMsgProducer().send(tpi, new TbProtoQueueMsg<>(UUID.randomUUID(), - TransportProtos.ToCoreMsg.newBuilder() - .setNotificationRuleProcessorMsg(msg) - .build()), null); + partitionService.getAllServiceIds(ServiceType.TB_CORE).stream().findAny().ifPresent(serviceId -> { + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_CORE, serviceId); + producerProvider.getTbCoreNotificationsMsgProducer().send(tpi, new TbProtoQueueMsg<>(UUID.randomUUID(), + TransportProtos.ToCoreNotificationMsg.newBuilder() + .setNotificationRuleProcessorMsg(msg) + .build()), null); + }); + } catch (Exception e) { + log.error("Failed to submit notification rule trigger: {}", trigger, e); + } } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubSubscriptionSettings.java b/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubSubscriptionSettings.java index 0a65c7ca7d..3d7ab85ecc 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubSubscriptionSettings.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubSubscriptionSettings.java @@ -19,10 +19,9 @@ import lombok.Getter; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.queue.util.PropertyUtils; import javax.annotation.PostConstruct; -import java.util.HashMap; import java.util.Map; @Component @@ -56,24 +55,12 @@ public class TbPubSubSubscriptionSettings { @PostConstruct private void init() { - coreSettings = getSettings(coreProperties); - ruleEngineSettings = getSettings(ruleEngineProperties); - transportApiSettings = getSettings(transportApiProperties); - notificationsSettings = getSettings(notificationsProperties); - jsExecutorSettings = getSettings(jsExecutorProperties); - vcSettings = getSettings(vcProperties); + coreSettings = PropertyUtils.getProps(coreProperties); + ruleEngineSettings = PropertyUtils.getProps(ruleEngineProperties); + transportApiSettings = PropertyUtils.getProps(transportApiProperties); + notificationsSettings = PropertyUtils.getProps(notificationsProperties); + jsExecutorSettings = PropertyUtils.getProps(jsExecutorProperties); + vcSettings = PropertyUtils.getProps(vcProperties); } - private Map getSettings(String properties) { - Map configs = new HashMap<>(); - if (StringUtils.isNotEmpty(properties)) { - for (String property : properties.split(";")) { - int delimiterPosition = property.indexOf(":"); - String key = property.substring(0, delimiterPosition); - String value = property.substring(delimiterPosition + 1); - configs.put(key, value); - } - } - return configs; - } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java b/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java index a86147a37e..5dfaa70e12 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java @@ -16,11 +16,11 @@ package org.thingsboard.server.queue.usagestats; import lombok.Data; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.ApiUsageRecordKey; -import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; @@ -32,12 +32,12 @@ import org.thingsboard.server.gen.transport.TransportProtos.UsageStatsKVProto; import org.thingsboard.server.queue.TbQueueProducer; 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.queue.scheduler.SchedulerComponent; import javax.annotation.PostConstruct; import java.util.EnumMap; -import java.util.Optional; import java.util.Random; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -47,6 +47,7 @@ import java.util.concurrent.atomic.AtomicLong; @Component @Slf4j +@RequiredArgsConstructor public class DefaultTbApiUsageReportClient implements TbApiUsageReportClient { @Value("${usage.stats.report.enabled:true}") @@ -56,19 +57,14 @@ public class DefaultTbApiUsageReportClient implements TbApiUsageReportClient { @Value("${usage.stats.report.interval:10}") private int interval; - private final EnumMap> stats = new EnumMap<>(ApiUsageRecordKey.class); + private final EnumMap> stats = new EnumMap<>(ApiUsageRecordKey.class); private final PartitionService partitionService; + private final TbServiceInfoProvider serviceInfoProvider; private final SchedulerComponent scheduler; private final TbQueueProducerProvider producerProvider; private TbQueueProducer> msgProducer; - public DefaultTbApiUsageReportClient(PartitionService partitionService, SchedulerComponent scheduler, TbQueueProducerProvider producerProvider) { - this.partitionService = partitionService; - this.scheduler = scheduler; - this.producerProvider = producerProvider; - } - @PostConstruct private void init() { if (enabled) { @@ -87,42 +83,48 @@ public class DefaultTbApiUsageReportClient implements TbApiUsageReportClient { } private void reportStats() { - ConcurrentMap report = new ConcurrentHashMap<>(); + ConcurrentMap report = new ConcurrentHashMap<>(); for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) { - ConcurrentMap statsForKey = stats.get(key); - statsForKey.forEach((ownerId, statsValue) -> { + ConcurrentMap statsForKey = stats.get(key); + statsForKey.forEach((reportLevel, statsValue) -> { long value = statsValue.get(); - if (value == 0) return; + if (value == 0 && key.isCounter()) return; - ToUsageStatsServiceMsg.Builder statsMsgBuilder = report.computeIfAbsent(ownerId, id -> { - ToUsageStatsServiceMsg.Builder newStatsMsgBuilder = ToUsageStatsServiceMsg.newBuilder(); + ToUsageStatsServiceMsg.Builder statsMsg = report.computeIfAbsent(reportLevel.getParentEntity(), parent -> { + ToUsageStatsServiceMsg.Builder newStatsMsg = ToUsageStatsServiceMsg.newBuilder(); - TenantId tenantId = ownerId.getTenantId(); - newStatsMsgBuilder.setTenantIdMSB(tenantId.getId().getMostSignificantBits()); - newStatsMsgBuilder.setTenantIdLSB(tenantId.getId().getLeastSignificantBits()); + TenantId tenantId = parent.getTenantId(); + newStatsMsg.setTenantIdMSB(tenantId.getId().getMostSignificantBits()); + newStatsMsg.setTenantIdLSB(tenantId.getId().getLeastSignificantBits()); - EntityId entityId = ownerId.getEntityId(); - if (entityId != null && entityId.getEntityType() == EntityType.CUSTOMER) { - newStatsMsgBuilder.setCustomerIdMSB(entityId.getId().getMostSignificantBits()); - newStatsMsgBuilder.setCustomerIdLSB(entityId.getId().getLeastSignificantBits()); + CustomerId customerId = parent.getCustomerId(); + if (customerId != null) { + newStatsMsg.setCustomerIdMSB(customerId.getId().getMostSignificantBits()); + newStatsMsg.setCustomerIdLSB(customerId.getId().getLeastSignificantBits()); } - return newStatsMsgBuilder; + newStatsMsg.setServiceId(serviceInfoProvider.getServiceId()); + return newStatsMsg; }); - statsMsgBuilder.addValues(UsageStatsKVProto.newBuilder().setKey(key.name()).setValue(value).build()); + UsageStatsKVProto.Builder statsItem = UsageStatsKVProto.newBuilder() + .setKey(key.name()) + .setValue(value); + statsMsg.addValues(statsItem.build()); }); statsForKey.clear(); } - report.forEach(((ownerId, statsMsg) -> { + report.forEach(((parent, statsMsg) -> { //TODO: figure out how to minimize messages into the queue. Maybe group by 100s of messages? - - TenantId tenantId = ownerId.getTenantId(); - EntityId entityId = Optional.ofNullable(ownerId.getEntityId()).orElse(tenantId); - TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId).newByTopic(msgProducer.getDefaultTopic()); - msgProducer.send(tpi, new TbProtoQueueMsg<>(UUID.randomUUID(), statsMsg.build()), null); + try { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, parent.getTenantId(), parent.getId()) + .newByTopic(msgProducer.getDefaultTopic()); + msgProducer.send(tpi, new TbProtoQueueMsg<>(UUID.randomUUID(), statsMsg.build()), null); + } catch (Exception e) { + log.warn("Failed to report usage stats for tenant {}", parent.getTenantId(), e); + } })); if (!report.isEmpty()) { @@ -132,16 +134,17 @@ public class DefaultTbApiUsageReportClient implements TbApiUsageReportClient { @Override public void report(TenantId tenantId, CustomerId customerId, ApiUsageRecordKey key, long value) { - if (enabled) { - ConcurrentMap statsForKey = stats.get(key); - - statsForKey.computeIfAbsent(new OwnerId(tenantId), id -> new AtomicLong()).addAndGet(value); - statsForKey.computeIfAbsent(new OwnerId(TenantId.SYS_TENANT_ID), id -> new AtomicLong()).addAndGet(value); + if (!enabled) return; - if (enabledPerCustomer && customerId != null && !customerId.isNullUid()) { - statsForKey.computeIfAbsent(new OwnerId(tenantId, customerId), id -> new AtomicLong()).addAndGet(value); - } + ReportLevel[] reportLevels = new ReportLevel[3]; + reportLevels[0] = ReportLevel.of(tenantId); + if (key.isCounter()) { + reportLevels[1] = ReportLevel.of(TenantId.SYS_TENANT_ID); + } + if (enabledPerCustomer && customerId != null && !customerId.isNullUid()) { + reportLevels[2] = ReportLevel.of(tenantId, customerId); } + report(key, value, reportLevels); } @Override @@ -149,18 +152,47 @@ public class DefaultTbApiUsageReportClient implements TbApiUsageReportClient { report(tenantId, customerId, key, 1); } + private void report(ApiUsageRecordKey key, long value, ReportLevel... levels) { + ConcurrentMap statsForKey = stats.get(key); + for (ReportLevel level : levels) { + if (level == null) continue; + + AtomicLong n = statsForKey.computeIfAbsent(level, k -> new AtomicLong()); + if (key.isCounter()) { + n.addAndGet(value); + } else { + n.set(value); + } + } + } + @Data - private static class OwnerId { - private TenantId tenantId; - private EntityId entityId; + private static class ReportLevel { + private final TenantId tenantId; + private final CustomerId customerId; + + public static ReportLevel of(TenantId tenantId) { + return new ReportLevel(tenantId, null); + } - public OwnerId(TenantId tenantId) { - this.tenantId = tenantId; + public static ReportLevel of(TenantId tenantId, CustomerId customerId) { + return new ReportLevel(tenantId, customerId); } - public OwnerId(TenantId tenantId, EntityId entityId) { - this.tenantId = tenantId; - this.entityId = entityId; + public ParentEntity getParentEntity() { + return new ParentEntity(tenantId, customerId); } + } + + @Data + private static class ParentEntity { + private final TenantId tenantId; + private final CustomerId customerId; + + public EntityId getId() { + return customerId != null ? customerId : tenantId; + } + } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/PropertyUtils.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/PropertyUtils.java new file mode 100644 index 0000000000..089d7f2219 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/PropertyUtils.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.util; + +import org.thingsboard.server.common.data.StringUtils; + +import java.util.HashMap; +import java.util.Map; + +public class PropertyUtils { + + public static Map getProps(String properties) { + Map configs = new HashMap<>(); + if (StringUtils.isNotEmpty(properties)) { + for (String property : properties.split(";")) { + if (StringUtils.isNotEmpty(property)) { + int delimiterPosition = property.indexOf(":"); + String key = property.substring(0, delimiterPosition); + String value = property.substring(delimiterPosition + 1); + configs.put(key, value); + } + } + } + return configs; + } + +} diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/kafka/TbKafkaSettingsTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/kafka/TbKafkaSettingsTest.java new file mode 100644 index 0000000000..7e8c2d319d --- /dev/null +++ b/common/queue/src/test/java/org/thingsboard/server/queue/kafka/TbKafkaSettingsTest.java @@ -0,0 +1,83 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.kafka; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; + +@SpringBootTest(classes = TbKafkaSettings.class) +@TestPropertySource(properties = { + "queue.type=kafka", + "queue.kafka.bootstrap.servers=localhost:9092", + "queue.kafka.other-inline=metrics.recording.level:INFO;metrics.sample.window.ms:30000", +}) +class TbKafkaSettingsTest { + + @Autowired + TbKafkaSettings settings; + + @BeforeEach + void beforeEach() { + settings = spy(settings); //SpyBean is not aware on @ConditionalOnProperty, that is why the traditional spy in use + } + + @Test + void givenToProps_whenConfigureSSL_thenVerifyOnce() { + Properties props = settings.toProps(); + + assertThat(props).as("TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS").containsEntry("request.timeout.ms", 30000); + assertThat(props).as("TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS").containsEntry("session.timeout.ms", 10000); + + //other-inline + assertThat(props).as("metrics.recording.level").containsEntry("metrics.recording.level", "INFO"); + assertThat(props).as("TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS").containsEntry("metrics.sample.window.ms", "30000"); + + Mockito.verify(settings).toProps(); + Mockito.verify(settings).configureSSL(any()); + } + + @Test + void givenToAdminProps_whenConfigureSSL_thenVerifyOnce() { + settings.toAdminProps(); + Mockito.verify(settings).toProps(); + Mockito.verify(settings).configureSSL(any()); + } + + @Test + void givenToConsumerProps_whenConfigureSSL_thenVerifyOnce() { + settings.toConsumerProps("main"); + Mockito.verify(settings).toProps(); + Mockito.verify(settings).configureSSL(any()); + } + + @Test + void givenTotoProducerProps_whenConfigureSSL_thenVerifyOnce() { + settings.toProducerProps(); + Mockito.verify(settings).toProps(); + Mockito.verify(settings).configureSSL(any()); + } + +} \ No newline at end of file diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/util/PropertyUtilsTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/util/PropertyUtilsTest.java new file mode 100644 index 0000000000..0484cb314f --- /dev/null +++ b/common/queue/src/test/java/org/thingsboard/server/queue/util/PropertyUtilsTest.java @@ -0,0 +1,62 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.util; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class PropertyUtilsTest { + + @Test + void givenNullOrEmpty_whenGetConfig_thenEmptyMap() { + assertThat(PropertyUtils.getProps(null)).as("null property").isEmpty(); + assertThat(PropertyUtils.getProps("")).as("empty property").isEmpty(); + assertThat(PropertyUtils.getProps(";")).as("ends with ;").isEmpty(); + } + + @Test + void givenKafkaOtherProperties_whenGetConfig_thenReturnMappedValues() { + assertThat(PropertyUtils.getProps("metrics.recording.level:INFO;metrics.sample.window.ms:30000")) + .as("two pairs") + .isEqualTo(Map.of( + "metrics.recording.level", "INFO", + "metrics.sample.window.ms", "30000" + )); + + assertThat(PropertyUtils.getProps("metrics.recording.level:INFO;metrics.sample.window.ms:30000" + ";")) + .as("two pairs ends with ;") + .isEqualTo(Map.of( + "metrics.recording.level", "INFO", + "metrics.sample.window.ms", "30000" + )); + } + + @Test + void givenKafkaTopicProperties_whenGetConfig_thenReturnMappedValues() { + assertThat(PropertyUtils.getProps("retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1")) + .isEqualTo(Map.of( + "retention.ms", "604800000", + "segment.bytes", "26214400", + "retention.bytes", "1048576000", + "partitions", "1", + "min.insync.replicas", "1" + )); + } + +} diff --git a/common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java b/common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java index 8fff7fc0fe..dc4068edcc 100644 --- a/common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java +++ b/common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java @@ -20,7 +20,6 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -191,7 +190,6 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { }, callbackExecutor); } - @NotNull private JsInvokeProtos.RemoteJsRequest buildJsInvokeRequest(JsScriptInfo jsInfo, Object[] args, boolean includeScriptBody, String scriptBody) { JsInvokeProtos.JsInvokeRequest.Builder jsRequestBuilder = JsInvokeProtos.JsInvokeRequest.newBuilder() .setScriptHash(jsInfo.getHash()) diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DefaultCoapClientContext.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DefaultCoapClientContext.java index 78c97a53d7..89d0054d08 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DefaultCoapClientContext.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DefaultCoapClientContext.java @@ -598,7 +598,7 @@ public class DefaultCoapClientContext implements CoapClientContext { response.addMessageObserver(new TbCoapMessageObserver(requestId, id -> { TransportProtos.ToDeviceRpcRequestMsg rpcRequestMsg = transportContext.getRpcAwaitingAck().remove(id); if (rpcRequestMsg != null) { - transportService.process(state.getSession(), rpcRequestMsg, RpcStatus.DELIVERED, TransportServiceCallback.EMPTY); + transportService.process(state.getSession(), rpcRequestMsg, RpcStatus.DELIVERED, true, TransportServiceCallback.EMPTY); } }, id -> { TransportProtos.ToDeviceRpcRequestMsg rpcRequestMsg = transportContext.getRpcAwaitingAck().remove(id); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcDownlinkRequestCallbackProxy.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcDownlinkRequestCallbackProxy.java index 0d47a1865e..70f67f8f63 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcDownlinkRequestCallbackProxy.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcDownlinkRequestCallbackProxy.java @@ -65,7 +65,7 @@ public abstract class RpcDownlinkRequestCallbackProxy implements DownlinkR @Override public void onSuccess(R request, T response) { - transportService.process(client.getSession(), this.request, RpcStatus.DELIVERED, TransportServiceCallback.EMPTY); + transportService.process(client.getSession(), this.request, RpcStatus.DELIVERED, true, TransportServiceCallback.EMPTY); sendRpcReplyOnSuccess(response); if (callback != null) { callback.onSuccess(request, response); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStore.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStore.java index 9e29177dcd..fb9cc870ad 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStore.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStore.java @@ -16,6 +16,7 @@ package org.thingsboard.server.transport.lwm2m.server.store; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.binary.Hex; import org.springframework.data.redis.connection.RedisClusterConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.Cursor; @@ -48,7 +49,12 @@ public class TbRedisLwM2MClientStore implements TbLwM2MClientStore { if (data == null) { return null; } else { - return deserialize(data); + try { + return deserialize(data); + } catch (Exception e) { + log.warn("[{}] Failed to deserialize client from data: {}", endpoint, Hex.encodeHexString(data), e); + return null; + } } } } @@ -70,7 +76,13 @@ public class TbRedisLwM2MClientStore implements TbLwM2MClientStore { scans.forEach(scan -> { scan.forEachRemaining(key -> { byte[] element = connection.get(key); - clients.add(deserialize(element)); + if (element != null) { + try { + clients.add(deserialize(element)); + } catch (Exception e) { + log.warn("[{}] Failed to deserialize client from data: {}", Hex.encodeHexString(key), Hex.encodeHexString(element), e); + } + } }); }); return clients; diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 4b425e8446..bfa80ba615 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -24,9 +24,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.DeviceTransportType; -import org.thingsboard.server.common.msg.EncryptionUtil; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; @@ -42,7 +41,6 @@ import javax.net.ssl.SSLEngine; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; -import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.concurrent.CountDownLatch; @@ -123,7 +121,7 @@ public class MqttSslHandlerProvider { static class ThingsboardMqttX509TrustManager implements X509TrustManager { private final X509TrustManager trustManager; - private TransportService transportService; + private final TransportService transportService; ThingsboardMqttX509TrustManager(X509TrustManager trustManager, TransportService transportService) { this.trustManager = trustManager; @@ -142,43 +140,59 @@ public class MqttSslHandlerProvider { } @Override - public void checkClientTrusted(X509Certificate[] chain, - String authType) throws CertificateException { - String credentialsBody = null; - for (X509Certificate cert : chain) { - try { - String strCert = SslUtil.getCertificateString(cert); - String sha3Hash = EncryptionUtil.getSha3Hash(strCert); - final String[] credentialsBodyHolder = new String[1]; - CountDownLatch latch = new CountDownLatch(1); - transportService.process(DeviceTransportType.MQTT, TransportProtos.ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), - new TransportServiceCallback() { - @Override - public void onSuccess(ValidateDeviceCredentialsResponse msg) { - if (!StringUtils.isEmpty(msg.getCredentials())) { - credentialsBodyHolder[0] = msg.getCredentials(); - } - latch.countDown(); - } - - @Override - public void onError(Throwable e) { - log.error(e.getMessage(), e); - latch.countDown(); + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + if (!validateCertificateChain(chain)) { + throw new CertificateException("Invalid Chain of X509 Certificates. "); + } + String clientDeviceCertValue = SslUtil.getCertificateString(chain[0]); + final String[] credentialsBodyHolder = new String[1]; + CountDownLatch latch = new CountDownLatch(1); + try { + String certificateChain = SslUtil.getCertificateChainString(chain); + transportService.process(DeviceTransportType.MQTT, TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg + .newBuilder().setCertificateChain(certificateChain).build(), + new TransportServiceCallback<>() { + @Override + public void onSuccess(ValidateDeviceCredentialsResponse msg) { + if (!StringUtils.isEmpty(msg.getCredentials())) { + credentialsBodyHolder[0] = msg.getCredentials(); } - }); - latch.await(10, TimeUnit.SECONDS); - if (strCert.equals(credentialsBodyHolder[0])) { - credentialsBody = credentialsBodyHolder[0]; - break; + latch.countDown(); + } + + @Override + public void onError(Throwable e) { + log.trace("Failed to process certificate chain: {}", certificateChain, e); + latch.countDown(); + } + }); + latch.await(10, TimeUnit.SECONDS); + if (!clientDeviceCertValue.equals(credentialsBodyHolder[0])) { + log.debug("Failed to find credentials for device certificate chain: {}", chain); + if (chain.length == 1) { + throw new CertificateException("Invalid Device Certificate"); + } else { + throw new CertificateException("Invalid Chain of X509 Certificates"); } - } catch (InterruptedException | CertificateEncodingException e) { - log.error(e.getMessage(), e); } + } catch (Exception e) { + log.error(e.getMessage(), e); } - if (credentialsBody == null) { - log.debug("Failed to find credentials for device certificate chain: {}", chain); - throw new CertificateException("Invalid Device Certificate"); + } + + private boolean validateCertificateChain(X509Certificate[] chain) { + try { + if (chain.length > 1) { + X509Certificate leafCert = chain[0]; + for (int i = 1; i < chain.length; i++) { + X509Certificate intermediateCert = chain[i]; + leafCert.verify(intermediateCert.getPublicKey()); + leafCert = intermediateCert; + } + } + return true; + } catch (Exception e) { + return false; } } } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 98cd0435ad..d4c060ed98 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -322,7 +322,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement int msgId = ((MqttPubAckMessage) msg).variableHeader().messageId(); TransportProtos.ToDeviceRpcRequestMsg rpcRequest = rpcAwaitingAck.remove(msgId); if (rpcRequest != null) { - transportService.process(deviceSessionCtx.getSessionInfo(), rpcRequest, RpcStatus.DELIVERED, TransportServiceCallback.EMPTY); + transportService.process(deviceSessionCtx.getSessionInfo(), rpcRequest, RpcStatus.DELIVERED, true, TransportServiceCallback.EMPTY); } break; default: diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java index 9b1e3e664e..a2c4b9db72 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java @@ -221,7 +221,7 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler onDeviceConnectProto(SparkplugTopic topic) throws ThingsboardException { try { - String deviceType = this.gateway.getDeviceType() + "-node"; + String deviceType = this.gateway.getDeviceType() + " device"; return onDeviceConnect(topic.getNodeDeviceName(), deviceType); } catch (RuntimeException e) { log.error("Failed Sparkplug Device connect proto!", e); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java index 384290f42c..abcd35ee23 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -54,8 +54,9 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMs import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateBasicMqttCredRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceLwM2MCredentialsRequestMsg; -import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg; import java.util.List; import java.util.concurrent.ExecutorService; @@ -87,6 +88,9 @@ public interface TransportService { void process(DeviceTransportType transportType, ValidateDeviceX509CertRequestMsg msg, TransportServiceCallback callback); + void process(DeviceTransportType transportType, ValidateOrCreateDeviceX509CertRequestMsg msg, + TransportServiceCallback callback); + void process(ValidateDeviceLwM2MCredentialsRequestMsg msg, TransportServiceCallback callback); @@ -117,6 +121,8 @@ public interface TransportService { void process(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback); + void process(SessionInfoProto sessionInfo, ToDeviceRpcRequestMsg msg, RpcStatus rpcStatus, boolean reportActivity, TransportServiceCallback callback); + void process(SessionInfoProto sessionInfo, ToDeviceRpcRequestMsg msg, RpcStatus rpcStatus, TransportServiceCallback callback); void process(SessionInfoProto sessionInfo, SubscriptionInfoProto msg, TransportServiceCallback callback); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index dc2ad73cc1..883522881f 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -72,7 +72,6 @@ import org.thingsboard.server.common.transport.auth.GetOrCreateDeviceFromGateway import org.thingsboard.server.common.transport.auth.TransportDeviceInfo; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; import org.thingsboard.server.common.transport.limits.TransportRateLimitService; -import org.thingsboard.server.queue.util.DataDecodingEncodingService; import org.thingsboard.server.common.transport.util.JsonUtils; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ProvisionDeviceRequestMsg; @@ -97,6 +96,7 @@ import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.provider.TbTransportQueueFactory; import org.thingsboard.server.queue.scheduler.SchedulerComponent; import org.thingsboard.server.queue.util.AfterStartUp; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; import org.thingsboard.server.queue.util.TbTransportComponent; import javax.annotation.PostConstruct; @@ -434,6 +434,13 @@ public class DefaultTransportService implements TransportService { doProcess(transportType, protoMsg, callback); } + @Override + public void process(DeviceTransportType transportType, TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg msg, TransportServiceCallback callback) { + log.trace("Processing msg: {}", msg); + TbProtoQueueMsg protoMsg = new TbProtoQueueMsg<>(UUID.randomUUID(), TransportApiRequestMsg.newBuilder().setValidateOrCreateX509CertRequestMsg(msg).build()); + doProcess(transportType, protoMsg, callback); + } + private void doProcess(DeviceTransportType transportType, TbProtoQueueMsg protoMsg, TransportServiceCallback callback) { ListenableFuture response = Futures.transform(transportApiRequestTemplate.send(protoMsg), tmp -> { @@ -647,6 +654,11 @@ public class DefaultTransportService implements TransportService { @Override public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.ToDeviceRpcRequestMsg msg, RpcStatus rpcStatus, TransportServiceCallback callback) { + process(sessionInfo, msg, rpcStatus, false, callback); + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.ToDeviceRpcRequestMsg msg, RpcStatus rpcStatus, boolean reportActivity, TransportServiceCallback callback) { TransportProtos.ToDeviceRpcResponseStatusMsg responseMsg = TransportProtos.ToDeviceRpcResponseStatusMsg.newBuilder() .setRequestId(msg.getRequestId()) .setRequestIdLSB(msg.getRequestIdLSB()) @@ -655,7 +667,9 @@ public class DefaultTransportService implements TransportService { .build(); if (checkLimits(sessionInfo, responseMsg, callback)) { - reportActivityInternal(sessionInfo); + if (reportActivity) { + reportActivityInternal(sessionInfo); + } sendToDeviceActor(sessionInfo, TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setRpcResponseStatusMsg(responseMsg).build(), new ApiStatsProxyCallback<>(getTenantId(sessionInfo), getCustomerId(sessionInfo), 1, TransportServiceCallback.EMPTY)); } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java index 0431868965..089718ec05 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java @@ -16,11 +16,21 @@ package org.thingsboard.server.common.transport.util; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.binary.Base64; +import org.bouncycastle.asn1.x500.RDN; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x500.style.IETFUtils; +import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; import org.springframework.util.Base64Utils; import org.thingsboard.server.common.msg.EncryptionUtil; +import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; /** * @author Valerii Sosliuk @@ -35,4 +45,44 @@ public class SslUtil { throws CertificateEncodingException { return EncryptionUtil.certTrimNewLines(Base64Utils.encodeToString(cert.getEncoded())); } + + public static String getCertificateChainString(Certificate[] chain) + throws CertificateEncodingException { + String begin = "-----BEGIN CERTIFICATE-----"; + String end = "-----END CERTIFICATE-----"; + StringBuilder stringBuilder = new StringBuilder(); + for (Certificate cert: chain) { + stringBuilder.append(begin).append(EncryptionUtil.certTrimNewLines(Base64Utils.encodeToString(cert.getEncoded()))).append(end).append("\n"); + } + return stringBuilder.toString(); + } + + public static X509Certificate readCertFile(String fileContent) { + X509Certificate certificate = null; + try { + if (fileContent != null && !fileContent.trim().isEmpty()) { + fileContent = fileContent.replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replaceAll("\\s", ""); + byte[] decoded = Base64.decodeBase64(fileContent); + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + try (InputStream inStream = new ByteArrayInputStream(decoded)) { + certificate = (X509Certificate) certFactory.generateCertificate(inStream); + } + } + } catch (Exception ignored) {} + return certificate; + } + + public static String parseCommonName(X509Certificate certificate) { + X500Name x500name; + try { + x500name = new JcaX509CertificateHolder(certificate).getSubject(); + } catch (CertificateEncodingException e) { + log.warn("Cannot parse CN from device certificate"); + throw new RuntimeException(e); + } + RDN cn = x500name.getRDNs(BCStyle.CN)[0]; + return IETFUtils.valueToString(cn.getFirst().getValue()); + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeUtils.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeUtils.java index 168782c0fa..d1abeda5b6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeUtils.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeUtils.java @@ -19,6 +19,9 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.Validator; +import org.thingsboard.server.dao.util.KvUtils; + +import java.util.List; public class AttributeUtils { @@ -27,10 +30,13 @@ public class AttributeUtils { Validator.validateString(scope, "Incorrect scope " + scope); } + public static void validate(List kvEntries) { + kvEntries.forEach(AttributeUtils::validate); + } + public static void validate(AttributeKvEntry kvEntry) { - if (kvEntry == null) { - throw new IncorrectParameterException("Key value entry can't be null"); - } else if (kvEntry.getDataType() == null) { + KvUtils.validate(kvEntry); + if (kvEntry.getDataType() == null) { throw new IncorrectParameterException("Incorrect kvEntry. Data type can't be null"); } else { Validator.validateString(kvEntry.getKey(), "Incorrect kvEntry. Key can't be empty"); diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java index ef38de352a..abd55b6b12 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.attributes; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; @@ -89,7 +90,7 @@ public class BaseAttributesService implements AttributesService { @Override public ListenableFuture> save(TenantId tenantId, EntityId entityId, String scope, List attributes) { validate(entityId, scope); - attributes.forEach(AttributeUtils::validate); + AttributeUtils.validate(attributes); List> saveFutures = attributes.stream().map(attribute -> attributesDao.save(tenantId, entityId, scope, attribute)).collect(Collectors.toList()); return Futures.allAsList(saveFutures); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java index 01a6d1201c..b95ce39d9a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java @@ -134,7 +134,7 @@ public class CachedAttributesService implements AttributesService { @Override public ListenableFuture> find(TenantId tenantId, EntityId entityId, String scope, Collection attributeKeys) { validate(entityId, scope); - attributeKeys = new LinkedHashSet<>(attributeKeys); // deduplicate the attributes + attributeKeys = new LinkedHashSet<>(attributeKeys); // deduplicate the attributes attributeKeys.forEach(attributeKey -> Validator.validateString(attributeKey, "Incorrect attribute key " + attributeKey)); Map> wrappedCachedAttributes = findCachedAttributes(entityId, scope, attributeKeys); @@ -220,7 +220,7 @@ public class CachedAttributesService implements AttributesService { @Override public ListenableFuture> save(TenantId tenantId, EntityId entityId, String scope, List attributes) { validate(entityId, scope); - attributes.forEach(AttributeUtils::validate); + AttributeUtils.validate(attributes); List> futures = new ArrayList<>(attributes.size()); for (var attribute : attributes) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java index 360085be20..6e79b953ff 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java @@ -16,15 +16,12 @@ package org.thingsboard.server.dao.device; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; import org.eclipse.leshan.core.SecurityMode; import org.eclipse.leshan.core.util.SecurityUtil; import org.hibernate.exception.ConstraintViolationException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.StringUtils; diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java index 07428064a8..d032282da9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.device; import lombok.Data; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.TenantId; @@ -30,34 +31,44 @@ public class DeviceProfileCacheKey implements Serializable { private final String name; private final DeviceProfileId deviceProfileId; private final boolean defaultProfile; + private final String provisionDeviceKey; - private DeviceProfileCacheKey(TenantId tenantId, String name, DeviceProfileId deviceProfileId, boolean defaultProfile) { + private DeviceProfileCacheKey(TenantId tenantId, String name, DeviceProfileId deviceProfileId, boolean defaultProfile, String provisionDeviceKey) { this.tenantId = tenantId; this.name = name; this.deviceProfileId = deviceProfileId; this.defaultProfile = defaultProfile; + this.provisionDeviceKey = provisionDeviceKey; } public static DeviceProfileCacheKey fromName(TenantId tenantId, String name) { - return new DeviceProfileCacheKey(tenantId, name, null, false); + return new DeviceProfileCacheKey(tenantId, name, null, false, null); } public static DeviceProfileCacheKey fromId(DeviceProfileId id) { - return new DeviceProfileCacheKey(null, null, id, false); + return new DeviceProfileCacheKey(null, null, id, false, null); } public static DeviceProfileCacheKey defaultProfile(TenantId tenantId) { - return new DeviceProfileCacheKey(tenantId, null, null, true); + return new DeviceProfileCacheKey(tenantId, null, null, true, null); } + public static DeviceProfileCacheKey fromProvisionDeviceKey(String provisionDeviceKey) { + return new DeviceProfileCacheKey(null, null, null, false, provisionDeviceKey); + } + + /** + * IMPORTANT: Method toString() has to return unique value, if you add additional field to this class, please also refactor toString(). + */ @Override public String toString() { if (deviceProfileId != null) { return deviceProfileId.toString(); } else if (defaultProfile) { return tenantId.toString(); - } else { - return tenantId + "_" + name; + } else if (StringUtils.isNotEmpty(provisionDeviceKey)) { + return provisionDeviceKey; } + return tenantId + "_" + name; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java index c5a699b035..6470bfc584 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java @@ -27,5 +27,6 @@ public class DeviceProfileEvictEvent { private final String oldName; private final DeviceProfileId deviceProfileId; private final boolean defaultProfile; + private final String provisionDeviceKey; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java index 98ca4fde7b..391695f2bf 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java @@ -34,12 +34,14 @@ import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileCon import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.device.profile.DeviceProfileData; import org.thingsboard.server.common.data.device.profile.DisabledDeviceProfileProvisionConfiguration; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.msg.EncryptionUtil; import org.thingsboard.server.dao.entity.AbstractCachedEntityService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.queue.QueueService; @@ -47,12 +49,19 @@ import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.service.Validator; +import java.io.ByteArrayInputStream; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import static org.thingsboard.server.dao.service.Validator.validateId; +import static org.thingsboard.server.dao.service.Validator.validateString; @Service("DeviceProfileDaoService") @Slf4j @@ -61,6 +70,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService deviceProfileDao.findById(tenantId, deviceProfileId.getId()), true); } @@ -107,33 +120,52 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService deviceProfileDao.findByName(tenantId, profileName), true); } + @Override + public DeviceProfile findDeviceProfileByProvisionDeviceKey(String provisionDeviceKey) { + log.trace("Executing findDeviceProfileByProvisionDeviceKey provisionKey [{}]", provisionDeviceKey); + validateString(provisionDeviceKey, INCORRECT_PROVISION_DEVICE_KEY + provisionDeviceKey); + return cache.getAndPutInTransaction(DeviceProfileCacheKey.fromProvisionDeviceKey(provisionDeviceKey), + () -> deviceProfileDao.findByProvisionDeviceKey(provisionDeviceKey), false); + } + @Override public DeviceProfileInfo findDeviceProfileInfoById(TenantId tenantId, DeviceProfileId deviceProfileId) { log.trace("Executing findDeviceProfileById [{}]", deviceProfileId); - Validator.validateId(deviceProfileId, INCORRECT_DEVICE_PROFILE_ID + deviceProfileId); + validateId(deviceProfileId, INCORRECT_DEVICE_PROFILE_ID + deviceProfileId); return toDeviceProfileInfo(findDeviceProfileById(tenantId, deviceProfileId)); } @Override public DeviceProfile saveDeviceProfile(DeviceProfile deviceProfile) { log.trace("Executing saveDeviceProfile [{}]", deviceProfile); + if (deviceProfile.getProfileData() != null && deviceProfile.getProfileData().getProvisionConfiguration() instanceof X509CertificateChainProvisionConfiguration) { + X509CertificateChainProvisionConfiguration x509Configuration = (X509CertificateChainProvisionConfiguration) deviceProfile.getProfileData().getProvisionConfiguration(); + if (x509Configuration.getProvisionDeviceSecret() != null) { + formatDeviceProfileCertificate(deviceProfile, x509Configuration); + } + } DeviceProfile oldDeviceProfile = deviceProfileValidator.validate(deviceProfile, DeviceProfile::getTenantId); DeviceProfile savedDeviceProfile; try { savedDeviceProfile = deviceProfileDao.saveAndFlush(deviceProfile.getTenantId(), deviceProfile); publishEvictEvent(new DeviceProfileEvictEvent(savedDeviceProfile.getTenantId(), savedDeviceProfile.getName(), - oldDeviceProfile != null ? oldDeviceProfile.getName() : null, savedDeviceProfile.getId(), savedDeviceProfile.isDefault())); + oldDeviceProfile != null ? oldDeviceProfile.getName() : null, savedDeviceProfile.getId(), savedDeviceProfile.isDefault(), + oldDeviceProfile != null ? oldDeviceProfile.getProvisionDeviceKey() : null)); } catch (Exception t) { handleEvictEvent(new DeviceProfileEvictEvent(deviceProfile.getTenantId(), deviceProfile.getName(), - oldDeviceProfile != null ? oldDeviceProfile.getName() : null, null, deviceProfile.isDefault())); + oldDeviceProfile != null ? oldDeviceProfile.getName() : null, null, deviceProfile.isDefault(), + oldDeviceProfile != null ? oldDeviceProfile.getProvisionDeviceKey() : null)); + String unqProvisionKeyErrorMsg = DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN.equals(deviceProfile.getProvisionType()) + ? "Device profile with such certificate already exists!" + : "Device profile with such provision device key already exists!"; checkConstraintViolation(t, Map.of("device_profile_name_unq_key", DEVICE_PROFILE_WITH_SUCH_NAME_ALREADY_EXISTS, - "device_provision_key_unq_key", "Device profile with such provision device key already exists!", + "device_provision_key_unq_key", unqProvisionKeyErrorMsg, "device_profile_external_id_unq_key", "Device profile with such external id already exists!")); throw t; } @@ -156,7 +188,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService 1) { + return EncryptionUtil.certTrimNewLinesForChainInDeviceProfile(certificateValue); + } + } catch (CertificateException ignored) {} + return EncryptionUtil.certTrimNewLines(certificateValue); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index a32f93d801..b9bcd83e93 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.device; -import com.google.common.base.Function; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; @@ -73,14 +72,11 @@ import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; -import javax.annotation.Nullable; import java.util.ArrayList; -import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.UUID; -import java.util.stream.Collectors; import static org.thingsboard.server.dao.DaoUtil.toUUIDs; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -508,27 +504,20 @@ public class DeviceServiceImpl extends AbstractCachedEntityService> findDevicesByQuery(TenantId tenantId, DeviceSearchQuery query) { ListenableFuture> relations = relationService.findByQuery(tenantId, query.toEntitySearchQuery()); - ListenableFuture> devices = Futures.transformAsync(relations, r -> { + return Futures.transform(relations, r -> { EntitySearchDirection direction = query.toEntitySearchQuery().getParameters().getDirection(); - List> futures = new ArrayList<>(); + List devices = new ArrayList<>(); for (EntityRelation relation : r) { EntityId entityId = direction == EntitySearchDirection.FROM ? relation.getTo() : relation.getFrom(); if (entityId.getEntityType() == EntityType.DEVICE) { - futures.add(findDeviceByIdAsync(tenantId, new DeviceId(entityId.getId()))); + Device device = findDeviceById(tenantId, new DeviceId(entityId.getId())); + if (query.getDeviceTypes().contains(device.getType())) { + devices.add(device); + } } } - return Futures.successfulAsList(futures); + return devices; }, MoreExecutors.directExecutor()); - - devices = Futures.transform(devices, new Function<>() { - @Nullable - @Override - public List apply(@Nullable List deviceList) { - return deviceList == null ? Collections.emptyList() : deviceList.stream().filter(device -> query.getDeviceTypes().contains(device.getType())).collect(Collectors.toList()); - } - }, MoreExecutors.directExecutor()); - - return devices; } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationRuleService.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationRuleService.java index a08a282e4e..3b2d8e0984 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationRuleService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationRuleService.java @@ -17,7 +17,6 @@ package org.thingsboard.server.dao.notification; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; @@ -28,43 +27,35 @@ import org.thingsboard.server.common.data.notification.rule.NotificationRuleInfo import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerType; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.dao.entity.AbstractCachedEntityService; +import org.thingsboard.server.dao.entity.AbstractEntityService; import org.thingsboard.server.dao.entity.EntityDaoService; -import org.thingsboard.server.dao.notification.cache.NotificationRuleCacheKey; -import org.thingsboard.server.dao.notification.cache.NotificationRuleCacheValue; -import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor -public class DefaultNotificationRuleService extends AbstractCachedEntityService implements NotificationRuleService, EntityDaoService { +public class DefaultNotificationRuleService extends AbstractEntityService implements NotificationRuleService, EntityDaoService { private final NotificationRuleDao notificationRuleDao; @Override public NotificationRule saveNotificationRule(TenantId tenantId, NotificationRule notificationRule) { - boolean created = notificationRule.getId() == null; - if (!created) { + if (notificationRule.getId() != null) { NotificationRule oldNotificationRule = findNotificationRuleById(tenantId, notificationRule.getId()); if (notificationRule.getTriggerType() != oldNotificationRule.getTriggerType()) { throw new IllegalArgumentException("Notification rule trigger type cannot be updated"); } } try { - notificationRule = notificationRuleDao.saveAndFlush(tenantId, notificationRule); - publishEvictEvent(notificationRule); + return notificationRuleDao.saveAndFlush(tenantId, notificationRule); } catch (Exception e) { - handleEvictEvent(notificationRule); checkConstraintViolation(e, Map.of( "uq_notification_rule_name", "Notification rule with such name already exists" )); throw e; } - return notificationRule; } @Override @@ -89,44 +80,17 @@ public class DefaultNotificationRuleService extends AbstractCachedEntityService< @Override public List findNotificationRulesByTenantIdAndTriggerType(TenantId tenantId, NotificationRuleTriggerType triggerType) { - NotificationRuleCacheKey cacheKey = NotificationRuleCacheKey.builder() - .tenantId(tenantId) - .triggerType(triggerType) - .build(); - return cache.getAndPutInTransaction(cacheKey, () -> NotificationRuleCacheValue.builder() - .notificationRules(notificationRuleDao.findByTenantIdAndTriggerType(tenantId, triggerType)) - .build(), false) - .getNotificationRules(); + return notificationRuleDao.findByTenantIdAndTriggerType(tenantId, triggerType); } - @Transactional @Override public void deleteNotificationRuleById(TenantId tenantId, NotificationRuleId id) { - NotificationRule notificationRule = findNotificationRuleById(tenantId, id); - publishEvictEvent(notificationRule); notificationRuleDao.removeById(tenantId, id.getId()); } @Override public void deleteNotificationRulesByTenantId(TenantId tenantId) { notificationRuleDao.removeByTenantId(tenantId); - - List cacheKeys = Arrays.stream(NotificationRuleTriggerType.values()) - .map(triggerType -> NotificationRuleCacheKey.builder() - .tenantId(tenantId) - .triggerType(triggerType) - .build()) - .collect(Collectors.toList()); - cache.evict(cacheKeys); - } - - @Override - public void handleEvictEvent(NotificationRule notificationRule) { - NotificationRuleCacheKey cacheKey = NotificationRuleCacheKey.builder() - .tenantId(notificationRule.getTenantId()) - .triggerType(notificationRule.getTriggerType()) - .build(); - cache.evict(cacheKey); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationSettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationSettingsService.java index 50a7d49f66..534e3136ca 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationSettingsService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationSettingsService.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.notification; -import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; @@ -24,31 +23,8 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AdminSettings; -import org.thingsboard.server.common.data.ApiUsageStateValue; import org.thingsboard.server.common.data.CacheConstants; -import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; -import org.thingsboard.server.common.data.id.NotificationTargetId; -import org.thingsboard.server.common.data.id.NotificationTemplateId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; -import org.thingsboard.server.common.data.notification.NotificationType; -import org.thingsboard.server.common.data.notification.rule.DefaultNotificationRuleRecipientsConfig; -import org.thingsboard.server.common.data.notification.rule.EscalatedNotificationRuleRecipientsConfig; -import org.thingsboard.server.common.data.notification.rule.NotificationRule; -import org.thingsboard.server.common.data.notification.rule.NotificationRuleConfig; -import org.thingsboard.server.common.data.notification.rule.trigger.AlarmAssignmentNotificationRuleTriggerConfig; -import org.thingsboard.server.common.data.notification.rule.trigger.AlarmCommentNotificationRuleTriggerConfig; -import org.thingsboard.server.common.data.notification.rule.trigger.AlarmNotificationRuleTriggerConfig; -import org.thingsboard.server.common.data.notification.rule.trigger.AlarmNotificationRuleTriggerConfig.AlarmAction; -import org.thingsboard.server.common.data.notification.rule.trigger.ApiUsageLimitNotificationRuleTriggerConfig; -import org.thingsboard.server.common.data.notification.rule.trigger.DeviceActivityNotificationRuleTriggerConfig; -import org.thingsboard.server.common.data.notification.rule.trigger.DeviceActivityNotificationRuleTriggerConfig.DeviceEvent; -import org.thingsboard.server.common.data.notification.rule.trigger.EntitiesLimitNotificationRuleTriggerConfig; -import org.thingsboard.server.common.data.notification.rule.trigger.EntityActionNotificationRuleTriggerConfig; -import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerConfig; -import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerType; -import org.thingsboard.server.common.data.notification.rule.trigger.RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.settings.NotificationSettings; import org.thingsboard.server.common.data.notification.targets.NotificationTarget; import org.thingsboard.server.common.data.notification.targets.platform.AffectedTenantAdministratorsFilter; @@ -59,20 +35,10 @@ import org.thingsboard.server.common.data.notification.targets.platform.Platform import org.thingsboard.server.common.data.notification.targets.platform.SystemAdministratorsFilter; import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter; import org.thingsboard.server.common.data.notification.targets.platform.UsersFilter; -import org.thingsboard.server.common.data.notification.template.NotificationTemplate; -import org.thingsboard.server.common.data.notification.template.NotificationTemplateConfig; -import org.thingsboard.server.common.data.notification.template.WebDeliveryMethodNotificationTemplate; -import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.dao.settings.AdminSettingsService; import java.util.Collections; -import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.Set; - -import static org.thingsboard.common.util.JacksonUtil.newObjectNode; -import static org.thingsboard.server.dao.DaoUtil.toUUIDs; @Service @RequiredArgsConstructor @@ -80,8 +46,7 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS private final AdminSettingsService adminSettingsService; private final NotificationTargetService notificationTargetService; - private final NotificationTemplateService notificationTemplateService; - private final NotificationRuleService notificationRuleService; + private final DefaultNotifications defaultNotifications; private static final String SETTINGS_KEY = "notifications"; @@ -119,31 +84,21 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS NotificationTarget tenantAdmins = createTarget(tenantId, "Tenant administrators", new TenantAdministratorsFilter(), tenantId.isSysTenantId() ? "All tenant administrators" : "Tenant administrators"); - createTemplate(tenantId, "Maintenance work notification", NotificationType.GENERAL, - "Infrastructure maintenance", - "Maintenance work is scheduled for tomorrow (7:00 a.m. - 9:00 a.m. UTC)"); + defaultNotifications.create(tenantId, DefaultNotifications.maintenanceWork); if (tenantId.isSysTenantId()) { NotificationTarget sysAdmins = createTarget(tenantId, "System administrators", new SystemAdministratorsFilter(), "All system administrators"); NotificationTarget affectedTenantAdmins = createTarget(tenantId, "Affected tenant's administrators", new AffectedTenantAdministratorsFilter(), ""); - NotificationTemplate entitiesLimitNotificationTemplate = createTemplate(tenantId, "Entities count limit notification", NotificationType.ENTITIES_LIMIT, - "${entityType}s limit will be reached soon for tenant ${tenantName}", - "${entityType}s usage: ${currentCount}/${limit} (${percents}%)"); - EntitiesLimitNotificationRuleTriggerConfig entitiesLimitRuleTriggerConfig = new EntitiesLimitNotificationRuleTriggerConfig(); - entitiesLimitRuleTriggerConfig.setEntityTypes(null); - entitiesLimitRuleTriggerConfig.setThreshold(0.8f); - createRule(tenantId, "Entities count limit", entitiesLimitNotificationTemplate.getId(), entitiesLimitRuleTriggerConfig, - List.of(affectedTenantAdmins.getId(), sysAdmins.getId()), "Send notification to tenant admins and system admins when count of entities of some type reached 80% threshold of the limit"); + defaultNotifications.create(tenantId, DefaultNotifications.entitiesLimitForSysadmin, sysAdmins.getId()); + defaultNotifications.create(tenantId, DefaultNotifications.entitiesLimitForTenant, affectedTenantAdmins.getId()); + + defaultNotifications.create(tenantId, DefaultNotifications.apiFeatureWarningForSysadmin, sysAdmins.getId()); + defaultNotifications.create(tenantId, DefaultNotifications.apiFeatureWarningForTenant, affectedTenantAdmins.getId()); + defaultNotifications.create(tenantId, DefaultNotifications.apiFeatureDisabledForSysadmin, sysAdmins.getId()); + defaultNotifications.create(tenantId, DefaultNotifications.apiFeatureDisabledForTenant, affectedTenantAdmins.getId()); - NotificationTemplate apiUsageLimitNotificationTemplate = createTemplate(tenantId, "API usage limit notification", NotificationType.API_USAGE_LIMIT, - "${feature} feature - ${status:upperCase}", - "Tenant '${tenantName}': usage - ${currentValue} out of ${limit} ${unitLabel}s"); - ApiUsageLimitNotificationRuleTriggerConfig apiUsageLimitRuleTriggerConfig = new ApiUsageLimitNotificationRuleTriggerConfig(); - apiUsageLimitRuleTriggerConfig.setApiFeatures(null); - apiUsageLimitRuleTriggerConfig.setNotifyOn(Set.of(ApiUsageStateValue.WARNING, ApiUsageStateValue.DISABLED)); - createRule(tenantId, "API usage limit", apiUsageLimitNotificationTemplate.getId(), apiUsageLimitRuleTriggerConfig, - List.of(affectedTenantAdmins.getId(), sysAdmins.getId()), "Send notification to tenant admins and system admins when API feature usage state changed"); + defaultNotifications.create(tenantId, DefaultNotifications.newPlatformVersion, sysAdmins.getId(), tenantAdmins.getId()); return; } @@ -152,95 +107,13 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS NotificationTarget affectedUser = createTarget(tenantId, "Affected user", new AffectedUserFilter(), "If rule trigger is an action that affects some user (e.g. alarm assigned to user) - this user"); - NotificationTemplate newAlarmNotificationTemplate = createTemplate(tenantId, "New alarm notification", NotificationType.ALARM, - "New alarm '${alarmType}'", - "Severity: ${alarmSeverity}, originator: ${alarmOriginatorEntityType} '${alarmOriginatorName}'", - "notifications", null, null); - AlarmNotificationRuleTriggerConfig newAlarmRuleTriggerConfig = new AlarmNotificationRuleTriggerConfig(); - newAlarmRuleTriggerConfig.setAlarmTypes(null); - newAlarmRuleTriggerConfig.setAlarmSeverities(null); - newAlarmRuleTriggerConfig.setNotifyOn(Set.of(AlarmAction.CREATED)); - createRule(tenantId, "New alarm", newAlarmNotificationTemplate.getId(), newAlarmRuleTriggerConfig, - List.of(tenantAdmins.getId(), originatorEntityOwnerUsers.getId()), "Send notification to tenant admins and alarm's customer users " + - "when an alarm is created"); - - NotificationTemplate alarmUpdateNotificationTemplate = createTemplate(tenantId, "Alarm update notification", NotificationType.ALARM, - "Alarm '${alarmType}' - ${action}", - "Severity: ${alarmSeverity}, originator: ${alarmOriginatorEntityType} '${alarmOriginatorName}'", - "notifications", null, null); - AlarmNotificationRuleTriggerConfig alarmRuleTriggerConfig = new AlarmNotificationRuleTriggerConfig(); - alarmRuleTriggerConfig.setAlarmTypes(null); - alarmRuleTriggerConfig.setAlarmSeverities(null); - alarmRuleTriggerConfig.setNotifyOn(Set.of(AlarmAction.SEVERITY_CHANGED, AlarmAction.ACKNOWLEDGED, AlarmAction.CLEARED)); - createRule(tenantId, "Alarm update", alarmUpdateNotificationTemplate.getId(), alarmRuleTriggerConfig, - List.of(tenantAdmins.getId(), originatorEntityOwnerUsers.getId()), "Send notification to tenant admins and alarm's customer users " + - "when any alarm is updated or cleared"); - - NotificationTemplate deviceActionNotificationTemplate = createTemplate(tenantId, "Device action notification", NotificationType.ENTITY_ACTION, - "${entityType} was ${actionType}", - "${entityType} '${entityName}' was ${actionType} by user ${userEmail}", - "info", "Go to device", "/devices/${entityId}"); - EntityActionNotificationRuleTriggerConfig deviceActionRuleTriggerConfig = new EntityActionNotificationRuleTriggerConfig(); - deviceActionRuleTriggerConfig.setEntityTypes(Set.of(EntityType.DEVICE)); - deviceActionRuleTriggerConfig.setCreated(true); - deviceActionRuleTriggerConfig.setUpdated(false); - deviceActionRuleTriggerConfig.setDeleted(false); - createRule(tenantId, "Device created", deviceActionNotificationTemplate.getId(), deviceActionRuleTriggerConfig, - List.of(originatorEntityOwnerUsers.getId()), "Send notification to tenant admins or customer users " + - "when device is created"); - - NotificationTemplate deviceActivityNotificationTemplate = createTemplate(tenantId, "Device activity notification", NotificationType.DEVICE_ACTIVITY, - "Device '${deviceName}' became ${eventType}", - "Device '${deviceName}' of type '${deviceType}' is now ${eventType}", - "info", "Go to device", "/devices/${deviceId}"); - DeviceActivityNotificationRuleTriggerConfig deviceActivityRuleTriggerConfig = new DeviceActivityNotificationRuleTriggerConfig(); - deviceActivityRuleTriggerConfig.setDevices(null); - deviceActivityRuleTriggerConfig.setDeviceProfiles(null); - deviceActivityRuleTriggerConfig.setNotifyOn(Set.of(DeviceEvent.ACTIVE, DeviceEvent.INACTIVE)); - createRule(tenantId, "Device activity status change", deviceActivityNotificationTemplate.getId(), deviceActivityRuleTriggerConfig, - List.of(originatorEntityOwnerUsers.getId()), "Send notification to tenant admins or customer users " + - "when any device changes its activity state"); - - NotificationTemplate alarmCommentNotificationTemplate = createTemplate(tenantId, "Alarm comment notification", NotificationType.ALARM_COMMENT, - "Comment on '${alarmType}' alarm", - "${userEmail} ${action} comment: ${comment}", - "people", null, null); - AlarmCommentNotificationRuleTriggerConfig alarmCommentRuleTriggerConfig = new AlarmCommentNotificationRuleTriggerConfig(); - alarmCommentRuleTriggerConfig.setAlarmTypes(null); - alarmCommentRuleTriggerConfig.setAlarmSeverities(null); - alarmCommentRuleTriggerConfig.setAlarmStatuses(Set.of(AlarmSearchStatus.ACTIVE)); - alarmCommentRuleTriggerConfig.setOnlyUserComments(true); - alarmCommentRuleTriggerConfig.setNotifyOnCommentUpdate(false); - createRule(tenantId, "Comment on active alarm", alarmCommentNotificationTemplate.getId(), alarmCommentRuleTriggerConfig, - List.of(originatorEntityOwnerUsers.getId()), "Send notification to tenant admins or customer users " + - "when comment is added by user on active alarm"); - - NotificationTemplate alarmAssignedNotificationTemplate = createTemplate(tenantId, "Alarm assigned notification", NotificationType.ALARM_ASSIGNMENT, - "Alarm '${alarmType}' (${alarmSeverity}) was assigned to user", - "${userEmail} assigned alarm on ${alarmOriginatorEntityType} '${alarmOriginatorName}' to ${assigneeEmail}", - "person", null, null); - AlarmAssignmentNotificationRuleTriggerConfig alarmAssignmentRuleTriggerConfig = new AlarmAssignmentNotificationRuleTriggerConfig(); - alarmAssignmentRuleTriggerConfig.setAlarmTypes(null); - alarmAssignmentRuleTriggerConfig.setAlarmSeverities(null); - alarmAssignmentRuleTriggerConfig.setAlarmStatuses(null); - alarmAssignmentRuleTriggerConfig.setNotifyOn(Set.of(AlarmAssignmentNotificationRuleTriggerConfig.Action.ASSIGNED)); - createRule(tenantId, "Alarm assignment", alarmAssignedNotificationTemplate.getId(), alarmAssignmentRuleTriggerConfig, - List.of(affectedUser.getId()), "Send notification to user when any alarm was assigned to him"); - - NotificationTemplate ruleEngineComponentLifecycleFailureNotificationTemplate = createTemplate(tenantId, "Rule chain/node lifecycle failure notification", NotificationType.RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT, - "${action:capitalize} failure in Rule chain '${ruleChainName}'", - "${componentType} '${componentName}' failed to ${action}", - "warning", "Go to rule chain", "/ruleChains/${ruleChainId}"); - RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig ruleEngineComponentLifecycleEventRuleTriggerConfig = new RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig(); - ruleEngineComponentLifecycleEventRuleTriggerConfig.setRuleChains(null); - ruleEngineComponentLifecycleEventRuleTriggerConfig.setRuleChainEvents(Set.of(ComponentLifecycleEvent.STARTED, ComponentLifecycleEvent.UPDATED, ComponentLifecycleEvent.STOPPED)); - ruleEngineComponentLifecycleEventRuleTriggerConfig.setOnlyRuleChainLifecycleFailures(true); - ruleEngineComponentLifecycleEventRuleTriggerConfig.setTrackRuleNodeEvents(true); - ruleEngineComponentLifecycleEventRuleTriggerConfig.setRuleNodeEvents(Set.of(ComponentLifecycleEvent.STARTED, ComponentLifecycleEvent.UPDATED, ComponentLifecycleEvent.STOPPED)); - ruleEngineComponentLifecycleEventRuleTriggerConfig.setOnlyRuleNodeLifecycleFailures(true); - createRule(tenantId, "Rule node initialization failure", ruleEngineComponentLifecycleFailureNotificationTemplate.getId(), - ruleEngineComponentLifecycleEventRuleTriggerConfig, List.of(tenantAdmins.getId()), - "Send notification to tenant admins when any Rule chain or Rule node failed to start, update or stop"); + defaultNotifications.create(tenantId, DefaultNotifications.newAlarm, tenantAdmins.getId()); + defaultNotifications.create(tenantId, DefaultNotifications.alarmUpdate, tenantAdmins.getId()); + defaultNotifications.create(tenantId, DefaultNotifications.deviceAction, tenantAdmins.getId()); + defaultNotifications.create(tenantId, DefaultNotifications.deviceActivity, tenantAdmins.getId()); + defaultNotifications.create(tenantId, DefaultNotifications.alarmComment, tenantAdmins.getId()); + defaultNotifications.create(tenantId, DefaultNotifications.alarmAssignment, affectedUser.getId()); + defaultNotifications.create(tenantId, DefaultNotifications.ruleEngineComponentLifecycleFailure, tenantAdmins.getId()); } private NotificationTarget createTarget(TenantId tenantId, String name, UsersFilter filter, String description) { @@ -255,76 +128,4 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS return notificationTargetService.saveNotificationTarget(tenantId, target); } - private NotificationTemplate createTemplate(TenantId tenantId, String name, NotificationType notificationType, - String subjectTemplate, String textTemplate) { - return createTemplate(tenantId, name, notificationType, subjectTemplate, textTemplate, null, null, null); - } - - private NotificationTemplate createTemplate(TenantId tenantId, String name, NotificationType notificationType, - String subjectTemplate, String textTemplate, - String icon, String button, String link) { - NotificationTemplate template = new NotificationTemplate(); - template.setTenantId(tenantId); - template.setName(name); - template.setNotificationType(notificationType); - - NotificationTemplateConfig templateConfig = new NotificationTemplateConfig(); - WebDeliveryMethodNotificationTemplate webTemplate = new WebDeliveryMethodNotificationTemplate(); - webTemplate.setSubject(subjectTemplate); - webTemplate.setBody(textTemplate); - ObjectNode additionalConfig = newObjectNode(); - ObjectNode iconConfig = newObjectNode(); - additionalConfig.set("icon", iconConfig); - ObjectNode buttonConfig = newObjectNode(); - additionalConfig.set("actionButtonConfig", buttonConfig); - if (icon != null) { - iconConfig.put("enabled", true) - .put("icon", icon) - .put("color", "#757575"); - } else { - iconConfig.put("enabled", false); - } - if (button != null) { - buttonConfig.put("enabled", true) - .put("text", button) - .put("linkType", "LINK") - .put("link", link); - } else { - buttonConfig.put("enabled", false); - } - webTemplate.setAdditionalConfig(additionalConfig); - webTemplate.setEnabled(true); - templateConfig.setDeliveryMethodsTemplates(Map.of( - NotificationDeliveryMethod.WEB, webTemplate - )); - template.setConfiguration(templateConfig); - return notificationTemplateService.saveNotificationTemplate(tenantId, template); - } - - private NotificationRule createRule(TenantId tenantId, String name, NotificationTemplateId templateId, - NotificationRuleTriggerConfig triggerConfig, List targets, - String description) { - NotificationRule rule = new NotificationRule(); - rule.setTenantId(tenantId); - rule.setName(name); - rule.setTemplateId(templateId); - rule.setTriggerType(triggerConfig.getTriggerType()); - rule.setTriggerConfig(triggerConfig); - if (rule.getTriggerType() == NotificationRuleTriggerType.ALARM) { - EscalatedNotificationRuleRecipientsConfig recipientsConfig = new EscalatedNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(rule.getTriggerType()); - recipientsConfig.setEscalationTable(Map.of(0, toUUIDs(targets))); - rule.setRecipientsConfig(recipientsConfig); - } else { - DefaultNotificationRuleRecipientsConfig recipientsConfig = new DefaultNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(rule.getTriggerType()); - recipientsConfig.setTargets(toUUIDs(targets)); - rule.setRecipientsConfig(recipientsConfig); - } - NotificationRuleConfig additionalConfig = new NotificationRuleConfig(); - additionalConfig.setDescription(description); - rule.setAdditionalConfig(additionalConfig); - return notificationRuleService.saveNotificationRule(tenantId, rule); - } - } diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTemplateService.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTemplateService.java index f1d0a79672..dfe62fd40e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTemplateService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTemplateService.java @@ -48,6 +48,12 @@ public class DefaultNotificationTemplateService extends AbstractEntityService im @Override public NotificationTemplate saveNotificationTemplate(TenantId tenantId, NotificationTemplate notificationTemplate) { + if (notificationTemplate.getId() != null) { + NotificationTemplate oldNotificationTemplate = findNotificationTemplateById(tenantId, notificationTemplate.getId()); + if (notificationTemplate.getNotificationType() != oldNotificationTemplate.getNotificationType()) { + throw new IllegalArgumentException("Notification type cannot be updated"); + } + } try { return notificationTemplateDao.saveAndFlush(tenantId, notificationTemplate); } catch (Exception e) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java new file mode 100644 index 0000000000..038ffe342a --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java @@ -0,0 +1,375 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.notification; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Builder; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.ApiUsageStateValue; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; +import org.thingsboard.server.common.data.id.NotificationTargetId; +import org.thingsboard.server.common.data.id.NotificationTemplateId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import org.thingsboard.server.common.data.notification.NotificationType; +import org.thingsboard.server.common.data.notification.rule.DefaultNotificationRuleRecipientsConfig; +import org.thingsboard.server.common.data.notification.rule.EscalatedNotificationRuleRecipientsConfig; +import org.thingsboard.server.common.data.notification.rule.NotificationRule; +import org.thingsboard.server.common.data.notification.rule.NotificationRuleConfig; +import org.thingsboard.server.common.data.notification.rule.trigger.AlarmAssignmentNotificationRuleTriggerConfig; +import org.thingsboard.server.common.data.notification.rule.trigger.AlarmCommentNotificationRuleTriggerConfig; +import org.thingsboard.server.common.data.notification.rule.trigger.AlarmNotificationRuleTriggerConfig; +import org.thingsboard.server.common.data.notification.rule.trigger.AlarmNotificationRuleTriggerConfig.AlarmAction; +import org.thingsboard.server.common.data.notification.rule.trigger.ApiUsageLimitNotificationRuleTriggerConfig; +import org.thingsboard.server.common.data.notification.rule.trigger.DeviceActivityNotificationRuleTriggerConfig; +import org.thingsboard.server.common.data.notification.rule.trigger.DeviceActivityNotificationRuleTriggerConfig.DeviceEvent; +import org.thingsboard.server.common.data.notification.rule.trigger.EntitiesLimitNotificationRuleTriggerConfig; +import org.thingsboard.server.common.data.notification.rule.trigger.EntityActionNotificationRuleTriggerConfig; +import org.thingsboard.server.common.data.notification.rule.trigger.NewPlatformVersionNotificationRuleTriggerConfig; +import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerConfig; +import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerType; +import org.thingsboard.server.common.data.notification.rule.trigger.RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig; +import org.thingsboard.server.common.data.notification.template.NotificationTemplate; +import org.thingsboard.server.common.data.notification.template.NotificationTemplateConfig; +import org.thingsboard.server.common.data.notification.template.WebDeliveryMethodNotificationTemplate; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.thingsboard.common.util.JacksonUtil.newObjectNode; +import static org.thingsboard.server.dao.DaoUtil.toUUIDs; + +@Service +@RequiredArgsConstructor +public class DefaultNotifications { + + public static final DefaultNotification maintenanceWork = DefaultNotification.builder() + .name("Maintenance work notification") + .subject("Infrastructure maintenance") + .text("Maintenance work is scheduled for tomorrow (7:00 a.m. - 9:00 a.m. UTC)") + .build(); + public static final DefaultNotification entitiesLimitForSysadmin = DefaultNotification.builder() + .name("Entities count limit notification for sysadmin") + .type(NotificationType.ENTITIES_LIMIT) + .subject("${entityType}s limit will be reached soon for tenant ${tenantName}") + .text("${entityType}s usage: ${currentCount}/${limit} (${percents}%)") + .icon("warning").color("#D6D634") + .rule(DefaultRule.builder() + .name("Entities count limit (sysadmin)") + .triggerConfig(EntitiesLimitNotificationRuleTriggerConfig.builder() + .entityTypes(null).threshold(0.8f) + .build()) + .description("Send notification to system admins when count of entities of some type reached 80% threshold of the limit for a tenant") + .build()) + .build(); + public static final DefaultNotification entitiesLimitForTenant = entitiesLimitForSysadmin.toBuilder() + .name("Entities count limit notification for tenant") + .subject("${entityType}s limit will be reached soon") + .rule(entitiesLimitForSysadmin.getRule().toBuilder() + .name("Entities count limit") + .description("Send notification to tenant admins when count of entities of some type reached 80% threshold of the limit") + .build()) + .build(); + public static final DefaultNotification apiFeatureWarningForSysadmin = DefaultNotification.builder() + .name("API feature warning notification for sysadmin") + .type(NotificationType.API_USAGE_LIMIT) + .subject("${feature} feature will be disabled soon for tenant ${tenantName}") + .text("Usage: ${currentValue} out of ${limit} ${unitLabel}s") + .icon("warning").color("#D6D634") + .rule(DefaultRule.builder() + .name("API feature warning (sysadmin)") + .triggerConfig(ApiUsageLimitNotificationRuleTriggerConfig.builder() + .apiFeatures(null) + .notifyOn(Set.of(ApiUsageStateValue.WARNING)) + .build()) + .description("Send notification to system admins on API feature usage WARNING state for a tenant") + .build()) + .build(); + public static final DefaultNotification apiFeatureWarningForTenant = apiFeatureWarningForSysadmin.toBuilder() + .name("API feature warning notification for tenant") + .subject("${feature} feature will be disabled soon") + .rule(apiFeatureWarningForSysadmin.getRule().toBuilder() + .name("API feature warning") + .description("Send notification to tenant admins on API feature usage WARNING state") + .build()) + .build(); + public static final DefaultNotification apiFeatureDisabledForSysadmin = DefaultNotification.builder() + .name("API feature disabled notification for sysadmin") + .type(NotificationType.API_USAGE_LIMIT) + .subject("${feature} feature was disabled for tenant ${tenantName}") + .text("Used ${currentValue} out of ${limit} ${unitLabel}s") + .icon("block").color("#e91a1a") + .rule(DefaultRule.builder() + .name("API feature disabled (sysadmin)") + .triggerConfig(ApiUsageLimitNotificationRuleTriggerConfig.builder() + .apiFeatures(null) + .notifyOn(Set.of(ApiUsageStateValue.DISABLED)) + .build()) + .description("Send notification to system admins when API feature is disabled for a tenant") + .build()) + .build(); + public static final DefaultNotification apiFeatureDisabledForTenant = apiFeatureDisabledForSysadmin.toBuilder() + .name("API feature disabled notification for tenant") + .subject("${feature} feature was disabled") + .rule(apiFeatureDisabledForSysadmin.getRule().toBuilder() + .name("API feature disabled") + .description("Send notification to tenant admins when API feature is disabled") + .build()) + .build(); + public static final DefaultNotification newPlatformVersion = DefaultNotification.builder() + .name("New platform version notification") + .type(NotificationType.NEW_PLATFORM_VERSION) + .subject("New version ${latestVersion} is available") + .text("Current platform version is ${currentVersion}") + .button("Open release notes").link("${latestVersionReleaseNotesUrl}") + .rule(DefaultRule.builder() + .name("New platform version") + .triggerConfig(new NewPlatformVersionNotificationRuleTriggerConfig()) + .description("Send notification to system admins and tenant admins when new platform version is available") + .build()) + .build(); + + public static final DefaultNotification newAlarm = DefaultNotification.builder() + .name("New alarm notification") + .type(NotificationType.ALARM) + .subject("New alarm '${alarmType}'") + .text("Severity: ${alarmSeverity}, originator: ${alarmOriginatorEntityType} '${alarmOriginatorName}'") + .icon("notifications").color(null) + .rule(DefaultRule.builder() + .name("New alarm") + .triggerConfig(AlarmNotificationRuleTriggerConfig.builder() + .alarmTypes(null) + .alarmSeverities(null) + .notifyOn(Set.of(AlarmAction.CREATED)) + .build()) + .description("Send notification to tenant admins when an alarm is created") + .build()) + .build(); + public static final DefaultNotification alarmUpdate = DefaultNotification.builder() + .name("Alarm update notification") + .type(NotificationType.ALARM) + .subject("Alarm '${alarmType}' - ${action}") + .text("Severity: ${alarmSeverity}, originator: ${alarmOriginatorEntityType} '${alarmOriginatorName}'") + .icon("notifications").color(null) + .rule(DefaultRule.builder() + .name("Alarm update") + .triggerConfig(AlarmNotificationRuleTriggerConfig.builder() + .alarmTypes(null) + .alarmSeverities(null) + .notifyOn(Set.of(AlarmAction.SEVERITY_CHANGED, AlarmAction.ACKNOWLEDGED, AlarmAction.CLEARED)) + .build()) + .description("Send notification to tenant admins when any alarm is updated or cleared") + .build()) + .build(); + public static final DefaultNotification deviceAction = DefaultNotification.builder() + .name("Device action notification") + .type(NotificationType.ENTITY_ACTION) + .subject("${entityType} was ${actionType}") + .text("${entityType} '${entityName}' was ${actionType} by user ${userEmail}") + .icon("info").color(null) + .button("Go to device").link("/devices/${entityId}") + .rule(DefaultRule.builder() + .name("Device created") + .triggerConfig(EntityActionNotificationRuleTriggerConfig.builder() + .entityTypes(Set.of(EntityType.DEVICE)) + .created(true) + .updated(false) + .deleted(false) + .build()) + .description("Send notification to tenant admins when device is created") + .build()) + .build(); + public static final DefaultNotification deviceActivity = DefaultNotification.builder() + .name("Device activity notification") + .type(NotificationType.DEVICE_ACTIVITY) + .subject("Device '${deviceName}' became ${eventType}") + .text("Device '${deviceName}' of type '${deviceType}' is now ${eventType}") + .icon("info").color(null) + .button("Go to device").link("/devices/${deviceId}") + .rule(DefaultRule.builder() + .name("Device activity status change") + .triggerConfig(DeviceActivityNotificationRuleTriggerConfig.builder() + .devices(null) + .deviceProfiles(null) + .notifyOn(Set.of(DeviceEvent.ACTIVE, DeviceEvent.INACTIVE)) + .build()) + .description("Send notification to tenant admins when any device changes its activity state") + .build()) + .build(); + public static final DefaultNotification alarmComment = DefaultNotification.builder() + .name("Alarm comment notification") + .type(NotificationType.ALARM_COMMENT) + .subject("Comment on '${alarmType}' alarm") + .text("${userEmail} ${action} comment: ${comment}") + .icon("people").color(null) + .rule(DefaultRule.builder() + .name("Comment on active alarm") + .triggerConfig(AlarmCommentNotificationRuleTriggerConfig.builder() + .alarmTypes(null) + .alarmSeverities(null) + .alarmStatuses(Set.of(AlarmSearchStatus.ACTIVE)) + .onlyUserComments(true) + .notifyOnCommentUpdate(false) + .build()) + .description("Send notification to tenant admins when comment is added by user on active alarm") + .build()) + .build(); + public static final DefaultNotification alarmAssignment = DefaultNotification.builder() + .name("Alarm assigned notification") + .type(NotificationType.ALARM_ASSIGNMENT) + .subject("Alarm '${alarmType}' (${alarmSeverity}) was assigned to user") + .text("${userEmail} assigned alarm on ${alarmOriginatorEntityType} '${alarmOriginatorName}' to ${assigneeEmail}") + .icon("person").color(null) + .rule(DefaultRule.builder() + .name("Alarm assignment") + .triggerConfig(AlarmAssignmentNotificationRuleTriggerConfig.builder() + .alarmTypes(null) + .alarmSeverities(null) + .alarmStatuses(null) + .notifyOn(Set.of(AlarmAssignmentNotificationRuleTriggerConfig.Action.ASSIGNED)) + .build()) + .description("Send notification to user when any alarm was assigned to him") + .build()) + .build(); + public static final DefaultNotification ruleEngineComponentLifecycleFailure = DefaultNotification.builder() + .name("Rule chain/node lifecycle failure notification") + .type(NotificationType.RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT) + .subject("${action:capitalize} failure in Rule chain '${ruleChainName}'") + .text("${componentType} '${componentName}' failed to ${action}") + .icon("warning").color(null) + .button("Go to rule chain").link("/ruleChains/${ruleChainId}") + .rule(DefaultRule.builder() + .name("Rule node initialization failure") + .triggerConfig(RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig.builder() + .ruleChains(null) + .ruleChainEvents(Set.of(ComponentLifecycleEvent.STARTED, ComponentLifecycleEvent.UPDATED, ComponentLifecycleEvent.STOPPED)) + .onlyRuleChainLifecycleFailures(true) + .trackRuleNodeEvents(true) + .ruleNodeEvents(Set.of(ComponentLifecycleEvent.STARTED, ComponentLifecycleEvent.UPDATED, ComponentLifecycleEvent.STOPPED)) + .onlyRuleNodeLifecycleFailures(true) + .build()) + .description("Send notification to tenant admins when any Rule chain or Rule node failed to start, update or stop") + .build()) + .build(); + + private final NotificationTemplateService templateService; + private final NotificationRuleService ruleService; + + public final void create(TenantId tenantId, DefaultNotification defaultNotification, NotificationTargetId... targets) { + NotificationTemplate template = defaultNotification.toTemplate(); + template.setTenantId(tenantId); + template = templateService.saveNotificationTemplate(tenantId, template); + + if (defaultNotification.getRule() != null && targets.length > 0) { + NotificationRule rule = defaultNotification.toRule(template.getId(), targets); + rule.setTenantId(tenantId); + rule = ruleService.saveNotificationRule(tenantId, rule); + } + } + + @Data + @Builder(toBuilder = true) + public static class DefaultNotification { + + private final String name; + private final NotificationType type; + private final String subject; + private final String text; + private final String icon; + private final String color; + private final String button; + private final String link; + + private final DefaultRule rule; + + public NotificationTemplate toTemplate() { + NotificationTemplate template = new NotificationTemplate(); + template.setName(name); + template.setNotificationType(type != null ? type : NotificationType.GENERAL); + + NotificationTemplateConfig templateConfig = new NotificationTemplateConfig(); + WebDeliveryMethodNotificationTemplate webTemplate = new WebDeliveryMethodNotificationTemplate(); + webTemplate.setSubject(subject); + webTemplate.setBody(text); + ObjectNode additionalConfig = newObjectNode(); + ObjectNode iconConfig = newObjectNode(); + additionalConfig.set("icon", iconConfig); + ObjectNode buttonConfig = newObjectNode(); + additionalConfig.set("actionButtonConfig", buttonConfig); + if (icon != null) { + iconConfig.put("enabled", true) + .put("icon", icon) + .put("color", color != null ? color : "#757575"); + } else { + iconConfig.put("enabled", false); + } + if (button != null) { + buttonConfig.put("enabled", true) + .put("text", button) + .put("linkType", "LINK") + .put("link", link); + } else { + buttonConfig.put("enabled", false); + } + webTemplate.setAdditionalConfig(additionalConfig); + webTemplate.setEnabled(true); + templateConfig.setDeliveryMethodsTemplates(Map.of( + NotificationDeliveryMethod.WEB, webTemplate + )); + template.setConfiguration(templateConfig); + return template; + } + + public NotificationRule toRule(NotificationTemplateId templateId, NotificationTargetId... targets) { + DefaultRule defaultRule = this.rule; + NotificationRule rule = new NotificationRule(); + rule.setName(name); + rule.setTemplateId(templateId); + rule.setTriggerType(defaultRule.getTriggerConfig().getTriggerType()); + rule.setTriggerConfig(defaultRule.getTriggerConfig()); + if (rule.getTriggerType() == NotificationRuleTriggerType.ALARM) { + EscalatedNotificationRuleRecipientsConfig recipientsConfig = new EscalatedNotificationRuleRecipientsConfig(); + recipientsConfig.setTriggerType(rule.getTriggerType()); + recipientsConfig.setEscalationTable(Map.of(0, toUUIDs(List.of(targets)))); + rule.setRecipientsConfig(recipientsConfig); + } else { + DefaultNotificationRuleRecipientsConfig recipientsConfig = new DefaultNotificationRuleRecipientsConfig(); + recipientsConfig.setTriggerType(rule.getTriggerType()); + recipientsConfig.setTargets(toUUIDs(List.of(targets))); + rule.setRecipientsConfig(recipientsConfig); + } + NotificationRuleConfig additionalConfig = new NotificationRuleConfig(); + additionalConfig.setDescription(defaultRule.getDescription()); + rule.setAdditionalConfig(additionalConfig); + return rule; + } + + } + + @Data + @Builder(toBuilder = true) + public static class DefaultRule { + private final String name; + private final NotificationRuleTriggerConfig triggerConfig; + private final String description; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/cache/NotificationRuleRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/notification/cache/NotificationRuleRedisCache.java deleted file mode 100644 index 230163a9eb..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/cache/NotificationRuleRedisCache.java +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Copyright © 2016-2023 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF 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.notification.cache; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.stereotype.Service; -import org.thingsboard.server.cache.CacheSpecsMap; -import org.thingsboard.server.cache.RedisTbTransactionalCache; -import org.thingsboard.server.cache.TBRedisCacheConfiguration; -import org.thingsboard.server.cache.TbFSTRedisSerializer; -import org.thingsboard.server.common.data.CacheConstants; - -@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") -@Service -public class NotificationRuleRedisCache extends RedisTbTransactionalCache { - - public NotificationRuleRedisCache(CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory, TBRedisCacheConfiguration configuration) { - super(CacheConstants.NOTIFICATION_RULES_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbFSTRedisSerializer<>()); - } - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java index 5a26715154..089c7b6857 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java @@ -51,6 +51,7 @@ import org.thingsboard.server.common.data.rule.RuleChainUpdateResult; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.rule.RuleNodeUpdateResult; import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.entity.EntityCountService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; @@ -90,6 +91,9 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC @Autowired private RuleNodeDao ruleNodeDao; + @Autowired + private EntityCountService entityCountService; + @Autowired private DataValidator ruleChainValidator; @@ -98,7 +102,11 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC public RuleChain saveRuleChain(RuleChain ruleChain) { ruleChainValidator.validate(ruleChain, RuleChain::getTenantId); try { - return ruleChainDao.save(ruleChain.getTenantId(), ruleChain); + RuleChain savedRuleChain = ruleChainDao.save(ruleChain.getTenantId(), ruleChain); + if (ruleChain.getId() == null) { + entityCountService.publishCountEntityEvictEvent(ruleChain.getTenantId(), EntityType.RULE_CHAIN); + } + return savedRuleChain; } catch (Exception e) { checkConstraintViolation(e, "rule_chain_external_id_unq_key", "Rule Chain with such external id already exists!"); throw e; @@ -696,6 +704,7 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC private void checkRuleNodesAndDelete(TenantId tenantId, RuleChainId ruleChainId) { try { + entityCountService.publishCountEntityEvictEvent(tenantId, EntityType.RULE_CHAIN); ruleChainDao.removeById(tenantId, ruleChainId.getId()); } catch (Exception t) { ConstraintViolationException e = extractConstraintViolationException(t).orElse(null); @@ -737,6 +746,11 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC return Optional.ofNullable(hasId); } + @Override + public long countByTenantId(TenantId tenantId) { + return ruleChainDao.countByTenantId(tenantId); + } + @Override public EntityType getEntityType() { return EntityType.RULE_CHAIN; diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java index 41a618959a..b884de0094 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.service; import com.google.common.collect.Iterators; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.hibernate.validator.HibernateValidator; import org.hibernate.validator.HibernateValidatorConfiguration; import org.hibernate.validator.cfg.ConstraintMapping; @@ -29,11 +30,13 @@ import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; import org.thingsboard.server.dao.exception.DataValidationException; -import javax.validation.Path; +import javax.validation.ConstraintViolation; import javax.validation.Validation; import javax.validation.Validator; import javax.validation.constraints.AssertTrue; -import java.util.List; +import javax.validation.metadata.ConstraintDescriptor; +import java.util.Collection; +import java.util.Set; import java.util.stream.Collectors; @Slf4j @@ -51,26 +54,31 @@ public class ConstraintValidator { } public static void validateFields(Object data, String errorPrefix) { - List constraintsViolations = getConstraintsViolations(data); + Set> constraintsViolations = fieldsValidator.validate(data); if (!constraintsViolations.isEmpty()) { - throw new DataValidationException(errorPrefix + String.join(", ", constraintsViolations)); + throw new DataValidationException(errorPrefix + getErrorMessage(constraintsViolations)); } } - public static List getConstraintsViolations(Object data) { - return fieldsValidator.validate(data).stream() - .map(constraintViolation -> { - String property; - if (constraintViolation.getConstraintDescriptor().getAttributes().containsKey("fieldName")) { - property = constraintViolation.getConstraintDescriptor().getAttributes().get("fieldName").toString(); - } else { - Path propertyPath = constraintViolation.getPropertyPath(); - property = Iterators.getLast(propertyPath.iterator()).toString(); - } - return property + " " + constraintViolation.getMessage(); - }) - .distinct() - .collect(Collectors.toList()); + public static String getErrorMessage(Collection> constraintsViolations) { + return constraintsViolations.stream() + .map(ConstraintValidator::getErrorMessage) + .distinct().sorted().collect(Collectors.joining(", ")); + } + + public static String getErrorMessage(ConstraintViolation constraintViolation) { + ConstraintDescriptor constraintDescriptor = constraintViolation.getConstraintDescriptor(); + String property = (String) constraintDescriptor.getAttributes().get("fieldName"); + if (StringUtils.isEmpty(property) && !(constraintDescriptor.getAnnotation() instanceof AssertTrue)) { + property = Iterators.getLast(constraintViolation.getPropertyPath().iterator()).toString(); + } + + String error = ""; + if (StringUtils.isNotEmpty(property)) { + error += property + " "; + } + error += constraintViolation.getMessage(); + return error; } private static void initializeValidators() { diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java index 400cc0e561..24e5ceb1b2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java @@ -17,10 +17,13 @@ package org.thingsboard.server.dao.service.validator; import com.google.protobuf.Descriptors; import com.google.protobuf.DynamicMessage; +import lombok.extern.slf4j.Slf4j; import org.eclipse.leshan.core.util.SecurityUtil; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; +import org.springframework.util.Base64Utils; import org.springframework.util.CollectionUtils; import org.thingsboard.server.common.data.DashboardInfo; import org.thingsboard.server.common.data.DeviceProfile; @@ -55,10 +58,22 @@ import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.PKIXParameters; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; import java.util.HashSet; import java.util.List; import java.util.Set; +@Slf4j @Component public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator { @@ -85,6 +100,12 @@ public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator implements A return alarmQueryRepository.countAlarmsByQuery(tenantId, customerId, query); } - @NotNull private static String getPropagationTypes(AlarmPropagationInfo ap) { String propagateRelationTypes; if (!CollectionUtils.isEmpty(ap.getPropagateRelationTypes())) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaBaseEdgeEventDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaBaseEdgeEventDao.java index 88fa72992b..3b126bf4d1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaBaseEdgeEventDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaBaseEdgeEventDao.java @@ -80,11 +80,11 @@ public class JpaBaseEdgeEventDao extends JpaAbstractSearchTextDao 0 ? System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(edge_events_ttl) : 1629158400000L; + long startTime = edgeEventsTtl > 0 ? System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(edgeEventsTtl) : 1629158400000L; long currentTime = System.currentTimeMillis(); var partitionStepInMs = TimeUnit.HOURS.toMillis(partitionSizeInHours); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java index 2885776a6d..9d4ffd58b4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java @@ -816,36 +816,39 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { } private String typeQuery(QueryContext ctx, EntityFilter filter) { - String type; + List types; String name; switch (filter.getType()) { case ASSET_TYPE: - type = ((AssetTypeFilter) filter).getAssetType(); + types = ((AssetTypeFilter) filter).getAssetTypes(); name = ((AssetTypeFilter) filter).getAssetNameFilter(); break; case DEVICE_TYPE: - type = ((DeviceTypeFilter) filter).getDeviceType(); + types = ((DeviceTypeFilter) filter).getDeviceTypes(); name = ((DeviceTypeFilter) filter).getDeviceNameFilter(); break; case ENTITY_VIEW_TYPE: - type = ((EntityViewTypeFilter) filter).getEntityViewType(); + types = ((EntityViewTypeFilter) filter).getEntityViewTypes(); name = ((EntityViewTypeFilter) filter).getEntityViewNameFilter(); break; case EDGE_TYPE: - type = ((EdgeTypeFilter) filter).getEdgeType(); + types = ((EdgeTypeFilter) filter).getEdgeTypes(); name = ((EdgeTypeFilter) filter).getEdgeNameFilter(); break; default: throw new RuntimeException("Not supported!"); } - ctx.addStringParameter("entity_filter_type_query_type", type); - ctx.addStringParameter("entity_filter_type_query_name", name); - - if (name.startsWith("%") || name.endsWith("%")) { - return "e.type = :entity_filter_type_query_type and lower(e.search_text) like lower(:entity_filter_type_query_name)"; + String typesFilter = "e.type in (:entity_filter_type_query_types)"; + ctx.addStringListParameter("entity_filter_type_query_types", types); + if (!StringUtils.isEmpty(name)) { + ctx.addStringParameter("entity_filter_type_query_name", name); + if (name.startsWith("%") || name.endsWith("%")) { + return typesFilter + " and lower(e.search_text) like lower(:entity_filter_type_query_name)"; + } + return typesFilter + " and lower(e.search_text) like lower(concat(:entity_filter_type_query_name, '%%'))"; + } else { + return typesFilter; } - - return "e.type = :entity_filter_type_query_type and lower(e.search_text) like lower(concat(:entity_filter_type_query_name, '%%'))"; } public static EntityType resolveEntityType(EntityFilter entityFilter) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index c1e3ca85ce..748c57b5dc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -42,6 +42,7 @@ import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.Validator; +import org.thingsboard.server.dao.util.KvUtils; import java.util.Collection; import java.util.Collections; @@ -155,10 +156,8 @@ public class BaseTimeseriesService implements TimeseriesService { @Override public ListenableFuture save(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { + KvUtils.validate(tsKvEntry); validate(entityId); - if (tsKvEntry == null) { - throw new IncorrectParameterException("Key value entry can't be null"); - } List> futures = Lists.newArrayListWithExpectedSize(INSERTS_PER_ENTRY); saveAndRegisterFutures(tenantId, futures, entityId, tsKvEntry, 0L); return Futures.transform(Futures.allAsList(futures), SUM_ALL_INTEGERS, MoreExecutors.directExecutor()); @@ -175,12 +174,10 @@ public class BaseTimeseriesService implements TimeseriesService { } private ListenableFuture doSave(TenantId tenantId, EntityId entityId, List tsKvEntries, long ttl, boolean saveLatest) { + KvUtils.validate(tsKvEntries); int inserts = saveLatest ? INSERTS_PER_ENTRY : INSERTS_PER_ENTRY_WITHOUT_LATEST; List> futures = Lists.newArrayListWithExpectedSize(tsKvEntries.size() * inserts); for (TsKvEntry tsKvEntry : tsKvEntries) { - if (tsKvEntry == null) { - throw new IncorrectParameterException("Key value entry can't be null"); - } if (saveLatest) { saveAndRegisterFutures(tenantId, futures, entityId, tsKvEntry, ttl); } else { @@ -192,11 +189,9 @@ public class BaseTimeseriesService implements TimeseriesService { @Override public ListenableFuture> saveLatest(TenantId tenantId, EntityId entityId, List tsKvEntries) { + KvUtils.validate(tsKvEntries); List> futures = Lists.newArrayListWithExpectedSize(tsKvEntries.size()); for (TsKvEntry tsKvEntry : tsKvEntries) { - if (tsKvEntry == null) { - throw new IncorrectParameterException("Key value entry can't be null"); - } futures.add(timeseriesLatestDao.saveLatest(tenantId, entityId, tsKvEntry)); } return Futures.allAsList(futures); diff --git a/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java index 6151e16d0d..ba49cc964c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java @@ -126,6 +126,7 @@ public class ApiUsageStateServiceImpl extends AbstractEntityService implements A List profileThresholds = new ArrayList<>(); for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) { + if (key.getApiLimitKey() == null) continue; profileThresholds.add(new BasicTsKvEntry(saved.getCreatedTime(), new LongDataEntry(key.getApiLimitKey(), configuration.getProfileThreshold(key)))); } tsService.save(tenantId, saved.getId(), profileThresholds, 0L); diff --git a/dao/src/main/java/org/thingsboard/server/dao/usagerecord/DefaultApiLimitService.java b/dao/src/main/java/org/thingsboard/server/dao/usagerecord/DefaultApiLimitService.java index d58c91c1c7..d5d19aa453 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/usagerecord/DefaultApiLimitService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/usagerecord/DefaultApiLimitService.java @@ -16,7 +16,6 @@ package org.thingsboard.server.dao.usagerecord; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.CustomerId; @@ -25,8 +24,6 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityTypeFilter; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; -import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; -import org.thingsboard.server.common.msg.notification.trigger.EntitiesLimitTrigger; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; @@ -36,8 +33,6 @@ public class DefaultApiLimitService implements ApiLimitService { private final EntityService entityService; private final TbTenantProfileCache tenantProfileCache; - @Autowired(required = false) - private NotificationRuleProcessor notificationRuleProcessor; @Override public boolean checkEntitiesLimit(TenantId tenantId, EntityType entityType) { @@ -47,14 +42,6 @@ public class DefaultApiLimitService implements ApiLimitService { EntityTypeFilter filter = new EntityTypeFilter(); filter.setEntityType(entityType); long currentCount = entityService.countEntitiesByQuery(tenantId, new CustomerId(EntityId.NULL_UUID), new EntityCountQuery(filter)); - if (notificationRuleProcessor != null) { - notificationRuleProcessor.process(EntitiesLimitTrigger.builder() - .tenantId(tenantId) - .entityType(entityType) - .currentCount(currentCount) - .limit(limit) - .build()); - } return currentCount < limit; } else { return true; diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/KvUtils.java b/dao/src/main/java/org/thingsboard/server/dao/util/KvUtils.java new file mode 100644 index 0000000000..8e80bd992d --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/util/KvUtils.java @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.util; + +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.dao.exception.IncorrectParameterException; +import org.thingsboard.server.dao.service.ConstraintValidator; + +import java.util.List; + +public class KvUtils { + public static void validate(List tsKvEntries) { + tsKvEntries.forEach(KvUtils::validate); + } + + public static void validate(KvEntry tsKvEntry) { + if (tsKvEntry == null) { + throw new IncorrectParameterException("Key value entry can't be null"); + } + ConstraintValidator.validateFields(tsKvEntry); + } +} diff --git a/dao/src/main/resources/sql/schema-entities-idx.sql b/dao/src/main/resources/sql/schema-entities-idx.sql index 96fb740088..4aad5fa1b2 100644 --- a/dao/src/main/resources/sql/schema-entities-idx.sql +++ b/dao/src/main/resources/sql/schema-entities-idx.sql @@ -20,10 +20,13 @@ CREATE INDEX IF NOT EXISTS idx_alarm_originator_created_time ON alarm(originator CREATE INDEX IF NOT EXISTS idx_alarm_tenant_created_time ON alarm(tenant_id, created_time DESC); --- Drop index by 'status' column and replace with new one that has only active alarms; +-- Drop index by 'status' column and replace with new indexes that has only active alarms; CREATE INDEX IF NOT EXISTS idx_alarm_originator_alarm_type_active ON alarm USING btree (originator_id, type) WHERE cleared = false; +CREATE INDEX IF NOT EXISTS idx_alarm_tenant_alarm_type_active + ON alarm USING btree (tenant_id, type) WHERE cleared = false; + CREATE INDEX IF NOT EXISTS idx_alarm_tenant_alarm_type_created_time ON alarm(tenant_id, type, created_time DESC); CREATE INDEX IF NOT EXISTS idx_alarm_tenant_assignee_created_time ON alarm(tenant_id, assignee_id, created_time DESC); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 4197dc2b07..ddd60ae812 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -804,7 +804,7 @@ CREATE TABLE IF NOT EXISTS notification_template ( tenant_id UUID NOT NULL, name VARCHAR(255) NOT NULL, notification_type VARCHAR(50) NOT NULL, - configuration VARCHAR(10000) NOT NULL, + configuration VARCHAR(10000000) NOT NULL, CONSTRAINT uq_notification_template_name UNIQUE (tenant_id, name) ); @@ -827,7 +827,7 @@ CREATE TABLE IF NOT EXISTS notification_request ( tenant_id UUID NOT NULL, targets VARCHAR(10000) NOT NULL, template_id UUID, - template VARCHAR(10000), + template VARCHAR(10000000), info VARCHAR(1000), additional_config VARCHAR(1000), originator_entity_id UUID, diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/BaseAssetServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/BaseAssetServiceTest.java index 9f4c8fe363..3f9c94ca14 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/BaseAssetServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/BaseAssetServiceTest.java @@ -211,7 +211,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { @Test public void testFindAssetsByTenantId() { List assets = new ArrayList<>(); - for (int i=0;i<178;i++) { + for (int i=0;i<13;i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); asset.setName("Asset"+i); @@ -220,7 +220,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { } List loadedAssets = new ArrayList<>(); - PageLink pageLink = new PageLink(23); + PageLink pageLink = new PageLink(3); PageData pageData = null; do { pageData = assetService.findAssetsByTenantId(tenantId, pageLink); @@ -237,7 +237,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { assetService.deleteAssetsByTenantId(tenantId); - pageLink = new PageLink(33); + pageLink = new PageLink(4); pageData = assetService.findAssetsByTenantId(tenantId, pageLink); Assert.assertFalse(pageData.hasNext()); Assert.assertTrue(pageData.getData().isEmpty()); @@ -247,7 +247,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { public void testFindAssetsByTenantIdAndName() { String title1 = "Asset title 1"; List assetsTitle1 = new ArrayList<>(); - for (int i=0;i<143;i++) { + for (int i=0;i<13;i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); @@ -259,7 +259,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { } String title2 = "Asset title 2"; List assetsTitle2 = new ArrayList<>(); - for (int i=0;i<175;i++) { + for (int i=0;i<17;i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); @@ -271,7 +271,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { } List loadedAssetsTitle1 = new ArrayList<>(); - PageLink pageLink = new PageLink(15, 0, title1); + PageLink pageLink = new PageLink(3, 0, title1); PageData pageData = null; do { pageData = assetService.findAssetInfosByTenantId(tenantId, pageLink); @@ -325,7 +325,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { String title1 = "Asset title 1"; String type1 = "typeA"; List assetsType1 = new ArrayList<>(); - for (int i=0;i<143;i++) { + for (int i=0;i<13;i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); @@ -338,7 +338,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { String title2 = "Asset title 2"; String type2 = "typeB"; List assetsType2 = new ArrayList<>(); - for (int i=0;i<175;i++) { + for (int i=0;i<17;i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); @@ -350,7 +350,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { } List loadedAssetsType1 = new ArrayList<>(); - PageLink pageLink = new PageLink(15); + PageLink pageLink = new PageLink(3); PageData pageData = null; do { pageData = assetService.findAssetsByTenantIdAndType(tenantId, type1, pageLink); @@ -408,7 +408,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { CustomerId customerId = customer.getId(); List assets = new ArrayList<>(); - for (int i=0;i<278;i++) { + for (int i=0;i<13;i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); asset.setName("Asset"+i); @@ -418,7 +418,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { } List loadedAssets = new ArrayList<>(); - PageLink pageLink = new PageLink(23); + PageLink pageLink = new PageLink(3); PageData pageData = null; do { pageData = assetService.findAssetInfosByTenantIdAndCustomerId(tenantId, customerId, pageLink); @@ -435,7 +435,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { assetService.unassignCustomerAssets(tenantId, customerId); - pageLink = new PageLink(33); + pageLink = new PageLink(4); pageData = assetService.findAssetInfosByTenantIdAndCustomerId(tenantId, customerId, pageLink); Assert.assertFalse(pageData.hasNext()); Assert.assertTrue(pageData.getData().isEmpty()); @@ -452,7 +452,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { String title1 = "Asset title 1"; List assetsTitle1 = new ArrayList<>(); - for (int i=0;i<175;i++) { + for (int i=0;i<17;i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); @@ -465,7 +465,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { } String title2 = "Asset title 2"; List assetsTitle2 = new ArrayList<>(); - for (int i=0;i<143;i++) { + for (int i=0;i<13;i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); @@ -478,7 +478,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { } List loadedAssetsTitle1 = new ArrayList<>(); - PageLink pageLink = new PageLink(15, 0, title1); + PageLink pageLink = new PageLink(3, 0, title1); PageData pageData = null; do { pageData = assetService.findAssetsByTenantIdAndCustomerId(tenantId, customerId, pageLink); @@ -540,7 +540,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { String title1 = "Asset title 1"; String type1 = "typeC"; List assetsType1 = new ArrayList<>(); - for (int i=0;i<175;i++) { + for (int i=0;i<17;i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); @@ -554,7 +554,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { String title2 = "Asset title 2"; String type2 = "typeD"; List assetsType2 = new ArrayList<>(); - for (int i=0;i<143;i++) { + for (int i=0;i<13;i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); @@ -567,7 +567,7 @@ public abstract class BaseAssetServiceTest extends AbstractServiceTest { } List loadedAssetsType1 = new ArrayList<>(); - PageLink pageLink = new PageLink(15); + PageLink pageLink = new PageLink(3); PageData pageData = null; do { pageData = assetService.findAssetsByTenantIdAndCustomerIdAndType(tenantId, customerId, type1, pageLink); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/BaseEntityServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/BaseEntityServiceTest.java index 1af63c0ed7..5ec546cd4e 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/BaseEntityServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/BaseEntityServiceTest.java @@ -131,7 +131,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest { } DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter(""); EntityCountQuery countQuery = new EntityCountQuery(filter); @@ -139,11 +139,11 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest { long count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); Assert.assertEquals(97, count); - filter.setDeviceType("unknown"); + filter.setDeviceTypes(List.of("unknown")); count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); Assert.assertEquals(0, count); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter("Device1"); count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); Assert.assertEquals(11, count); @@ -235,7 +235,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest { } EdgeTypeFilter filter = new EdgeTypeFilter(); - filter.setEdgeType("default"); + filter.setEdgeTypes(List.of("default")); filter.setEdgeNameFilter(""); EntityCountQuery countQuery = new EntityCountQuery(filter); @@ -243,11 +243,11 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest { long count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); Assert.assertEquals(97, count); - filter.setEdgeType("unknown"); + filter.setEdgeTypes(List.of("unknown")); count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); Assert.assertEquals(0, count); - filter.setEdgeType("default"); + filter.setEdgeTypes(List.of("default")); filter.setEdgeNameFilter("Edge1"); count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); Assert.assertEquals(11, count); @@ -813,7 +813,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest { } DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter(""); EntityDataSortOrder sortOrder = new EntityDataSortOrder( @@ -1337,7 +1337,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest { private EntityDataQuery createDeviceSearchQuery(String deviceField, StringOperation operation, String searchQuery) { DeviceTypeFilter deviceTypeFilter = new DeviceTypeFilter(); - deviceTypeFilter.setDeviceType("default"); + deviceTypeFilter.setDeviceTypes(List.of("default")); deviceTypeFilter.setDeviceNameFilter(""); EntityDataSortOrder sortOrder = new EntityDataSortOrder( @@ -1402,7 +1402,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest { Futures.allAsList(attributeFutures).get(); DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter(""); EntityDataSortOrder sortOrder = new EntityDataSortOrder( @@ -1502,7 +1502,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest { Futures.allAsList(attributeFutures).get(); DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter(""); EntityDataSortOrder sortOrder = new EntityDataSortOrder( @@ -1648,7 +1648,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest { Futures.allAsList(timeseriesFutures).get(); DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter(""); EntityDataSortOrder sortOrder = new EntityDataSortOrder( @@ -1780,7 +1780,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest { Futures.allAsList(attributeFutures).get(); DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter(""); EntityDataSortOrder sortOrder = new EntityDataSortOrder( @@ -1912,7 +1912,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest { } DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter(""); EntityDataSortOrder sortOrder = new EntityDataSortOrder( @@ -2029,7 +2029,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest { } DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter(""); EntityDataSortOrder sortOrder = new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.DESC); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/ConstraintValidatorTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/ConstraintValidatorTest.java new file mode 100644 index 0000000000..24a32b6a61 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/ConstraintValidatorTest.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.service; + +import org.junit.Assert; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.dao.exception.DataValidationException; + +class ConstraintValidatorTest { + + private static final int MIN_IN_MS = 60000; + private static final int _1M = 1_000_000; + + @Test + void validateFields() { + StringDataEntry stringDataEntryValid = new StringDataEntry("key", "value"); + StringDataEntry stringDataEntryInvalid1 = new StringDataEntry("", "value"); + + Assert.assertThrows(DataValidationException.class, () -> ConstraintValidator.validateFields(stringDataEntryInvalid1)); + ConstraintValidator.validateFields(stringDataEntryValid); + } + + @Test + void validatePerMinute() { + StringDataEntry stringDataEntryValid = new StringDataEntry("key", "value"); + + long start = System.currentTimeMillis(); + for (int i = 0; i < _1M; i++) { + ConstraintValidator.validateFields(stringDataEntryValid); + } + long end = System.currentTimeMillis(); + + Assertions.assertTrue(MIN_IN_MS > end - start); + } +} \ No newline at end of file diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index b55fba1f3b..d89211cb2f 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -82,6 +82,8 @@ redis.connection.password= security.user_login_case_sensitive=true security.claim.allowClaimingByDefault=true security.claim.duration=60000 +security.java_cacerts.path=/path/to/cacerts/file +security.java_cacerts.password=myPassword database.ts_max_intervals=700 diff --git a/monitoring/pom.xml b/monitoring/pom.xml new file mode 100644 index 0000000000..e044635e98 --- /dev/null +++ b/monitoring/pom.xml @@ -0,0 +1,175 @@ + + + + 4.0.0 + + org.thingsboard + 3.5.0-SNAPSHOT + thingsboard + + + monitoring + ThingsBoard Monitoring Service + jar + + + UTF-8 + ${basedir}/.. + java + false + process-resources + package + tb-monitoring + false + ${project.build.directory}/windows + ThingsBoard Monitoring Service + org.thingsboard.monitoring.ThingsboardMonitoringApplication + + 2.6.1 + 2.0.0-M4 + + + + + org.thingsboard.common + data + + + org.thingsboard.common + util + + + org.thingsboard + rest-client + compile + + + org.springframework.boot + spring-boot-starter + + + + org.eclipse.californium + californium-core + ${californium.version} + + + org.eclipse.californium + scandium + ${californium.version} + + + org.eclipse.paho + org.eclipse.paho.client.mqttv3 + + + org.apache.httpcomponents + httpclient + + + org.eclipse.leshan + leshan-client-cf + ${leshan.version} + + + org.eclipse.leshan + leshan-core + ${leshan.version} + + + org.java-websocket + Java-WebSocket + compile + + + com.google.guava + guava + + + org.apache.commons + commons-lang3 + + + org.slf4j + slf4j-api + + + org.slf4j + log4j-over-slf4j + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + + + ${pkg.name}-${project.version} + + + ${project.basedir}/src/main/resources + + + + + org.apache.maven.plugins + maven-resources-plugin + + + org.apache.maven.plugins + maven-dependency-plugin + + + org.apache.maven.plugins + maven-jar-plugin + + + org.springframework.boot + spring-boot-maven-plugin + + + org.thingsboard + gradle-maven-plugin + + + org.apache.maven.plugins + maven-assembly-plugin + + + org.apache.maven.plugins + maven-install-plugin + + + + + + jenkins + Jenkins Repository + https://repo.jenkins-ci.org/releases + + false + + + + diff --git a/monitoring/src/main/conf/logback.xml b/monitoring/src/main/conf/logback.xml new file mode 100644 index 0000000000..3c1e1382b7 --- /dev/null +++ b/monitoring/src/main/conf/logback.xml @@ -0,0 +1,51 @@ + + + + + + + ${pkg.logFolder}/${pkg.name}.log + + ${pkg.logFolder}/${pkg.name}.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + diff --git a/monitoring/src/main/conf/tb-monitoring.conf b/monitoring/src/main/conf/tb-monitoring.conf new file mode 100644 index 0000000000..d1d729bd20 --- /dev/null +++ b/monitoring/src/main/conf/tb-monitoring.conf @@ -0,0 +1,22 @@ +# +# Copyright © 2016-2023 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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 JAVA_OPTS="$JAVA_OPTS -Xlog:gc*,heap*,age*,safepoint=debug:file=/var/log/tb-monitoring/gc.log:time,uptime,level,tags:filecount=10,filesize=10M" +export JAVA_OPTS="$JAVA_OPTS -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError" +export JAVA_OPTS="$JAVA_OPTS -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:+UseG1GC -XX:MaxGCPauseMillis=500 -XX:+UseStringDeduplication -XX:+ParallelRefProcEnabled -XX:MaxTenuringThreshold=10" +export LOG_FILENAME=tb-monitoring.out +export LOADER_PATH=/usr/share/tb-monitoring/conf diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/ThingsboardMonitoringApplication.java b/monitoring/src/main/java/org/thingsboard/monitoring/ThingsboardMonitoringApplication.java new file mode 100644 index 0000000000..3fa43a8175 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/ThingsboardMonitoringApplication.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.thingsboard.common.util.ThingsBoardThreadFactory; + +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +@SpringBootApplication +@EnableScheduling +@Slf4j +public class ThingsboardMonitoringApplication { + + public static void main(String[] args) { + new SpringApplicationBuilder(ThingsboardMonitoringApplication.class) + .properties(Map.of("spring.config.name", "tb-monitoring")) + .run(args); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java b/monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java new file mode 100644 index 0000000000..473a17aa00 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java @@ -0,0 +1,171 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.client; + +import lombok.Getter; +import lombok.Setter; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.californium.core.network.CoapEndpoint; +import org.eclipse.californium.core.network.config.NetworkConfig; +import org.eclipse.californium.core.observe.ObservationStore; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.eclipse.leshan.client.californium.LeshanClient; +import org.eclipse.leshan.client.californium.LeshanClientBuilder; +import org.eclipse.leshan.client.engine.DefaultRegistrationEngineFactory; +import org.eclipse.leshan.client.object.Security; +import org.eclipse.leshan.client.object.Server; +import org.eclipse.leshan.client.resource.BaseInstanceEnabler; +import org.eclipse.leshan.client.resource.DummyInstanceEnabler; +import org.eclipse.leshan.client.resource.ObjectsInitializer; +import org.eclipse.leshan.client.servers.ServerIdentity; +import org.eclipse.leshan.core.californium.EndpointFactory; +import org.eclipse.leshan.core.model.InvalidDDFFileException; +import org.eclipse.leshan.core.model.LwM2mModel; +import org.eclipse.leshan.core.model.ObjectLoader; +import org.eclipse.leshan.core.model.ObjectModel; +import org.eclipse.leshan.core.model.StaticModel; +import org.eclipse.leshan.core.node.codec.DefaultLwM2mDecoder; +import org.eclipse.leshan.core.node.codec.DefaultLwM2mEncoder; +import org.eclipse.leshan.core.response.ReadResponse; +import org.thingsboard.monitoring.util.ResourceUtils; + +import javax.security.auth.Destroyable; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.eclipse.leshan.client.object.Security.noSec; +import static org.eclipse.leshan.core.LwM2mId.ACCESS_CONTROL; +import static org.eclipse.leshan.core.LwM2mId.DEVICE; +import static org.eclipse.leshan.core.LwM2mId.SECURITY; +import static org.eclipse.leshan.core.LwM2mId.SERVER; + +@Slf4j +public class Lwm2mClient extends BaseInstanceEnabler implements Destroyable { + + @Getter + @Setter + private LeshanClient leshanClient; + + private static final List supportedResources = Collections.singletonList(0); + + private String data = ""; + + private String serverUri; + private String endpoint; + + public Lwm2mClient(String serverUri, String endpoint) { + this.serverUri = serverUri; + this.endpoint = endpoint; + } + + public Lwm2mClient() { + } + + public void initClient() throws InvalidDDFFileException, IOException { + String[] resources = new String[]{"0.xml", "1.xml", "2.xml", "test-model.xml"}; + List models = new ArrayList<>(); + for (String resourceName : resources) { + models.addAll(ObjectLoader.loadDdfFile(ResourceUtils.getResourceAsStream("lwm2m/models/" + resourceName), resourceName)); + } + + Security security = noSec(serverUri, 123); + NetworkConfig coapConfig = new NetworkConfig().setString(NetworkConfig.Keys.COAP_PORT, StringUtils.substringAfterLast(serverUri, ":")); + + LeshanClient leshanClient; + + LwM2mModel model = new StaticModel(models); + ObjectsInitializer initializer = new ObjectsInitializer(model); + initializer.setInstancesForObject(SECURITY, security); + initializer.setInstancesForObject(SERVER, new Server(123, 300)); + initializer.setInstancesForObject(DEVICE, this); + initializer.setClassForObject(ACCESS_CONTROL, DummyInstanceEnabler.class); + DtlsConnectorConfig.Builder dtlsConfig = new DtlsConnectorConfig.Builder(); + dtlsConfig.setRecommendedCipherSuitesOnly(true); + dtlsConfig.setClientOnly(); + + DefaultRegistrationEngineFactory engineFactory = new DefaultRegistrationEngineFactory(); + engineFactory.setReconnectOnUpdate(false); + engineFactory.setResumeOnConnect(true); + + EndpointFactory endpointFactory = new EndpointFactory() { + + @Override + public CoapEndpoint createUnsecuredEndpoint(InetSocketAddress address, NetworkConfig coapConfig, + ObservationStore store) { + CoapEndpoint.Builder builder = new CoapEndpoint.Builder(); + builder.setInetSocketAddress(address); + builder.setNetworkConfig(coapConfig); + return builder.build(); + } + + @Override + public CoapEndpoint createSecuredEndpoint(DtlsConnectorConfig dtlsConfig, NetworkConfig coapConfig, + ObservationStore store) { + CoapEndpoint.Builder builder = new CoapEndpoint.Builder(); + DtlsConnectorConfig.Builder dtlsConfigBuilder = new DtlsConnectorConfig.Builder(dtlsConfig); + builder.setConnector(new DTLSConnector(dtlsConfigBuilder.build())); + builder.setNetworkConfig(coapConfig); + return builder.build(); + } + }; + + LeshanClientBuilder builder = new LeshanClientBuilder(endpoint); + builder.setObjects(initializer.createAll()); + builder.setCoapConfig(coapConfig); + builder.setDtlsConfig(dtlsConfig); + builder.setRegistrationEngineFactory(engineFactory); + builder.setEndpointFactory(endpointFactory); + builder.setDecoder(new DefaultLwM2mDecoder(false)); + builder.setEncoder(new DefaultLwM2mEncoder(false)); + leshanClient = builder.build(); + + setLeshanClient(leshanClient); + + leshanClient.start(); + } + + @Override + public List getAvailableResourceIds(ObjectModel model) { + return supportedResources; + } + + @Override + public ReadResponse read(ServerIdentity identity, int resourceId) { + if (supportedResources.contains(resourceId)) { + return ReadResponse.success(resourceId, data); + } + return super.read(identity, resourceId); + } + + @SneakyThrows + public void send(String data, int resource) { + this.data = data; + fireResourcesChange(resource); + } + + @Override + public void destroy() { + if (leshanClient != null) { + leshanClient.destroy(true); + } + } +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/client/TbClient.java b/monitoring/src/main/java/org/thingsboard/monitoring/client/TbClient.java new file mode 100644 index 0000000000..9dcc58cf2f --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/client/TbClient.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.client; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; +import org.thingsboard.rest.client.RestClient; + +import java.time.Duration; + +@Component +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class TbClient extends RestClient { + + @Value("${monitoring.rest.username}") + private String username; + @Value("${monitoring.rest.password}") + private String password; + + public TbClient(@Value("${monitoring.rest.base_url}") String baseUrl, + @Value("${monitoring.rest.request_timeout_ms}") int requestTimeoutMs) { + super(new RestTemplateBuilder() + .setConnectTimeout(Duration.ofMillis(requestTimeoutMs)) + .setReadTimeout(Duration.ofMillis(requestTimeoutMs)) + .build(), baseUrl); + } + + public String logIn() { + login(username, password); + return getToken(); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/client/WsClient.java b/monitoring/src/main/java/org/thingsboard/monitoring/client/WsClient.java new file mode 100644 index 0000000000..20e7a900ff --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/client/WsClient.java @@ -0,0 +1,194 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.client; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomUtils; +import org.apache.commons.lang3.StringUtils; +import org.java_websocket.client.WebSocketClient; +import org.java_websocket.handshake.ServerHandshake; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.monitoring.data.cmd.CmdsWrapper; +import org.thingsboard.monitoring.data.cmd.EntityDataCmd; +import org.thingsboard.monitoring.data.cmd.EntityDataUpdate; +import org.thingsboard.monitoring.data.cmd.LatestValueCmd; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityListFilter; + +import javax.net.ssl.SSLParameters; +import java.net.URI; +import java.nio.channels.NotYetConnectedException; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +@Slf4j +public class WsClient extends WebSocketClient implements AutoCloseable { + + public volatile JsonNode lastMsg; + private CountDownLatch reply; + private CountDownLatch update; + + private final Lock updateLock = new ReentrantLock(); + + private long requestTimeoutMs; + + public WsClient(URI serverUri, long requestTimeoutMs) { + super(serverUri); + this.requestTimeoutMs = requestTimeoutMs; + } + + @Override + public void onOpen(ServerHandshake serverHandshake) { + + } + + @Override + public void onMessage(String s) { + if (s == null) { + return; + } + updateLock.lock(); + try { + lastMsg = JacksonUtil.toJsonNode(s); + log.trace("Received new msg: {}", lastMsg.toPrettyString()); + if (update != null) { + update.countDown(); + } + if (reply != null) { + reply.countDown(); + } + } finally { + updateLock.unlock(); + } + } + + @Override + public void onClose(int i, String s, boolean b) { + log.debug("WebSocket client is closed"); + } + + @Override + public void onError(Exception e) { + log.error("WebSocket client error:", e); + } + + public void registerWaitForUpdate() { + updateLock.lock(); + try { + lastMsg = null; + update = new CountDownLatch(1); + } finally { + updateLock.unlock(); + } + log.trace("Registered wait for update"); + } + + @Override + public void send(String text) throws NotYetConnectedException { + updateLock.lock(); + try { + reply = new CountDownLatch(1); + } finally { + updateLock.unlock(); + } + super.send(text); + } + + public WsClient subscribeForTelemetry(List devices, String key) { + EntityDataCmd cmd = new EntityDataCmd(); + cmd.setCmdId(RandomUtils.nextInt(0, 1000)); + + EntityListFilter devicesFilter = new EntityListFilter(); + devicesFilter.setEntityType(EntityType.DEVICE); + devicesFilter.setEntityList(devices.stream().map(UUID::toString).collect(Collectors.toList())); + EntityDataPageLink pageLink = new EntityDataPageLink(100,0, null, null); + EntityDataQuery devicesQuery = new EntityDataQuery(devicesFilter, pageLink, Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + cmd.setQuery(devicesQuery); + + LatestValueCmd latestCmd = new LatestValueCmd(); + latestCmd.setKeys(List.of(new EntityKey(EntityKeyType.TIME_SERIES, key))); + cmd.setLatestCmd(latestCmd); + + CmdsWrapper wrapper = new CmdsWrapper(); + wrapper.setEntityDataCmds(List.of(cmd)); + send(JacksonUtil.toString(wrapper)); + return this; + } + + public JsonNode waitForUpdate(long ms) { + log.trace("update latch count: {}", update.getCount()); + try { + if (update.await(ms, TimeUnit.MILLISECONDS)) { + log.trace("Waited for update"); + return getLastMsg(); + } + } catch (InterruptedException e) { + log.debug("Failed to await reply", e); + } + log.trace("No update arrived within {} ms", ms); + return null; + } + + public JsonNode waitForReply() { + try { + if (reply.await(requestTimeoutMs, TimeUnit.MILLISECONDS)) { + log.trace("Waited for reply"); + return getLastMsg(); + } + } catch (InterruptedException e) { + log.debug("Failed to await reply", e); + } + log.trace("No reply arrived within {} ms", requestTimeoutMs); + throw new IllegalStateException("No WS reply arrived within " + requestTimeoutMs + " ms"); + } + + private JsonNode getLastMsg() { + if (lastMsg != null) { + JsonNode errorMsg = lastMsg.get("errorMsg"); + if (errorMsg != null && !errorMsg.isNull() && StringUtils.isNotEmpty(errorMsg.asText())) { + throw new RuntimeException("WS error from server: " + errorMsg.asText()); + } else { + return lastMsg; + } + } else { + return null; + } + } + + public Object getTelemetryUpdate(UUID deviceId, String key) { + JsonNode lastMsg = getLastMsg(); + if (lastMsg == null || lastMsg.isNull()) return null; + EntityDataUpdate update = JacksonUtil.treeToValue(lastMsg, EntityDataUpdate.class); + return update.getLatest(deviceId, key); + } + + @Override + protected void onSetSSLParameters(SSLParameters sslParameters) { + sslParameters.setEndpointIdentificationAlgorithm(null); + } + +} \ No newline at end of file diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/client/WsClientFactory.java b/monitoring/src/main/java/org/thingsboard/monitoring/client/WsClientFactory.java new file mode 100644 index 0000000000..1bab803dc9 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/client/WsClientFactory.java @@ -0,0 +1,59 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.client; + +import lombok.RequiredArgsConstructor; +import org.apache.http.ssl.SSLContextBuilder; +import org.apache.http.ssl.SSLContexts; +import org.apache.http.ssl.TrustStrategy; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.thingsboard.monitoring.data.Latencies; +import org.thingsboard.monitoring.service.MonitoringReporter; +import org.thingsboard.monitoring.util.TbStopWatch; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +@Component +@RequiredArgsConstructor +public class WsClientFactory { + + private final MonitoringReporter monitoringReporter; + private final TbStopWatch stopWatch; + @Value("${monitoring.ws.base_url}") + private String baseUrl; + @Value("${monitoring.ws.request_timeout_ms}") + private int requestTimeoutMs; + + public WsClient createClient(String accessToken) throws Exception { + URI uri = new URI(baseUrl + "/api/ws/plugins/telemetry?token=" + accessToken); + stopWatch.start(); + WsClient wsClient = new WsClient(uri, requestTimeoutMs); + if (baseUrl.startsWith("wss")) { + SSLContextBuilder builder = SSLContexts.custom(); + builder.loadTrustMaterial(null, (TrustStrategy) (chain, authType) -> true); + wsClient.setSocketFactory(builder.build().getSocketFactory()); + } + boolean connected = wsClient.connectBlocking(requestTimeoutMs, TimeUnit.MILLISECONDS); + if (!connected) { + throw new IllegalStateException("Failed to establish WS session"); + } + monitoringReporter.reportLatency(Latencies.WS_CONNECT, stopWatch.getTime()); + return wsClient; + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/DeviceConfig.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/DeviceConfig.java new file mode 100644 index 0000000000..548ad6d08b --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/DeviceConfig.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.config; + +import lombok.Data; +import org.apache.commons.lang3.StringUtils; +import org.thingsboard.server.common.data.security.DeviceCredentials; + +import java.util.UUID; + +@Data +public class DeviceConfig { + + private UUID id; + private DeviceCredentials credentials; + + public void setId(String id) { + this.id = StringUtils.isNotEmpty(id) ? UUID.fromString(id) : null; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationText.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/MonitoringTargetConfig.java similarity index 71% rename from common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationText.java rename to monitoring/src/main/java/org/thingsboard/monitoring/config/MonitoringTargetConfig.java index 6cb0e673a5..5f1ab49e91 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationText.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/MonitoringTargetConfig.java @@ -13,18 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.data.notification.template; +package org.thingsboard.monitoring.config; -import lombok.AllArgsConstructor; import lombok.Data; -import lombok.NoArgsConstructor; @Data -@AllArgsConstructor -@NoArgsConstructor -public class NotificationText { +public class MonitoringTargetConfig { - private String body; - private String subject; + private String baseUrl; + private DeviceConfig device; } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/TransportType.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/TransportType.java new file mode 100644 index 0000000000..a3aaa7ec98 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/TransportType.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.config; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.thingsboard.monitoring.transport.TransportHealthChecker; +import org.thingsboard.monitoring.transport.impl.CoapTransportHealthChecker; +import org.thingsboard.monitoring.transport.impl.HttpTransportHealthChecker; +import org.thingsboard.monitoring.transport.impl.Lwm2mTransportHealthChecker; +import org.thingsboard.monitoring.transport.impl.MqttTransportHealthChecker; + +@AllArgsConstructor +@Getter +public enum TransportType { + + MQTT(MqttTransportHealthChecker.class), + COAP(CoapTransportHealthChecker.class), + HTTP(HttpTransportHealthChecker.class), + LWM2M(Lwm2mTransportHealthChecker.class); + + private final Class> serviceClass; + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/cache/NotificationRuleCaffeineCache.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/service/CoapTransportMonitoringConfig.java similarity index 51% rename from dao/src/main/java/org/thingsboard/server/dao/notification/cache/NotificationRuleCaffeineCache.java rename to monitoring/src/main/java/org/thingsboard/monitoring/config/service/CoapTransportMonitoringConfig.java index 13b9d5a77a..905858b4eb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/cache/NotificationRuleCaffeineCache.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/service/CoapTransportMonitoringConfig.java @@ -13,20 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao.notification.cache; +package org.thingsboard.monitoring.config.service; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.cache.CacheManager; -import org.springframework.stereotype.Service; -import org.thingsboard.server.cache.CaffeineTbTransactionalCache; -import org.thingsboard.server.common.data.CacheConstants; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.thingsboard.monitoring.config.TransportType; -@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) -@Service -public class NotificationRuleCaffeineCache extends CaffeineTbTransactionalCache { +@Component +@ConditionalOnProperty(name = "monitoring.transports.coap.enabled", havingValue = "true") +@ConfigurationProperties(prefix = "monitoring.transports.coap") +public class CoapTransportMonitoringConfig extends TransportMonitoringConfig { - public NotificationRuleCaffeineCache(CacheManager cacheManager) { - super(cacheManager, CacheConstants.NOTIFICATION_RULES_CACHE); + @Override + public TransportType getTransportType() { + return TransportType.COAP; } } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/service/HttpTransportMonitoringConfig.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/service/HttpTransportMonitoringConfig.java new file mode 100644 index 0000000000..3a3e8f612c --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/service/HttpTransportMonitoringConfig.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.config.service; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.thingsboard.monitoring.config.TransportType; + +@Component +@ConditionalOnProperty(name = "monitoring.transports.http.enabled", havingValue = "true") +@ConfigurationProperties(prefix = "monitoring.transports.http") +public class HttpTransportMonitoringConfig extends TransportMonitoringConfig { + + @Override + public TransportType getTransportType() { + return TransportType.HTTP; + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/service/Lwm2mTransportMonitoringConfig.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/service/Lwm2mTransportMonitoringConfig.java new file mode 100644 index 0000000000..4d97de8366 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/service/Lwm2mTransportMonitoringConfig.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.config.service; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.thingsboard.monitoring.config.TransportType; + +@Component +@ConditionalOnProperty(name = "monitoring.transports.lwm2m.enabled", havingValue = "true") +@ConfigurationProperties(prefix = "monitoring.transports.lwm2m") +public class Lwm2mTransportMonitoringConfig extends TransportMonitoringConfig { + + @Override + public TransportType getTransportType() { + return TransportType.LWM2M; + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/service/MqttTransportMonitoringConfig.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/service/MqttTransportMonitoringConfig.java new file mode 100644 index 0000000000..7fef5b95dc --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/service/MqttTransportMonitoringConfig.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.config.service; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.thingsboard.monitoring.config.TransportType; + +@Component +@ConditionalOnProperty(name = "monitoring.transports.mqtt.enabled", havingValue = "true") +@ConfigurationProperties(prefix = "monitoring.transports.mqtt") +@Data +@EqualsAndHashCode(callSuper = true) +public class MqttTransportMonitoringConfig extends TransportMonitoringConfig { + + private Integer qos; + + @Override + public TransportType getTransportType() { + return TransportType.MQTT; + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/service/TransportMonitoringConfig.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/service/TransportMonitoringConfig.java new file mode 100644 index 0000000000..0712d1d919 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/service/TransportMonitoringConfig.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.config.service; + +import lombok.Data; +import org.thingsboard.monitoring.config.MonitoringTargetConfig; +import org.thingsboard.monitoring.config.TransportType; + +import java.util.List; + +@Data +public abstract class TransportMonitoringConfig { + + private int requestTimeoutMs; + + private List targets; + + public abstract TransportType getTransportType(); + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/Latencies.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/Latencies.java new file mode 100644 index 0000000000..8141c34586 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/Latencies.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data; + +import org.thingsboard.monitoring.config.TransportType; + +public class Latencies { + + public static final String WS_UPDATE = "wsUpdate"; + public static final String WS_CONNECT = "wsConnect"; + public static final String LOG_IN = "logIn"; + + public static String transportRequest(TransportType transportType) { + return String.format("%sTransportRequest", transportType.name().toLowerCase()); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/Latency.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/Latency.java new file mode 100644 index 0000000000..c64291e30f --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/Latency.java @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data; + +import com.google.common.util.concurrent.AtomicDouble; +import lombok.RequiredArgsConstructor; + +import java.util.concurrent.atomic.AtomicInteger; + +@RequiredArgsConstructor +public class Latency { + + private final String key; + private final AtomicDouble latencySum = new AtomicDouble(); + private final AtomicInteger counter = new AtomicInteger(); + + public synchronized void report(double latencyInMs) { + latencySum.addAndGet(latencyInMs); + counter.incrementAndGet(); + } + + public synchronized double getAvg() { + return latencySum.get() / counter.get(); + } + + public boolean isNotEmpty() { + return counter.get() > 0; + } + + public synchronized void reset() { + latencySum.set(0.0); + counter.set(0); + } + + public String getKey() { + return key; + } + + public synchronized Latency snapshot() { + Latency snapshot = new Latency(key); + snapshot.latencySum.set(latencySum.get()); + snapshot.counter.set(counter.get()); + return snapshot; + } + + @Override + public String toString() { + return "Latency{" + + "key='" + key + '\'' + + ", avgLatency=" + getAvg() + + '}'; + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java new file mode 100644 index 0000000000..825162952f --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data; + +public class MonitoredServiceKey { + + public static final String GENERAL = "Monitoring"; + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/TransportFailureException.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/TransportFailureException.java new file mode 100644 index 0000000000..8157c4af5f --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/TransportFailureException.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data; + +public class TransportFailureException extends RuntimeException { + + public TransportFailureException(Throwable cause) { + super(cause.getMessage(), cause); + } + + public TransportFailureException(String message) { + super(message); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/TransportInfo.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/TransportInfo.java new file mode 100644 index 0000000000..d7619ea32d --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/TransportInfo.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data; + +import lombok.Data; +import org.thingsboard.monitoring.config.TransportType; + +@Data +public class TransportInfo { + + private final TransportType transportType; + private final String url; + + @Override + public String toString() { + return String.format("%s (%s)", transportType, url); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/cmd/CmdsWrapper.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/cmd/CmdsWrapper.java new file mode 100644 index 0000000000..34227150ea --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/cmd/CmdsWrapper.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data.cmd; + +import lombok.Data; + +import java.util.List; + +@Data +public class CmdsWrapper { + + private List entityDataCmds; + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/cmd/EntityDataCmd.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/cmd/EntityDataCmd.java new file mode 100644 index 0000000000..ccd6fa8f7c --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/cmd/EntityDataCmd.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data.cmd; + +import lombok.Data; +import org.thingsboard.server.common.data.query.EntityDataQuery; + +@Data +public class EntityDataCmd { + + private int cmdId; + private EntityDataQuery query; + private LatestValueCmd latestCmd; + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/cmd/EntityDataUpdate.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/cmd/EntityDataUpdate.java new file mode 100644 index 0000000000..5f3a466217 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/cmd/EntityDataUpdate.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data.cmd; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.TsValue; + +import java.util.List; +import java.util.UUID; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class EntityDataUpdate { + + @JsonIgnoreProperties(ignoreUnknown = true) + private List update; + + public String getLatest(UUID entityId, String key) { + if (update == null) return null; + + return update.stream() + .filter(entityData -> entityData.getEntityId().getId().equals(entityId)).findFirst() + .map(EntityData::getLatest).map(latest -> latest.get(EntityKeyType.TIME_SERIES)) + .map(latest -> latest.get(key)).map(TsValue::getValue) + .orElse(null); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/cmd/LatestValueCmd.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/cmd/LatestValueCmd.java new file mode 100644 index 0000000000..5c2cffcbb6 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/cmd/LatestValueCmd.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data.cmd; + +import lombok.Data; +import org.thingsboard.server.common.data.query.EntityKey; + +import java.util.List; + +@Data +public class LatestValueCmd { + + private List keys; + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java new file mode 100644 index 0000000000..939cb7440f --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data.notification; + +import org.thingsboard.monitoring.data.Latency; + +import java.util.Collection; + +public class HighLatencyNotification implements Notification { + + private final Collection latencies; + private final int thresholdMs; + + public HighLatencyNotification(Collection latencies, int thresholdMs) { + this.latencies = latencies; + this.thresholdMs = thresholdMs; + } + + @Override + public String getText() { + StringBuilder text = new StringBuilder(); + text.append("Some of the latencies are higher than ").append(thresholdMs).append(" ms:\n"); + latencies.forEach(latency -> { + text.append(String.format("[%s] %,.2f ms\n", latency.getKey(), latency.getAvg())); + }); + return text.toString(); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java new file mode 100644 index 0000000000..21e5994d84 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data.notification; + +public interface Notification { + + String getText(); + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java new file mode 100644 index 0000000000..4b78d96a63 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data.notification; + +import lombok.Getter; +import org.apache.commons.lang3.exception.ExceptionUtils; + +@Getter +public class ServiceFailureNotification implements Notification { + + private final Object serviceKey; + private final Throwable error; + private final int failuresCount; + + public ServiceFailureNotification(Object serviceKey, Throwable error, int failuresCount) { + this.serviceKey = serviceKey; + this.error = error; + this.failuresCount = failuresCount; + } + + @Override + public String getText() { + String errorMsg = error.getMessage(); + if (errorMsg == null || errorMsg.equals("null")) { + Throwable cause = ExceptionUtils.getRootCause(error); + if (cause != null) { + errorMsg = cause.getMessage(); + } + } + if (errorMsg == null) { + errorMsg = error.getClass().getSimpleName(); + } + return String.format("[%s] Failure: %s (number of subsequent failures: %s)", serviceKey, errorMsg, failuresCount); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java new file mode 100644 index 0000000000..44eaf093cc --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data.notification; + +public class ServiceRecoveryNotification implements Notification { + + private final Object serviceKey; + + public ServiceRecoveryNotification(Object serviceKey) { + this.serviceKey = serviceKey; + } + + @Override + public String getText() { + return String.format("[%s] is OK", serviceKey); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java new file mode 100644 index 0000000000..f882105048 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.notification; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.monitoring.data.notification.Notification; +import org.thingsboard.monitoring.notification.channels.NotificationChannel; + +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +@Component +@RequiredArgsConstructor +@Slf4j +public class NotificationService { + + private final List notificationChannels; + private final ExecutorService notificationExecutor = Executors.newSingleThreadExecutor(); + + public void sendNotification(Notification notification) { + forEachNotificationChannel(notificationChannel -> notificationChannel.sendNotification(notification)); + } + + private void forEachNotificationChannel(Consumer function) { + notificationChannels.forEach(notificationChannel -> { + notificationExecutor.submit(() -> { + try { + function.accept(notificationChannel); + } catch (Exception e) { + log.error("Failed to send notification to {}", notificationChannel.getClass().getSimpleName(), e); + } + }); + }); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java new file mode 100644 index 0000000000..6c1c23cc28 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.notification.channels; + +import org.thingsboard.monitoring.data.notification.Notification; + +public interface NotificationChannel { + + void sendNotification(Notification notification); + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java new file mode 100644 index 0000000000..092ea0b8c6 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.notification.channels.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.thingsboard.monitoring.data.notification.Notification; +import org.thingsboard.monitoring.notification.channels.NotificationChannel; + +import javax.annotation.PostConstruct; +import java.time.Duration; +import java.util.Map; + +@Component +@ConditionalOnProperty(value = "monitoring.notification_channels.slack.enabled", havingValue = "true") +@Slf4j +public class SlackNotificationChannel implements NotificationChannel { + + @Value("${monitoring.notification_channels.slack.webhook_url}") + private String webhookUrl; + + private RestTemplate restTemplate; + + @PostConstruct + private void init() { + restTemplate = new RestTemplateBuilder() + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(2)) + .build(); + } + + @Override + public void sendNotification(Notification notification) { + sendNotification(notification.getText()); + } + + private void sendNotification(String message) { + restTemplate.postForObject(webhookUrl, Map.of("text", message), String.class); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java new file mode 100644 index 0000000000..4649e31ac4 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java @@ -0,0 +1,144 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.service; + +import com.fasterxml.jackson.databind.node.DoubleNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.monitoring.client.TbClient; +import org.thingsboard.monitoring.data.Latency; +import org.thingsboard.monitoring.data.MonitoredServiceKey; +import org.thingsboard.monitoring.data.notification.HighLatencyNotification; +import org.thingsboard.monitoring.data.notification.ServiceFailureNotification; +import org.thingsboard.monitoring.data.notification.ServiceRecoveryNotification; +import org.thingsboard.monitoring.notification.NotificationService; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.id.AssetId; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MonitoringReporter { + + private final NotificationService notificationService; + + private final Map latencies = new ConcurrentHashMap<>(); + private final Map failuresCounters = new ConcurrentHashMap<>(); + + @Value("${monitoring.failures_threshold}") + private int failuresThreshold; + @Value("${monitoring.send_repeated_failure_notification}") + private boolean sendRepeatedFailureNotification; + + @Value("${monitoring.latency.enabled}") + private boolean latencyReportingEnabled; + @Value("${monitoring.latency.threshold_ms}") + private int latencyThresholdMs; + @Value("${monitoring.latency.reporting_asset_id}") + private String reportingAssetId; + + public void reportLatencies(TbClient tbClient) { + List latencies = this.latencies.values().stream() + .filter(Latency::isNotEmpty) + .map(latency -> { + Latency snapshot = latency.snapshot(); + latency.reset(); + return snapshot; + }) + .collect(Collectors.toList()); + if (latencies.isEmpty()) { + return; + } + log.info("Latencies:\n{}", latencies.stream().map(latency -> latency.getKey() + ": " + latency.getAvg() + " ms") + .collect(Collectors.joining("\n"))); + + if (!latencyReportingEnabled) return; + + if (latencies.stream().anyMatch(latency -> latency.getAvg() >= (double) latencyThresholdMs)) { + HighLatencyNotification highLatencyNotification = new HighLatencyNotification(latencies, latencyThresholdMs); + notificationService.sendNotification(highLatencyNotification); + } + + try { + if (StringUtils.isBlank(reportingAssetId)) { + String assetName = "Monitoring"; + Asset monitoringAsset = tbClient.findAsset(assetName).orElseGet(() -> { + Asset asset = new Asset(); + asset.setType("Monitoring"); + asset.setName(assetName); + asset = tbClient.saveAsset(asset); + log.info("Created monitoring asset {}", asset.getId()); + return asset; + }); + reportingAssetId = monitoringAsset.getId().toString(); + } + + ObjectNode msg = JacksonUtil.newObjectNode(); + latencies.forEach(latency -> { + msg.set(latency.getKey(), new DoubleNode(latency.getAvg())); + }); + tbClient.saveEntityTelemetry(new AssetId(UUID.fromString(reportingAssetId)), "time", msg); + } catch (Exception e) { + log.error("Failed to report latencies: {}", e.getMessage()); + } + } + + public void reportLatency(String key, long latencyInNanos) { + String latencyKey = key + "Latency"; + double latencyInMs = (double) latencyInNanos / 1000_000; + log.trace("Reporting latency [{}]: {} ms", key, latencyInMs); + latencies.computeIfAbsent(latencyKey, k -> new Latency(latencyKey)).report(latencyInMs); + } + + public void serviceFailure(Object serviceKey, Throwable error) { + if (log.isDebugEnabled()) { + log.error("Error occurred", error); + } + int failuresCount = failuresCounters.computeIfAbsent(serviceKey, k -> new AtomicInteger()).incrementAndGet(); + ServiceFailureNotification notification = new ServiceFailureNotification(serviceKey, error, failuresCount); + log.error(notification.getText()); + if (failuresCount == failuresThreshold || (sendRepeatedFailureNotification && failuresCount % failuresThreshold == 0)) { + notificationService.sendNotification(notification); + } + } + + public void serviceIsOk(Object serviceKey) { + ServiceRecoveryNotification notification = new ServiceRecoveryNotification(serviceKey); + if (!serviceKey.equals(MonitoredServiceKey.GENERAL)) { + log.info(notification.getText()); + } + AtomicInteger failuresCounter = failuresCounters.get(serviceKey); + if (failuresCounter != null) { + if (failuresCounter.get() >= failuresThreshold) { + notificationService.sendNotification(notification); + } + failuresCounter.set(0); + } + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/transport/TransportHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/transport/TransportHealthChecker.java new file mode 100644 index 0000000000..0c742e7709 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/transport/TransportHealthChecker.java @@ -0,0 +1,123 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.transport; + +import com.fasterxml.jackson.databind.node.TextNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.monitoring.client.WsClient; +import org.thingsboard.monitoring.config.MonitoringTargetConfig; +import org.thingsboard.monitoring.config.TransportType; +import org.thingsboard.monitoring.config.service.TransportMonitoringConfig; +import org.thingsboard.monitoring.data.Latencies; +import org.thingsboard.monitoring.data.MonitoredServiceKey; +import org.thingsboard.monitoring.data.TransportFailureException; +import org.thingsboard.monitoring.data.TransportInfo; +import org.thingsboard.monitoring.service.MonitoringReporter; +import org.thingsboard.monitoring.util.TbStopWatch; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.UUID; + +@Slf4j +public abstract class TransportHealthChecker { + + protected final C config; + protected final MonitoringTargetConfig target; + private TransportInfo transportInfo; + + @Autowired + private MonitoringReporter reporter; + @Autowired + private TbStopWatch stopWatch; + @Value("${monitoring.check_timeout_ms}") + private int resultCheckTimeoutMs; + + public static final String TEST_TELEMETRY_KEY = "testData"; + + protected TransportHealthChecker(C config, MonitoringTargetConfig target) { + this.config = config; + this.target = target; + } + + @PostConstruct + private void init() { + transportInfo = new TransportInfo(getTransportType(), target.getBaseUrl()); + } + + public final void check(WsClient wsClient) { + log.debug("[{}] Checking", transportInfo); + try { + wsClient.registerWaitForUpdate(); + + String testValue = UUID.randomUUID().toString(); + String testPayload = createTestPayload(testValue); + try { + initClientAndSendPayload(testPayload); + log.trace("[{}] Sent test payload ({})", transportInfo, testPayload); + } catch (Throwable e) { + throw new TransportFailureException(e); + } + + log.trace("[{}] Waiting for WS update", transportInfo); + checkWsUpdate(wsClient, testValue); + + reporter.serviceIsOk(transportInfo); + reporter.serviceIsOk(MonitoredServiceKey.GENERAL); + } catch (TransportFailureException transportFailureException) { + reporter.serviceFailure(transportInfo, transportFailureException); + } catch (Exception e) { + reporter.serviceFailure(MonitoredServiceKey.GENERAL, e); + } + } + + private void initClientAndSendPayload(String payload) throws Throwable { + initClient(); + stopWatch.start(); + sendTestPayload(payload); + reporter.reportLatency(Latencies.transportRequest(getTransportType()), stopWatch.getTime()); + } + + private void checkWsUpdate(WsClient wsClient, String testValue) { + stopWatch.start(); + wsClient.waitForUpdate(resultCheckTimeoutMs); + log.trace("[{}] Waited for WS update. Last WS msg: {}", transportInfo, wsClient.lastMsg); + Object update = wsClient.getTelemetryUpdate(target.getDevice().getId(), TEST_TELEMETRY_KEY); + if (update == null) { + throw new TransportFailureException("No WS update arrived within " + resultCheckTimeoutMs + " ms"); + } else if (!update.toString().equals(testValue)) { + throw new TransportFailureException("Was expecting value " + testValue + " but got " + update); + } + reporter.reportLatency(Latencies.WS_UPDATE, stopWatch.getTime()); + } + + protected String createTestPayload(String testValue) { + return JacksonUtil.newObjectNode().set(TEST_TELEMETRY_KEY, new TextNode(testValue)).toString(); + } + + protected abstract void initClient() throws Exception; + + protected abstract void sendTestPayload(String payload) throws Exception; + + @PreDestroy + protected abstract void destroyClient() throws Exception; + + protected abstract TransportType getTransportType(); + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/transport/TransportMonitoringService.java b/monitoring/src/main/java/org/thingsboard/monitoring/transport/TransportMonitoringService.java new file mode 100644 index 0000000000..d94cbc9624 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/transport/TransportMonitoringService.java @@ -0,0 +1,198 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.transport; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationContext; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.monitoring.client.TbClient; +import org.thingsboard.monitoring.client.WsClient; +import org.thingsboard.monitoring.client.WsClientFactory; +import org.thingsboard.monitoring.config.DeviceConfig; +import org.thingsboard.monitoring.config.MonitoringTargetConfig; +import org.thingsboard.monitoring.config.TransportType; +import org.thingsboard.monitoring.config.service.TransportMonitoringConfig; +import org.thingsboard.monitoring.data.Latencies; +import org.thingsboard.monitoring.data.MonitoredServiceKey; +import org.thingsboard.monitoring.service.MonitoringReporter; +import org.thingsboard.monitoring.util.ResourceUtils; +import org.thingsboard.monitoring.util.TbStopWatch; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MBootstrapClientCredentials; +import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MDeviceCredentials; +import org.thingsboard.server.common.data.device.credentials.lwm2m.NoSecBootstrapClientCredential; +import org.thingsboard.server.common.data.device.credentials.lwm2m.NoSecClientCredential; +import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration; +import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; +import org.thingsboard.server.common.data.device.data.DeviceData; +import org.thingsboard.server.common.data.device.data.Lwm2mDeviceTransportConfiguration; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; + +import javax.annotation.PostConstruct; +import java.util.LinkedList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +@Slf4j +public final class TransportMonitoringService { + + private final List configs; + private final List> transportHealthCheckers = new LinkedList<>(); + private final List devices = new LinkedList<>(); + + private final TbClient tbClient; + private final WsClientFactory wsClientFactory; + private final TbStopWatch stopWatch; + private final MonitoringReporter reporter; + private final ApplicationContext applicationContext; + private ScheduledExecutorService scheduler; + @Value("${monitoring.transports.monitoring_rate_ms}") + private int monitoringRateMs; + + @PostConstruct + private void init() { + configs.forEach(config -> { + config.getTargets().stream() + .filter(target -> StringUtils.isNotBlank(target.getBaseUrl())) + .peek(target -> checkMonitoringTarget(config, target, tbClient)) + .forEach(target -> { + TransportHealthChecker transportHealthChecker = applicationContext.getBean(config.getTransportType().getServiceClass(), config, target); + transportHealthCheckers.add(transportHealthChecker); + devices.add(target.getDevice().getId()); + }); + }); + scheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("monitoring-executor")); + } + + @EventListener(ApplicationReadyEvent.class) + public void startMonitoring() { + scheduler.scheduleWithFixedDelay(() -> { + try { + log.debug("Starting transports check"); + stopWatch.start(); + String accessToken = tbClient.logIn(); + reporter.reportLatency(Latencies.LOG_IN, stopWatch.getTime()); + + try (WsClient wsClient = wsClientFactory.createClient(accessToken)) { + wsClient.subscribeForTelemetry(devices, TransportHealthChecker.TEST_TELEMETRY_KEY).waitForReply(); + + for (TransportHealthChecker transportHealthChecker : transportHealthCheckers) { + transportHealthChecker.check(wsClient); + } + } + reporter.reportLatencies(tbClient); + log.debug("Finished transports check"); + } catch (Throwable error) { + try { + reporter.serviceFailure(MonitoredServiceKey.GENERAL, error); + } catch (Throwable reportError) { + log.error("Error occurred during service failure reporting", reportError); + } + } + }, 0, monitoringRateMs, TimeUnit.MILLISECONDS); + } + + private void checkMonitoringTarget(TransportMonitoringConfig config, MonitoringTargetConfig target, TbClient tbClient) { + DeviceConfig deviceConfig = target.getDevice(); + tbClient.logIn(); + + DeviceId deviceId; + if (deviceConfig == null || deviceConfig.getId() == null) { + String deviceName = String.format("[%s] Monitoring device (%s)", config.getTransportType(), target.getBaseUrl()); + Device device = tbClient.getTenantDevice(deviceName) + .orElseGet(() -> { + log.info("Creating new device '{}'", deviceName); + return createDevice(config.getTransportType(), deviceName, tbClient); + }); + deviceId = device.getId(); + target.getDevice().setId(deviceId.toString()); + } else { + deviceId = new DeviceId(deviceConfig.getId()); + } + + log.info("Using device {} for {} monitoring", deviceId, config.getTransportType()); + DeviceCredentials credentials = tbClient.getDeviceCredentialsByDeviceId(deviceId) + .orElseThrow(() -> new IllegalArgumentException("No credentials found for device " + deviceId)); + target.getDevice().setCredentials(credentials); + } + + private Device createDevice(TransportType transportType, String name, TbClient tbClient) { + Device device = new Device(); + device.setName(name); + + DeviceCredentials credentials = new DeviceCredentials(); + credentials.setCredentialsId(RandomStringUtils.randomAlphabetic(20)); + + DeviceData deviceData = new DeviceData(); + deviceData.setConfiguration(new DefaultDeviceConfiguration()); + if (transportType != TransportType.LWM2M) { + device.setType("default"); + deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); + credentials.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + } else { + tbClient.getResources(new PageLink(1, 0, "lwm2m monitoring")).getData() + .stream().findFirst() + .orElseGet(() -> { + TbResource newResource = ResourceUtils.getResource("lwm2m/resource.json", TbResource.class); + log.info("Creating LwM2M resource"); + return tbClient.saveResource(newResource); + }); + String profileName = "LwM2M Monitoring"; + DeviceProfile profile = tbClient.getDeviceProfiles(new PageLink(1, 0, profileName)).getData() + .stream().findFirst() + .orElseGet(() -> { + DeviceProfile newProfile = ResourceUtils.getResource("lwm2m/device_profile.json", DeviceProfile.class); + newProfile.setName(profileName); + log.info("Creating LwM2M device profile"); + return tbClient.saveDeviceProfile(newProfile); + }); + device.setType(profileName); + device.setDeviceProfileId(profile.getId()); + deviceData.setTransportConfiguration(new Lwm2mDeviceTransportConfiguration()); + + credentials.setCredentialsType(DeviceCredentialsType.LWM2M_CREDENTIALS); + LwM2MDeviceCredentials lwm2mCreds = new LwM2MDeviceCredentials(); + NoSecClientCredential client = new NoSecClientCredential(); + client.setEndpoint(credentials.getCredentialsId()); + lwm2mCreds.setClient(client); + LwM2MBootstrapClientCredentials bootstrap = new LwM2MBootstrapClientCredentials(); + bootstrap.setBootstrapServer(new NoSecBootstrapClientCredential()); + bootstrap.setLwm2mServer(new NoSecBootstrapClientCredential()); + lwm2mCreds.setBootstrap(bootstrap); + credentials.setCredentialsValue(JacksonUtil.toString(lwm2mCreds)); + } + return tbClient.saveDeviceWithCredentials(device, credentials).get(); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/transport/impl/CoapTransportHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/transport/impl/CoapTransportHealthChecker.java new file mode 100644 index 0000000000..3e36e62c58 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/transport/impl/CoapTransportHealthChecker.java @@ -0,0 +1,78 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.transport.impl; + +import lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.CoapClient; +import org.eclipse.californium.core.CoapResponse; +import org.eclipse.californium.core.coap.CoAP; +import org.eclipse.californium.core.coap.MediaTypeRegistry; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; +import org.thingsboard.monitoring.config.MonitoringTargetConfig; +import org.thingsboard.monitoring.config.TransportType; +import org.thingsboard.monitoring.config.service.CoapTransportMonitoringConfig; +import org.thingsboard.monitoring.transport.TransportHealthChecker; + +import java.io.IOException; + +@Component +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class CoapTransportHealthChecker extends TransportHealthChecker { + + private CoapClient coapClient; + + protected CoapTransportHealthChecker(CoapTransportMonitoringConfig config, MonitoringTargetConfig target) { + super(config, target); + } + + @Override + protected void initClient() throws Exception { + if (coapClient == null) { + String accessToken = target.getDevice().getCredentials().getCredentialsId(); + String uri = target.getBaseUrl() + "/api/v1/" + accessToken + "/telemetry"; + coapClient = new CoapClient(uri); + coapClient.setTimeout((long) config.getRequestTimeoutMs()); + log.debug("Initialized CoAP client for URI {}", uri); + } + } + + @Override + protected void sendTestPayload(String payload) throws Exception { + CoapResponse response = coapClient.post(payload, MediaTypeRegistry.APPLICATION_JSON); + CoAP.ResponseCode code = response.getCode(); + if (code.codeClass != CoAP.CodeClass.SUCCESS_RESPONSE.value) { + throw new IOException("COAP client didn't receive success response from transport"); + } + } + + @Override + protected void destroyClient() throws Exception { + if (coapClient != null) { + coapClient.shutdown(); + coapClient = null; + log.info("Disconnected CoAP client"); + } + } + + @Override + protected TransportType getTransportType() { + return TransportType.COAP; + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/transport/impl/HttpTransportHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/transport/impl/HttpTransportHealthChecker.java new file mode 100644 index 0000000000..834025b9e5 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/transport/impl/HttpTransportHealthChecker.java @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.transport.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.thingsboard.monitoring.config.MonitoringTargetConfig; +import org.thingsboard.monitoring.config.TransportType; +import org.thingsboard.monitoring.config.service.HttpTransportMonitoringConfig; +import org.thingsboard.monitoring.transport.TransportHealthChecker; + +import java.time.Duration; + +@Component +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class HttpTransportHealthChecker extends TransportHealthChecker { + + private RestTemplate restTemplate; + + protected HttpTransportHealthChecker(HttpTransportMonitoringConfig config, MonitoringTargetConfig target) { + super(config, target); + } + + @Override + protected void initClient() throws Exception { + if (restTemplate == null) { + restTemplate = new RestTemplateBuilder() + .setConnectTimeout(Duration.ofMillis(config.getRequestTimeoutMs())) + .setReadTimeout(Duration.ofMillis(config.getRequestTimeoutMs())) + .build(); + log.debug("Initialized HTTP client"); + } + } + + @Override + protected void sendTestPayload(String payload) throws Exception { + String accessToken = target.getDevice().getCredentials().getCredentialsId(); + restTemplate.postForObject(target.getBaseUrl() + "/api/v1/" + accessToken + "/telemetry", payload, String.class); + } + + @Override + protected void destroyClient() throws Exception {} + + @Override + protected TransportType getTransportType() { + return TransportType.HTTP; + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/transport/impl/Lwm2mTransportHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/transport/impl/Lwm2mTransportHealthChecker.java new file mode 100644 index 0000000000..6487003e0c --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/transport/impl/Lwm2mTransportHealthChecker.java @@ -0,0 +1,72 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.transport.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; +import org.thingsboard.monitoring.client.Lwm2mClient; +import org.thingsboard.monitoring.config.MonitoringTargetConfig; +import org.thingsboard.monitoring.config.TransportType; +import org.thingsboard.monitoring.config.service.Lwm2mTransportMonitoringConfig; +import org.thingsboard.monitoring.transport.TransportHealthChecker; + +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class Lwm2mTransportHealthChecker extends TransportHealthChecker { + + private Lwm2mClient lwm2mClient; + + protected Lwm2mTransportHealthChecker(Lwm2mTransportMonitoringConfig config, MonitoringTargetConfig target) { + super(config, target); + } + + @Override + protected void initClient() throws Exception { + if (lwm2mClient == null || lwm2mClient.getLeshanClient() == null || lwm2mClient.isDestroyed()) { + String endpoint = target.getDevice().getCredentials().getCredentialsId(); + lwm2mClient = new Lwm2mClient(target.getBaseUrl(), endpoint); + lwm2mClient.initClient(); + log.debug("Initialized LwM2M client for endpoint '{}'", endpoint); + } + } + + @Override + protected void sendTestPayload(String payload) throws Exception { + lwm2mClient.send(payload, 0); + } + + @Override + protected String createTestPayload(String testValue) { + return testValue; + } + + @Override + protected void destroyClient() throws Exception { + if (lwm2mClient != null) { + lwm2mClient.destroy(); + lwm2mClient = null; + } + } + + @Override + protected TransportType getTransportType() { + return TransportType.LWM2M; + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/transport/impl/MqttTransportHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/transport/impl/MqttTransportHealthChecker.java new file mode 100644 index 0000000000..850cfe136b --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/transport/impl/MqttTransportHealthChecker.java @@ -0,0 +1,87 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.transport.impl; + +import lombok.extern.slf4j.Slf4j; +import org.eclipse.paho.client.mqttv3.IMqttToken; +import org.eclipse.paho.client.mqttv3.MqttAsyncClient; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; +import org.thingsboard.monitoring.config.MonitoringTargetConfig; +import org.thingsboard.monitoring.config.TransportType; +import org.thingsboard.monitoring.config.service.MqttTransportMonitoringConfig; +import org.thingsboard.monitoring.transport.TransportHealthChecker; + +@Component +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class MqttTransportHealthChecker extends TransportHealthChecker { + + private MqttClient mqttClient; + + private static final String DEVICE_TELEMETRY_TOPIC = "v1/devices/me/telemetry"; + + protected MqttTransportHealthChecker(MqttTransportMonitoringConfig config, MonitoringTargetConfig target) { + super(config, target); + } + + @Override + protected void initClient() throws Exception { + if (mqttClient == null || !mqttClient.isConnected()) { + String clientId = MqttAsyncClient.generateClientId(); + String accessToken = target.getDevice().getCredentials().getCredentialsId(); + mqttClient = new MqttClient(target.getBaseUrl(), clientId, new MemoryPersistence()); + mqttClient.setTimeToWait(config.getRequestTimeoutMs()); + + MqttConnectOptions options = new MqttConnectOptions(); + options.setUserName(accessToken); + options.setConnectionTimeout(config.getRequestTimeoutMs() / 1000); + IMqttToken result = mqttClient.connectWithResult(options); + if (result.getException() != null) { + throw result.getException(); + } + log.debug("Initialized MQTT client for URI {}", mqttClient.getServerURI()); + } + } + + @Override + protected void sendTestPayload(String payload) throws Exception { + MqttMessage message = new MqttMessage(); + message.setPayload(payload.getBytes()); + message.setQos(config.getQos()); + mqttClient.publish(DEVICE_TELEMETRY_TOPIC, message); + } + + @Override + protected void destroyClient() throws Exception { + if (mqttClient != null) { + mqttClient.disconnect(); + mqttClient = null; + log.info("Disconnected MQTT client"); + } + } + + @Override + protected TransportType getTransportType() { + return TransportType.MQTT; + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/util/ResourceUtils.java b/monitoring/src/main/java/org/thingsboard/monitoring/util/ResourceUtils.java new file mode 100644 index 0000000000..4cc1efe8cb --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/util/ResourceUtils.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.util; + +import lombok.SneakyThrows; +import org.thingsboard.common.util.JacksonUtil; + +import java.io.InputStream; + +public class ResourceUtils { + + @SneakyThrows + public static T getResource(String path, Class type) { + InputStream resource = ResourceUtils.class.getClassLoader().getResourceAsStream(path); + if (resource == null) { + throw new IllegalArgumentException("Resource not found for path " + path); + } + return JacksonUtil.OBJECT_MAPPER.readValue(resource, type); + } + + public static InputStream getResourceAsStream(String path) { + InputStream resource = ResourceUtils.class.getClassLoader().getResourceAsStream(path); + if (resource == null) { + throw new IllegalArgumentException("Resource not found for path " + path); + } + return resource; + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/util/TbStopWatch.java b/monitoring/src/main/java/org/thingsboard/monitoring/util/TbStopWatch.java new file mode 100644 index 0000000000..49e387999a --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/util/TbStopWatch.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.util; + +import org.apache.commons.lang3.time.StopWatch; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +@Component +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class TbStopWatch { + + private final StopWatch internal = new StopWatch(); + + public void start() { + internal.reset(); + internal.start(); + } + + public long getTime() { + internal.stop(); + long nanoTime = internal.getNanoTime(); + internal.reset(); + return nanoTime; + } + +} diff --git a/monitoring/src/main/resources/logback.xml b/monitoring/src/main/resources/logback.xml new file mode 100644 index 0000000000..a7ca84126f --- /dev/null +++ b/monitoring/src/main/resources/logback.xml @@ -0,0 +1,38 @@ + + + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + diff --git a/monitoring/src/main/resources/lwm2m/device_profile.json b/monitoring/src/main/resources/lwm2m/device_profile.json new file mode 100644 index 0000000000..7f93a7e6b6 --- /dev/null +++ b/monitoring/src/main/resources/lwm2m/device_profile.json @@ -0,0 +1,59 @@ +{ + "name": "LwM2M Monitoring", + "type": "DEFAULT", + "image": null, + "defaultQueueName": null, + "transportType": "LWM2M", + "provisionType": "DISABLED", + "description": "", + "profileData": { + "configuration": { + "type": "DEFAULT" + }, + "transportConfiguration": { + "observeAttr": { + "observe": [ + "/3_1.0/0/0" + ], + "attribute": [], + "telemetry": [ + "/3_1.0/0/0" + ], + "keyName": { + "/3_1.0/0/0": "testData" + }, + "attributeLwm2m": {} + }, + "bootstrap": [ + { + "shortServerId": 123, + "bootstrapServerIs": false, + "host": "0.0.0.0", + "port": 5685, + "clientHoldOffTime": 1, + "serverPublicKey": "", + "serverCertificate": "", + "bootstrapServerAccountTimeout": 0, + "lifetime": 300, + "defaultMinPeriod": 1, + "notifIfDisabled": true, + "binding": "U", + "securityMode": "NO_SEC" + } + ], + "clientLwM2mSettings": { + "clientOnlyObserveAfterConnect": 1, + "fwUpdateStrategy": 1, + "swUpdateStrategy": 1, + "powerMode": "DRX", + "compositeOperationsSupport": false + }, + "bootstrapServerUpdateEnable": false, + "type": "LWM2M" + }, + "alarms": null, + "provisionConfiguration": { + "type": "DISABLED" + } + } +} \ No newline at end of file diff --git a/monitoring/src/main/resources/lwm2m/models/0.xml b/monitoring/src/main/resources/lwm2m/models/0.xml new file mode 100644 index 0000000000..d122984c16 --- /dev/null +++ b/monitoring/src/main/resources/lwm2m/models/0.xml @@ -0,0 +1,364 @@ + + + + + LWM2M Security + + 0 + urn:oma:lwm2m:oma:0:1.2 + 1.1 + 1.2 + Multiple + Mandatory + + + LWM2M Server URI + + Single + Mandatory + String + 0..255 + + + + + Bootstrap-Server + + Single + Mandatory + Boolean + + + + + + Security Mode + + Single + Mandatory + Integer + 0..4 + + + + + Public Key or Identity + + Single + Mandatory + Opaque + + + + + + Server Public Key + + Single + Mandatory + Opaque + + + + + + Secret Key + + Single + Mandatory + Opaque + + + + + + SMS Security Mode + + Single + Optional + Integer + 0..255 + + + + + SMS Binding Key Parameters + + Single + Optional + Opaque + 6 + + + + + SMS Binding Secret Key(s) + + Single + Optional + Opaque + 16,32,48 + + + + + LwM2M Server SMS Number + + Single + Optional + String + + + + + + Short Server ID + + Single + Optional + Integer + 1..65534 + + + + + Client Hold Off Time + + Single + Optional + Integer + + s + + + + Bootstrap-Server Account Timeout + + Single + Optional + Integer + + s + + + + Matching Type + + Single + Optional + Integer + 0..3 + + + + + SNI + + Single + Optional + String + + + + + + Certificate Usage + + Single + Optional + Integer + 0..3 + + + + + DTLS/TLS Ciphersuite + + Multiple + Optional + Integer + + + + + OSCORE Security Mode + + Single + Optional + Objlnk + + + + + + Groups To Use by Client + + Multiple + Optional + Integer + 0..65535 + + + + + Signature Algorithms Supported by Server + + Multiple + Optional + Integer + 0..65535 + + + + Signature Algorithms To Use by Client + + Multiple + Optional + Integer + 0..65535 + + + + + Signature Algorithm Certs Supported by Server + + Multiple + Optional + Integer + 0..65535 + + + + + TLS 1.3 Features To Use by Client + + Single + Optional + Integer + 0..65535 + + + + + TLS Extensions Supported by Server + + Single + Optional + Integer + 0..65535 + + + + + TLS Extensions To Use by Client + + Single + Optional + Integer + 0..65535 + + + + + Secondary LwM2M Server URI + + Multiple + Optional + String + 0..255 + + + + MQTT Server + + Single + Optional + Objlnk + + + + + LwM2M COSE Security + + Multiple + Optional + Objlnk + + + + + RDS Destination Port + + Single + Optional + Integer + 0..15 + + + + RDS Source Port + + Single + Optional + Integer + 0..15 + + + + RDS Application ID + + Single + Optional + String + + + + + + + + diff --git a/monitoring/src/main/resources/lwm2m/models/1.xml b/monitoring/src/main/resources/lwm2m/models/1.xml new file mode 100644 index 0000000000..a81caa27b0 --- /dev/null +++ b/monitoring/src/main/resources/lwm2m/models/1.xml @@ -0,0 +1,319 @@ + + + + + LwM2M Server + + 1 + urn:oma:lwm2m:oma:1:1.2 + 1.2 + 1.2 + Multiple + Mandatory + + + Short Server ID + R + Single + Mandatory + Integer + 1..65534 + + + + + Lifetime + RW + Single + Mandatory + Integer + + s + + + + Default Minimum Period + RW + Single + Optional + Integer + + s + + + + Default Maximum Period + RW + Single + Optional + Integer + + s + + + + Disable + E + Single + Optional + + + + + + + Disable Timeout + RW + Single + Optional + Integer + + s + + + + Notification Storing When Disabled or Offline + RW + Single + Mandatory + Boolean + + + + + + Binding + RW + Single + Mandatory + String + + + + + + Registration Update Trigger + E + Single + Mandatory + + + + + + + Bootstrap-Request Trigger + E + Single + Optional + + + + + + + APN Link + RW + Single + Optional + Objlnk + + + + + + TLS-DTLS Alert Code + R + Single + Optional + Integer + 0..255 + + + + + Last Bootstrapped + R + Single + Optional + Time + + + + + + Registration Priority Order + R + Single + Optional + Integer + + + + + + Initial Registration Delay Timer + RW + Single + Optional + Integer + + s + + + + Registration Failure Block + R + Single + Optional + Boolean + + + + + + Bootstrap on Registration Failure + R + Single + Optional + Boolean + + + + + + Communication Retry Count + RW + Single + Optional + Integer + + + + + + Communication Retry Timer + RW + Single + Optional + Integer + + s + + + + Communication Sequence Delay Timer + RW + Single + Optional + Integer + + s + + + + Communication Sequence Retry Count + RW + Single + Optional + Integer + + + + + + Trigger + RW + Single + Optional + Boolean + + + + + + Preferred Transport + RW + Single + Optional + String + The possible values are those listed in the LwM2M Core Specification + + + + Mute Send + RW + Single + Optional + Boolean + + + + + + Alternate APN Links + RW + Multiple + Optional + Objlnk + + + + + + Supported Server Versions + RW + Multiple + Optional + String + + + + + + Default Notification Mode + RW + Single + Optional + Integer + 0..1 + + + + + Profile ID Hash Algorithm + RW + Single + Optional + Integer + 0..255 + + + + + + + diff --git a/monitoring/src/main/resources/lwm2m/models/2.xml b/monitoring/src/main/resources/lwm2m/models/2.xml new file mode 100644 index 0000000000..79b2ed1321 --- /dev/null +++ b/monitoring/src/main/resources/lwm2m/models/2.xml @@ -0,0 +1,83 @@ + + + + + LwM2M Access Control + + 2 + urn:oma:lwm2m:oma:2:1.1 + 1.0 + 1.1 + Multiple + Optional + + + Object ID + R + Single + Mandatory + Integer + 1..65534 + + + + + Object Instance ID + R + Single + Mandatory + Integer + 0..65535 + + + + + ACL + RW + Multiple + Optional + Integer + 0..31 + + + + + Access Control Owner + RW + Single + Mandatory + Integer + 0..65535 + + + + + + + diff --git a/monitoring/src/main/resources/lwm2m/models/test-model.xml b/monitoring/src/main/resources/lwm2m/models/test-model.xml new file mode 100644 index 0000000000..c8e49ce32c --- /dev/null +++ b/monitoring/src/main/resources/lwm2m/models/test-model.xml @@ -0,0 +1,45 @@ + + + + + LwM2M Monitoring + + + 3 + urn:oma:lwm2m:oma:3:1.0 + 1.1 + 1.0 + Single + Mandatory + + + Test data + R + Single + Optional + String + + + + + + + + diff --git a/monitoring/src/main/resources/lwm2m/resource.json b/monitoring/src/main/resources/lwm2m/resource.json new file mode 100644 index 0000000000..b624fa400e --- /dev/null +++ b/monitoring/src/main/resources/lwm2m/resource.json @@ -0,0 +1,6 @@ +{ + "title": "", + "resourceType": "LWM2M_MODEL", + "fileName": "test-model.xml", + "data": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCEtLQoKICAgIENvcHlyaWdodCDCqSAyMDE2LTIwMjIgVGhlIFRoaW5nc2JvYXJkIEF1dGhvcnMKCiAgICBMaWNlbnNlZCB1bmRlciB0aGUgQXBhY2hlIExpY2Vuc2UsIFZlcnNpb24gMi4wICh0aGUgIkxpY2Vuc2UiKTsKICAgIHlvdSBtYXkgbm90IHVzZSB0aGlzIGZpbGUgZXhjZXB0IGluIGNvbXBsaWFuY2Ugd2l0aCB0aGUgTGljZW5zZS4KICAgIFlvdSBtYXkgb2J0YWluIGEgY29weSBvZiB0aGUgTGljZW5zZSBhdAoKICAgICAgICBodHRwOi8vd3d3LmFwYWNoZS5vcmcvbGljZW5zZXMvTElDRU5TRS0yLjAKCiAgICBVbmxlc3MgcmVxdWlyZWQgYnkgYXBwbGljYWJsZSBsYXcgb3IgYWdyZWVkIHRvIGluIHdyaXRpbmcsIHNvZnR3YXJlCiAgICBkaXN0cmlidXRlZCB1bmRlciB0aGUgTGljZW5zZSBpcyBkaXN0cmlidXRlZCBvbiBhbiAiQVMgSVMiIEJBU0lTLAogICAgV0lUSE9VVCBXQVJSQU5USUVTIE9SIENPTkRJVElPTlMgT0YgQU5ZIEtJTkQsIGVpdGhlciBleHByZXNzIG9yIGltcGxpZWQuCiAgICBTZWUgdGhlIExpY2Vuc2UgZm9yIHRoZSBzcGVjaWZpYyBsYW5ndWFnZSBnb3Zlcm5pbmcgcGVybWlzc2lvbnMgYW5kCiAgICBsaW1pdGF0aW9ucyB1bmRlciB0aGUgTGljZW5zZS4KCi0tPgo8TFdNMk0geG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIKICAgICAgIHhzaTpub05hbWVzcGFjZVNjaGVtYUxvY2F0aW9uPSJodHRwOi8vd3d3Lm9wZW5tb2JpbGVhbGxpYW5jZS5vcmcvdGVjaC9wcm9maWxlcy9MV00yTS12MV8xLnhzZCI+CiAgICA8T2JqZWN0IE9iamVjdFR5cGU9Ik1PRGVmaW5pdGlvbiI+CiAgICAgICAgPE5hbWU+THdNMk0gTW9uaXRvcmluZzwvTmFtZT4KICAgICAgICA8RGVzY3JpcHRpb24xPgogICAgICAgICAgICA8IVtDREFUQVtdXT48L0Rlc2NyaXB0aW9uMT4KICAgICAgICA8T2JqZWN0SUQ+MzwvT2JqZWN0SUQ+CiAgICAgICAgPE9iamVjdFVSTj51cm46b21hOmx3bTJtOm9tYTozOjEuMDwvT2JqZWN0VVJOPgogICAgICAgIDxMV00yTVZlcnNpb24+MS4xPC9MV00yTVZlcnNpb24+CiAgICAgICAgPE9iamVjdFZlcnNpb24+MS4wPC9PYmplY3RWZXJzaW9uPgogICAgICAgIDxNdWx0aXBsZUluc3RhbmNlcz5TaW5nbGU8L011bHRpcGxlSW5zdGFuY2VzPgogICAgICAgIDxNYW5kYXRvcnk+TWFuZGF0b3J5PC9NYW5kYXRvcnk+CiAgICAgICAgPFJlc291cmNlcz4KICAgICAgICAgICAgPEl0ZW0gSUQ9IjAiPgogICAgICAgICAgICAgICAgPE5hbWU+VGVzdCBkYXRhPC9OYW1lPgogICAgICAgICAgICAgICAgPE9wZXJhdGlvbnM+UjwvT3BlcmF0aW9ucz4KICAgICAgICAgICAgICAgIDxNdWx0aXBsZUluc3RhbmNlcz5TaW5nbGU8L011bHRpcGxlSW5zdGFuY2VzPgogICAgICAgICAgICAgICAgPE1hbmRhdG9yeT5PcHRpb25hbDwvTWFuZGF0b3J5PgogICAgICAgICAgICAgICAgPFR5cGU+U3RyaW5nPC9UeXBlPgogICAgICAgICAgICAgICAgPFJhbmdlRW51bWVyYXRpb24+PC9SYW5nZUVudW1lcmF0aW9uPgogICAgICAgICAgICAgICAgPFVuaXRzPjwvVW5pdHM+CiAgICAgICAgICAgICAgICA8RGVzY3JpcHRpb24+PCFbQ0RBVEFbVGVzdCBkYXRhXV0+PC9EZXNjcmlwdGlvbj4KICAgICAgICAgICAgPC9JdGVtPgogICAgICAgIDwvUmVzb3VyY2VzPgogICAgICAgIDxEZXNjcmlwdGlvbjI+PC9EZXNjcmlwdGlvbjI+CiAgICA8L09iamVjdD4KPC9MV00yTT4K" +} \ No newline at end of file diff --git a/monitoring/src/main/resources/tb-monitoring.yml b/monitoring/src/main/resources/tb-monitoring.yml new file mode 100644 index 0000000000..d89886cd72 --- /dev/null +++ b/monitoring/src/main/resources/tb-monitoring.yml @@ -0,0 +1,122 @@ +# +# Copyright © 2016-2023 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +monitoring: + # Monitored server domain + domain: "${DOMAIN:localhost}" + rest: + # Base REST API url, https://DOMAIN by default + base_url: '${REST_BASE_URL:https://${monitoring.domain}}' + # Authentication username + username: '${REST_AUTH_USERNAME:tenant@thingsboard.org}' + # Authentication password + password: '${REST_AUTH_PASSWORD:tenant}' + # REST request timeout in milliseconds + request_timeout_ms: '${REST_REQUEST_TIMEOUT_MS:5000}' + ws: + # WebSocket url, wss://DOMAIN by default + base_url: '${WS_BASE_URL:wss://${monitoring.domain}}' + # WebSocket request timeout + request_timeout_ms: '${WS_REQUEST_TIMEOUT_MS:3000}' + + # Maximum time between request to transport and WebSocket update + check_timeout_ms: '${CHECK_TIMEOUT_MS:5000}' + + # Failures threshold for notifying + failures_threshold: '${FAILURES_THRESHOLD:2}' + # Whether to notify about next failures after first notification (will notify after each FAILURES_THRESHOLD failures) + send_repeated_failure_notification: '${SEND_REPEATED_FAILURE_NOTIFICATION:true}' + + transports: + # Transports check frequency in milliseconds + monitoring_rate_ms: '${TRANSPORTS_MONITORING_RATE_MS:10000}' + + mqtt: + # Enable MQTT checks + enabled: '${MQTT_TRANSPORT_MONITORING_ENABLED:true}' + # MQTT request timeout in milliseconds + request_timeout_ms: '${MQTT_REQUEST_TIMEOUT_MS:4000}' + # MQTT QoS + qos: '${MQTT_QOS_LEVEL:1}' + targets: + # MQTT base url, tcp://DOMAIN:1883 by default + - base_url: '${MQTT_TRANSPORT_BASE_URL:tcp://${monitoring.domain}:1883}' + device: + # MQTT device to push telemetry for. If not set - device will be found or created automatically + id: '${MQTT_TRANSPORT_TARGET_DEVICE_ID:}' + # To add more targets, use following environment variables: + # monitoring.transports.mqtt.targets[1].base_url, monitoring.transports.mqtt.targets[1].device.id, + # monitoring.transports.mqtt.targets[2].base_url, monitoring.transports.mqtt.targets[2].device.id, etc. + + coap: + # Enable CoAP checks + enabled: '${COAP_TRANSPORT_MONITORING_ENABLED:true}' + # CoAP request timeout in milliseconds + request_timeout_ms: '${COAP_REQUEST_TIMEOUT_MS:4000}' + targets: + # CoAP base url, coap://DOMAIN by default + - base_url: '${COAP_TRANSPORT_BASE_URL:coap://${monitoring.domain}}' + # CoAP device to push telemetry for. If not set - device will be found or created automatically + device: + id: '${COAP_TRANSPORT_TARGET_DEVICE_ID:}' + # To add more targets, use following environment variables: + # monitoring.transports.coap.targets[1].base_url, monitoring.transports.coap.targets[1].device.id, + # monitoring.transports.coap.targets[2].base_url, monitoring.transports.coap.targets[2].device.id, etc. + + http: + # Enable HTTP checks + enabled: '${HTTP_TRANSPORT_MONITORING_ENABLED:true}' + # HTTP request timeout in milliseconds + request_timeout_ms: '${HTTP_REQUEST_TIMEOUT_MS:4000}' + targets: + # HTTP base url, https://DOMAIN by default + - base_url: '${HTTP_TRANSPORT_BASE_URL:https://${monitoring.domain}}' + device: + # HTTP device to push telemetry for. If not set - device will be found or created automatically + id: '${HTTP_TRANSPORT_TARGET_DEVICE_ID:}' + # To add more targets, use following environment variables: + # monitoring.transports.http.targets[1].base_url, monitoring.transports.http.targets[1].device.id, + # monitoring.transports.http.targets[2].base_url, monitoring.transports.http.targets[2].device.id, etc. + + lwm2m: + # Enable LwM2M checks + enabled: '${LWM2M_TRANSPORT_MONITORING_ENABLED:true}' + # LwM2M request timeout in milliseconds + request_timeout_ms: '${LWM2M_REQUEST_TIMEOUT_MS:4000}' + targets: + # LwM2M base url, coap://DOMAIN:5685 by default + - base_url: '${LWM2M_TRANSPORT_BASE_URL:coap://${monitoring.domain}:5685}' + # LwM2M device to push telemetry for. If not set - device will be found or created automatically + device: + id: '${LWM2M_TRANSPORT_TARGET_DEVICE_ID:}' + # To add more targets, use following environment variables: + # monitoring.transports.lwm2m.targets[1].base_url, monitoring.transports.lwm2m.targets[1].device.id, + # monitoring.transports.lwm2m.targets[2].base_url, monitoring.transports.lwm2m.targets[2].device.id, etc. + + notification_channels: + slack: + # Enable notifying via Slack + enabled: '${SLACK_NOTIFICATION_CHANNEL_ENABLED:false}' + # Slack webhook url + webhook_url: '${SLACK_WEBHOOK_URL:}' + + latency: + # Enable latencies reporting + enabled: "${LATENCY_REPORTING_ENABLED:true}" + # Latency threshold for notifying + threshold_ms: '${LATENCY_THRESHOLD:2000}' + # ID of the asset to save latencies to. If not set and latencies reporting is enabled - asset will be found or created automatically + reporting_asset_id: '${LATENCY_REPORTING_ASSET_ID:}' diff --git a/msa/black-box-tests/README.md b/msa/black-box-tests/README.md index 5b63ba712a..4a88d5e8cd 100644 --- a/msa/black-box-tests/README.md +++ b/msa/black-box-tests/README.md @@ -54,6 +54,7 @@ As result, in REPOSITORY column, next images should be present: mvn clean install -DblackBoxTests.skip=false -Dsuite=all - - - +### To run a separate test manually on a built UI: +1. Add the black-box-tests module in the [pom.xml](../pom.xml) or add as a Maven project +2. Add Vm Option "*-DrunLocal=true -Dtb.baseUiUrl=http://localhost:4200/*" in "Run" -> "Edit Configuration" -> "Edit Configuration Templates" -> "TestNG" +3. To run a specific test, go to the test class in the [UI tests package](../black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests) and run the test. Alternatively, go to the [resources](../black-box-tests/src/test/resources) in the black-box-tests module and run the test suite that you need. \ No newline at end of file diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java index be9a5c422a..c9fed6a670 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java @@ -18,10 +18,6 @@ package org.thingsboard.server.msa; import lombok.extern.slf4j.Slf4j; import org.testcontainers.DockerClientFactory; -import java.io.IOException; -import java.io.InputStream; -import java.util.Properties; - @Slf4j public class TestProperties { @@ -31,41 +27,26 @@ public class TestProperties { private static final ContainerTestSuite instance = ContainerTestSuite.getInstance(); - private static Properties properties; - public static String getBaseUrl() { if (instance.isActive()) { return HTTPS_URL; } - return getProperties().getProperty("tb.baseUrl"); + return System.getProperty("tb.baseUrl", "http://localhost:8080"); } public static String getBaseUiUrl() { if (instance.isActive()) { - //return "https://host.docker.internal" // this alternative requires docker-selenium.yml extra_hosts: - "host.docker.internal:host-gateway" + //return "https://host.docker.internal"; // this alternative requires docker-selenium.yml extra_hosts: - "host.docker.internal:host-gateway" //return "https://" + DockerClientFactory.instance().dockerHostIpAddress(); //this alternative will get Docker IP from testcontainers return "https://haproxy"; //communicate inside current docker-compose network to the load balancer container } - return getProperties().getProperty("tb.baseUiUrl"); + return System.getProperty("tb.baseUiUrl", "http://localhost:8080"); } public static String getWebSocketUrl() { if (instance.isActive()) { return WSS_URL; } - return getProperties().getProperty("tb.wsUrl"); - } - - private static Properties getProperties() { - if (properties == null) { - try (InputStream input = TestProperties.class.getClassLoader().getResourceAsStream("config.properties")) { - properties = new Properties(); - properties.load(input); - } catch (IOException ex) { - log.error("Exception while reading test properties " + ex.getMessage()); - } - } - return properties; + return System.getProperty("tb.wsUrl", "ws://localhost:8080"); } - } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/customerSmoke/CreateCustomerTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/customerSmoke/CreateCustomerTest.java index 81fcd6a020..c59ccd62f4 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/customerSmoke/CreateCustomerTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/customerSmoke/CreateCustomerTest.java @@ -196,4 +196,24 @@ public class CreateCustomerTest extends AbstractDriverBaseTest { Assert.assertTrue(urlContains(urlPath), "URL contains " + urlPath); } -} \ No newline at end of file + + @Epic("Customers smoke tests") + @Feature("Create customer") + @Test(groups = "smoke") + @Description("Go to customer documentation page") + public void createCustomerAddAndRemovePhoneNumber() { + String customerName = ENTITY_NAME; + String number = "12015550123"; + + sideBarMenuView.customerBtn().click(); + customerPage.plusBtn().click(); + customerPage.titleFieldAddEntityView().sendKeys(customerName); + customerPage.phoneNumberAddEntityView().sendKeys(number); + customerPage.phoneNumberAddEntityView().sendKeys(Keys.CONTROL + "A" + Keys.BACK_SPACE); + customerPage.addBtnC().click(); + this.customerName = customerName; + customerPage.entity(customerName).click(); + + Assert.assertTrue(customerPage.phoneNumberEntityView().getAttribute("value").isEmpty(), "Phone field is empty"); + } +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/customerSmoke/CustomerEditMenuTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/customerSmoke/CustomerEditMenuTest.java index c07e450bc4..47d950c312 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/customerSmoke/CustomerEditMenuTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/customerSmoke/CustomerEditMenuTest.java @@ -18,6 +18,7 @@ package org.thingsboard.server.msa.ui.tests.customerSmoke; import io.qameta.allure.Description; import io.qameta.allure.Epic; import io.qameta.allure.Feature; +import org.openqa.selenium.Keys; import org.testng.Assert; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeClass; @@ -307,4 +308,24 @@ public class CustomerEditMenuTest extends AbstractDriverBaseTest { Assert.assertEquals(customerPage.phoneNumberEntityView().getAttribute("value"), "+1" + number); Assert.assertEquals(customerPage.emailEntityView().getAttribute("value"), email); } -} \ No newline at end of file + + @Epic("Customers smoke tests") + @Feature("Edit customer") + @Test(groups = "smoke") + @Description("Delete phone number") + public void deletePhoneNumber() { + String customerName = ENTITY_NAME; + int number = 2015550123; + testRestClient.postCustomer(defaultCustomerPrototype(customerName, number)); + this.customerName = customerName; + + sideBarMenuView.customerBtn().click(); + customerPage.entity(customerName).click(); + customerPage.editPencilBtn().click(); + customerPage.phoneNumberEntityView().click(); + customerPage.phoneNumberEntityView().sendKeys(Keys.CONTROL + "A" + Keys.BACK_SPACE); + customerPage.doneBtnEditView().click(); + + Assert.assertTrue(customerPage.phoneNumberEntityView().getAttribute("value").isEmpty(), "Phone field is empty"); + } +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/ruleChainsSmoke/RuleChainEditMenuTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/ruleChainsSmoke/RuleChainEditMenuTest.java index 5b380221b6..3c65bc8f0a 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/ruleChainsSmoke/RuleChainEditMenuTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/ruleChainsSmoke/RuleChainEditMenuTest.java @@ -151,12 +151,12 @@ public class RuleChainEditMenuTest extends AbstractDriverBaseTest { ruleChainsPage.editPencilBtn().click(); ruleChainsPage.debugCheckboxEdit().click(); ruleChainsPage.doneBtnEditView().click(); - boolean debugMode = Boolean.parseBoolean(ruleChainsPage.debugCheckboxView().getAttribute("aria-checked")); + boolean debugMode = ruleChainsPage.debugCheckboxView().getAttribute("class").contains("selected"); ruleChainsPage.editPencilBtn().click(); ruleChainsPage.debugCheckboxEdit().click(); ruleChainsPage.doneBtnEditView().click(); - Assert.assertFalse(Boolean.parseBoolean(ruleChainsPage.debugCheckboxView().getAttribute("aria-checked"))); - Assert.assertTrue(debugMode); + Assert.assertFalse(ruleChainsPage.debugCheckboxView().getAttribute("class").contains("selected"), "Debug mode disable"); + Assert.assertTrue(debugMode, "Debug mode enable"); } } \ No newline at end of file diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/utils/EntityPrototypes.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/utils/EntityPrototypes.java index c131c0fb25..cfb438b59b 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/utils/EntityPrototypes.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/utils/EntityPrototypes.java @@ -43,6 +43,13 @@ public class EntityPrototypes { return customer; } + public static Customer defaultCustomerPrototype(String entityName, int number) { + Customer customer = new Customer(); + customer.setTitle(entityName); + customer.setPhone("+1" + number); + return customer; + } + public static RuleChain defaultRuleChainPrototype(String entityName) { RuleChain ruleChain = new RuleChain(); ruleChain.setName(entityName); diff --git a/msa/black-box-tests/src/test/resources/config.properties b/msa/black-box-tests/src/test/resources/config.properties deleted file mode 100644 index 4d8a0880a9..0000000000 --- a/msa/black-box-tests/src/test/resources/config.properties +++ /dev/null @@ -1,3 +0,0 @@ -tb.baseUrl=http://localhost:8080 -tb.baseUiUrl=http://localhost:8080 -tb.wsUrl=ws://localhost:8080 \ No newline at end of file diff --git a/msa/js-executor/config/custom-environment-variables.yml b/msa/js-executor/config/custom-environment-variables.yml index 1c8ee5972c..5ca66d71a1 100644 --- a/msa/js-executor/config/custom-environment-variables.yml +++ b/msa/js-executor/config/custom-environment-variables.yml @@ -32,6 +32,7 @@ kafka: linger_ms: "TB_KAFKA_LINGER_MS" # for producer partitions_consumed_concurrently: "TB_KAFKA_PARTITIONS_CONSUMED_CONCURRENTLY" # (EXPERIMENTAL) increase this value if you are planning to handle more than one partition (scale up, scale down) - this will decrease the latency requestTimeout: "TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS" + connectionTimeout: "TB_KAFKA_CONNECTION_TIMEOUT_MS" compression: "TB_QUEUE_KAFKA_COMPRESSION" # gzip or uncompressed topic_properties: "TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES" use_confluent_cloud: "TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD" diff --git a/msa/js-executor/config/default.yml b/msa/js-executor/config/default.yml index 8a2ad6b240..43712f0f61 100644 --- a/msa/js-executor/config/default.yml +++ b/msa/js-executor/config/default.yml @@ -32,6 +32,7 @@ kafka: linger_ms: "5" # for producer partitions_consumed_concurrently: "1" # (EXPERIMENTAL) increase this value if you are planning to handle more than one partition (scale up, scale down) - this will decrease the latency requestTimeout: "30000" # The default value in kafkajs is: 30000 + connectionTimeout: "1000" # The default value in kafkajs is: 1000 compression: "gzip" # gzip or uncompressed topic_properties: "retention.ms:604800000;segment.bytes:26214400;retention.bytes:104857600;partitions:100;min.insync.replicas:1" use_confluent_cloud: false diff --git a/msa/js-executor/queue/kafkaTemplate.ts b/msa/js-executor/queue/kafkaTemplate.ts index f88fbda61c..5d1b6cc6c1 100644 --- a/msa/js-executor/queue/kafkaTemplate.ts +++ b/msa/js-executor/queue/kafkaTemplate.ts @@ -42,6 +42,7 @@ export class KafkaTemplate implements IQueue { private maxBatchSize = Number(config.get('kafka.batch_size')); private linger = Number(config.get('kafka.linger_ms')); private requestTimeout = Number(config.get('kafka.requestTimeout')); + private connectionTimeout = Number(config.get('kafka.connectionTimeout')); private compressionType = (config.get('kafka.compression') === "gzip") ? CompressionTypes.GZIP : CompressionTypes.None; private partitionsConsumedConcurrently = Number(config.get('kafka.partitions_consumed_concurrently')); @@ -80,6 +81,8 @@ export class KafkaTemplate implements IQueue { kafkaConfig['requestTimeout'] = this.requestTimeout; + kafkaConfig['connectionTimeout'] = this.connectionTimeout; + if (useConfluent) { kafkaConfig['sasl'] = { mechanism: config.get('kafka.confluent.sasl.mechanism') as any, diff --git a/msa/monitoring/docker/Dockerfile b/msa/monitoring/docker/Dockerfile new file mode 100644 index 0000000000..5da050eb91 --- /dev/null +++ b/msa/monitoring/docker/Dockerfile @@ -0,0 +1,30 @@ +# +# Copyright © 2016-2023 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM thingsboard/openjdk11:bullseye-slim + +COPY start-tb-monitoring.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb-monitoring.sh /usr/bin && \ + (yes | dpkg -i /tmp/${pkg.name}.deb) && \ + rm /tmp/${pkg.name}.deb && \ + (systemctl --no-reload disable --now ${pkg.name}.service > /dev/null 2>&1 || :) && \ + chmod 555 ${pkg.installFolder}/bin/${pkg.name}.jar + +USER ${pkg.user} + +CMD ["start-tb-monitoring.sh"] diff --git a/msa/monitoring/docker/start-tb-monitoring.sh b/msa/monitoring/docker/start-tb-monitoring.sh new file mode 100755 index 0000000000..d5bcc52d37 --- /dev/null +++ b/msa/monitoring/docker/start-tb-monitoring.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# +# Copyright © 2016-2023 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +CONF_FOLDER=${pkg.installFolder}/conf +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf + +source "${CONF_FOLDER}/${configfile}" + +echo "Starting '${project.name}' ..." + +cd ${pkg.installFolder}/bin + +exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.monitoring.ThingsboardMonitoringApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dlogging.config=$CONF_FOLDER/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher diff --git a/msa/monitoring/pom.xml b/msa/monitoring/pom.xml new file mode 100644 index 0000000000..09b492f759 --- /dev/null +++ b/msa/monitoring/pom.xml @@ -0,0 +1,192 @@ + + + + 4.0.0 + + org.thingsboard + 3.5.0-SNAPSHOT + msa + + + org.thingsboard.msa + monitoring + pom + + ThingsBoard Monitoring Microservice + + + UTF-8 + ${basedir}/../.. + tb-monitoring + tb-monitoring + /var/log/${pkg.name} + /usr/share/${pkg.name} + pre-integration-test + + + + + org.thingsboard + monitoring + ${project.version} + deb + deb + provided + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-tb-monitoring-deb + package + + copy + + + + + org.thingsboard + monitoring + deb + deb + ${pkg.name}.deb + ${project.build.directory} + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-docker-config + process-resources + + copy-resources + + + ${project.build.directory} + + + docker + true + + + + + + + + com.spotify + dockerfile-maven-plugin + + + build-docker-image + pre-integration-test + + build + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + true + false + ${project.build.directory} + + + + tag-docker-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + + + + + + + + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-image + pre-integration-test + + push + + + latest + ${docker.repo}/${docker.name} + + + + push-version-docker-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${docker.name} + + + + + + + + + + + jenkins + Jenkins Repository + https://repo.jenkins-ci.org/releases + + false + + + + + \ No newline at end of file diff --git a/msa/pom.xml b/msa/pom.xml index e80dc54269..bf4e15d9d4 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -47,6 +47,7 @@ tb-node transport js-executor + monitoring diff --git a/msa/vc-executor/src/main/resources/tb-vc-executor.yml b/msa/vc-executor/src/main/resources/tb-vc-executor.yml index ed1377e1b9..352f94e091 100644 --- a/msa/vc-executor/src/main/resources/tb-vc-executor.yml +++ b/msa/vc-executor/src/main/resources/tb-vc-executor.yml @@ -50,6 +50,13 @@ queue: print-interval-ms: "${TB_QUEUE_IN_MEMORY_STATS_PRINT_INTERVAL_MS:60000}" kafka: bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + ssl: + enabled: "${TB_KAFKA_SSL_ENABLED:false}" + truststore.location: "${TB_KAFKA_SSL_TRUSTSTORE_LOCATION:}" + truststore.password: "${TB_KAFKA_SSL_TRUSTSTORE_PASSWORD:}" + keystore.location: "${TB_KAFKA_SSL_KEYSTORE_LOCATION:}" + keystore.password: "${TB_KAFKA_SSL_KEYSTORE_PASSWORD:}" + key.password: "${TB_KAFKA_SSL_KEY_PASSWORD:}" acks: "${TB_KAFKA_ACKS:all}" retries: "${TB_KAFKA_RETRIES:1}" compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip @@ -63,6 +70,8 @@ queue: max_poll_records: "${TB_QUEUE_KAFKA_MAX_POLL_RECORDS:8192}" max_partition_fetch_bytes: "${TB_QUEUE_KAFKA_MAX_PARTITION_FETCH_BYTES:16777216}" fetch_max_bytes: "${TB_QUEUE_KAFKA_FETCH_MAX_BYTES:134217728}" + request.timeout.ms: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + session.timeout.ms: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms use_confluent_cloud: "${TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD:false}" confluent: ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" @@ -81,11 +90,12 @@ queue: # tb_rule_engine.sq: # - key: max.poll.records # value: "${TB_QUEUE_KAFKA_SQ_MAX_POLL_RECORDS:1024}" - other: # In this section you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside - - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms - value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) - - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms - value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) + other-inline: "${TB_QUEUE_KAFKA_OTHER_PROPERTIES:}" # In this section you can specify custom parameters (semicolon separated) for Kafka consumer/producer/admin # Example "metrics.recording.level:INFO;metrics.sample.window.ms:30000" + other: # DEPRECATED. In this section you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside + # - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + # value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) + # - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms + # value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) topic-properties: core: "${TB_QUEUE_KAFKA_CORE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" @@ -167,6 +177,14 @@ vc: io_pool_size: "${TB_VC_GIT_POOL_SIZE:3}" repositories-folder: "${TB_VC_GIT_REPOSITORIES_FOLDER:${java.io.tmpdir}/repositories}" +# Usage statistics parameters +usage: + stats: + report: + enabled: "${USAGE_STATS_REPORT_ENABLED:true}" + enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" + interval: "${USAGE_STATS_REPORT_INTERVAL:10}" + metrics: # Enable/disable actuator metrics. enabled: "${METRICS_ENABLED:false}" diff --git a/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java b/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java index 9778ac69f1..cb1b6b81fe 100644 --- a/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java +++ b/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java @@ -15,9 +15,7 @@ */ package org.thingsboard.mqtt.integration; -import io.netty.buffer.ByteBuf; -import io.netty.buffer.ByteBufAllocator; -import io.netty.buffer.UnpooledByteBufAllocator; +import io.netty.buffer.Unpooled; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.handler.codec.mqtt.MqttMessageType; @@ -44,7 +42,7 @@ public class MqttIntegrationTest { static final String MQTT_HOST = "localhost"; static final int KEEPALIVE_TIMEOUT_SECONDS = 2; - static final ByteBufAllocator ALLOCATOR = new UnpooledByteBufAllocator(false); + static final long RECONNECT_DELAY_SECONDS = 10L; EventLoopGroup eventLoopGroup; MqttServer mqttServer; @@ -68,7 +66,7 @@ public class MqttIntegrationTest { this.mqttServer.shutdown(); } if (this.eventLoopGroup != null) { - this.eventLoopGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS); + this.eventLoopGroup.shutdownGracefully(0, 0, TimeUnit.MILLISECONDS); } } @@ -80,6 +78,7 @@ public class MqttIntegrationTest { log.warn("Sending publish messages..."); CountDownLatch latch = new CountDownLatch(3); for (int i = 0; i < 3; i++) { + Thread.sleep(30); Future pubFuture = publishMsg(); pubFuture.addListener(future -> latch.countDown()); } @@ -87,35 +86,30 @@ public class MqttIntegrationTest { log.warn("Waiting for messages acknowledgments..."); boolean awaitResult = latch.await(10, TimeUnit.SECONDS); Assert.assertTrue(awaitResult); + log.warn("Messages are delivered successfully..."); //when - CountDownLatch keepAliveLatch = new CountDownLatch(1); - log.warn("Starting idle period..."); - boolean keepaliveAwaitResult = keepAliveLatch.await(5, TimeUnit.SECONDS); - Assert.assertFalse(keepaliveAwaitResult); + Thread.sleep(5000); //then List allReceivedEvents = this.mqttServer.getEventsFromClient(); - long pubCount = allReceivedEvents.stream().filter(mqttMessageType -> mqttMessageType == MqttMessageType.PUBLISH).count(); long disconnectCount = allReceivedEvents.stream().filter(type -> type == MqttMessageType.DISCONNECT).count(); - Assert.assertEquals(3, pubCount); Assert.assertEquals(1, disconnectCount); } private Future publishMsg() { - ByteBuf byteBuf = ALLOCATOR.buffer(); - byteBuf.writeBytes("payload".getBytes(StandardCharsets.UTF_8)); return this.mqttClient.publish( "test/topic", - byteBuf, - MqttQoS.AT_LEAST_ONCE); + Unpooled.wrappedBuffer("payload".getBytes(StandardCharsets.UTF_8)), + MqttQoS.AT_MOST_ONCE); } private MqttClient initClient() throws Exception { MqttClientConfig config = new MqttClientConfig(); config.setTimeoutSeconds(KEEPALIVE_TIMEOUT_SECONDS); + config.setReconnectDelay(RECONNECT_DELAY_SECONDS); MqttClient client = MqttClient.create(config, null); client.setEventLoop(this.eventLoopGroup); Future connectFuture = client.connect(MQTT_HOST, this.mqttServer.getMqttPort()); diff --git a/pom.xml b/pom.xml index cbcb4474d6..9735ff79cb 100755 --- a/pom.xml +++ b/pom.xml @@ -39,13 +39,13 @@ 1.3.2 2.3.2 2.3.2 - 2.7.0 - 2.7.0 - 5.3.20 - 5.5.12 - 5.7.1 - 2.7.0 - 3.7.1 + 2.7.10 + 2.7.10 + 5.3.26 + 5.5.17 + 5.7.7 + 2.7.10 + 3.8.0 0.7.0 1.7.32 2.17.1 @@ -75,14 +75,14 @@ 2.3.30 1.6.2 4.2.0 - 3.5.5 + 3.8.1 3.21.9 1.42.1 1.0.6 1.18.18 1.2.4 1.2.5 - 4.1.75.Final + 4.1.91.Final 2.0.51.Final 1.7.0 4.8.0 @@ -107,13 +107,13 @@ 4.1.1 2.57 2.7.7 - 1.27 + 2.0 1.11.747 1.105.0 2.1.0 3.2.0 1.5.0 - 1.4.3 + 1.5.4 1.9.4 3.2.2 1.9.0 @@ -123,7 +123,7 @@ 6.0.20.Final 3.0.0 2.0.1.Final - 1.6.8 + 1.7.2 2.8.5 4.1.0 @@ -164,6 +164,7 @@ application msa rest-client + monitoring @@ -194,6 +195,7 @@ org.apache.maven.plugins maven-resources-plugin + 3.2.0 copy-conf @@ -623,7 +625,7 @@ org.apache.maven.plugins maven-resources-plugin - 2.7 + 3.2.0 org.apache.maven.plugins @@ -1084,6 +1086,11 @@ jaxb-runtime ${jaxb-runtime.version} + + org.springframework.boot + spring-boot-starter + ${spring-boot.version} + org.springframework.boot spring-boot-starter-security diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java index 029531494e..65b7800fe3 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -21,7 +21,6 @@ import com.google.gson.JsonElement; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; import org.thingsboard.server.common.data.util.CollectionsUtil; import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; @@ -127,7 +126,6 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { } } - @NotNull private FutureCallback getFutureCallback(TbContext ctx, TbMsg msg, EntityView entityView) { return new FutureCallback() { @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateRelationNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateRelationNode.java index 22b7e2115b..d2692993de 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateRelationNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateRelationNode.java @@ -25,6 +25,7 @@ import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.rule.engine.util.EntityContainer; +import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; @@ -171,13 +172,12 @@ public class TbCreateRelationNode extends TbAbstractRelationActionNode processDevice(TbContext ctx, EntityContainer entityContainer, SearchDirectionIds sdId, String relationType) { - return Futures.transformAsync(ctx.getDeviceService().findDeviceByIdAsync(ctx.getTenantId(), new DeviceId(entityContainer.getEntityId().getId())), device -> { - if (device != null) { - return processSave(ctx, sdId, relationType); - } else { - return Futures.immediateFuture(true); - } - }, ctx.getDbCallbackExecutor()); + Device device = ctx.getDeviceService().findDeviceById(ctx.getTenantId(), new DeviceId(entityContainer.getEntityId().getId())); + if (device != null) { + return processSave(ctx, sdId, relationType); + } else { + return Futures.immediateFuture(true); + } } private ListenableFuture processAsset(TbContext ctx, EntityContainer entityContainer, SearchDirectionIds sdId, String relationType) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java index e8816ff607..c408e45d2d 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java @@ -43,14 +43,16 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @RuleNode( - type = ComponentType.ACTION, + type = ComponentType.TRANSFORMATION, name = "deduplication", configClazz = TbMsgDeduplicationNodeConfiguration.class, - nodeDescription = "Deduplicate messages for a configurable period based on a specified deduplication strategy.", + nodeDescription = "Deduplicate messages within the same originator entity for a configurable period " + + "based on a specified deduplication strategy.", nodeDetails = "Rule node allows you to select one of the following strategy to deduplicate messages:

" + "FIRST - return first message that arrived during deduplication period.

" + "LAST - return last message that arrived during deduplication period.

" + - "ALL - return all messages as a single JSON array message. Where each element represents object with msg and metadata inner properties.

", + "ALL - return all messages as a single JSON array message. " + + "Where each element represents object with msg and metadata inner properties.

", icon = "content_copy", uiResources = {"static/rulenode/rulenode-core-config.js"}, configDirective = "tbActionNodeMsgDeduplicationConfig" @@ -153,7 +155,15 @@ public class TbMsgDeduplicationNode implements TbNode { } } } - deduplicationResults.add(resultMsg); + if (resultMsg != null) { + deduplicationResults.add(TbMsg.newMsg( + resultMsg.getQueueName(), + resultMsg.getType(), + resultMsg.getOriginator(), + resultMsg.getCustomerId(), + resultMsg.getMetaData(), + resultMsg.getData())); + } } packBoundsOpt = findValidPack(msgList, deduplicationTimeoutMs); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java index 857a068e8c..1f18aea86e 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java @@ -66,7 +66,17 @@ public class TbMsgDelayNode implements TbNode { if (msg.getType().equals(TB_MSG_DELAY_NODE_MSG)) { TbMsg pendingMsg = pendingMsgs.remove(UUID.fromString(msg.getData())); if (pendingMsg != null) { - ctx.enqueueForTellNext(pendingMsg, SUCCESS); + ctx.enqueueForTellNext( + TbMsg.newMsg( + pendingMsg.getQueueName(), + pendingMsg.getType(), + pendingMsg.getOriginator(), + pendingMsg.getCustomerId(), + pendingMsg.getMetaData(), + pendingMsg.getData() + ), + SUCCESS + ); } } else { if (pendingMsgs.size() < config.getMaxPendingMsgs()) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/AbstractTbMsgPushNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/AbstractTbMsgPushNode.java index 7be7dae2de..318b38aa39 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/AbstractTbMsgPushNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/AbstractTbMsgPushNode.java @@ -77,9 +77,9 @@ public abstract class AbstractTbMsgPushNode entityBody = new HashMap<>(); Map metadata = msg.getMetaData().getData(); + EdgeEventActionType actionType = getEdgeEventActionTypeByMsgType(msgType, metadata); + Map entityBody = new HashMap<>(); JsonNode dataJson = JacksonUtil.toJsonNode(msg.getData()); switch (actionType) { case ATTRIBUTES_UPDATED: @@ -148,7 +148,7 @@ public abstract class AbstractTbMsgPushNode metadata) { EdgeEventActionType actionType; if (SessionMsgType.POST_TELEMETRY_REQUEST.name().equals(msgType) || DataConstants.TIMESERIES_UPDATED.equals(msgType)) { @@ -159,6 +159,16 @@ public abstract class AbstractTbMsgPushNode implements TbNode { @@ -67,7 +66,7 @@ public abstract class TbAbstractGetEntityDetailsNode getDetails(TbContext ctx, TbMsg msg); - protected abstract ListenableFuture getContactBasedListenableFuture(TbContext ctx, TbMsg msg); + protected abstract ListenableFuture getContactBasedListenableFuture(TbContext ctx, TbMsg msg); protected MessageData getDataAsJson(TbMsg msg) { if (this.config.isAddToMetadata()) { @@ -79,7 +78,7 @@ public abstract class TbAbstractGetEntityDetailsNode getTbMsgListenableFuture(TbContext ctx, TbMsg msg, MessageData messageData, String prefix) { if (!this.config.getDetailsList().isEmpty()) { - ListenableFuture contactBasedListenableFuture = getContactBasedListenableFuture(ctx, msg); + ListenableFuture contactBasedListenableFuture = getContactBasedListenableFuture(ctx, msg); ListenableFuture resultObject = addContactProperties(messageData.getData(), contactBasedListenableFuture, prefix); return transformMsg(ctx, msg, resultObject, messageData); } else { @@ -102,7 +101,7 @@ public abstract class TbAbstractGetEntityDetailsNode addContactProperties(JsonElement data, ListenableFuture entityFuture, String prefix) { + private ListenableFuture addContactProperties(JsonElement data, ListenableFuture entityFuture, String prefix) { return Futures.transformAsync(entityFuture, contactBased -> { if (contactBased != null) { JsonElement jsonElement = null; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNode.java index fa4f3552bc..bf9c689b30 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNode.java @@ -26,6 +26,8 @@ import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.ContactBased; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.HasCustomerId; +import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EdgeId; @@ -59,81 +61,44 @@ public class TbGetCustomerDetailsNode extends TbAbstractGetEntityDetailsNode getContactBasedListenableFuture(TbContext ctx, TbMsg msg) { - return Futures.transformAsync(getCustomer(ctx, msg), customer -> { - if (customer != null) { - return Futures.immediateFuture(customer); - } else { - return Futures.immediateFuture(null); - } - }, MoreExecutors.directExecutor()); + protected ListenableFuture getContactBasedListenableFuture(TbContext ctx, TbMsg msg) { + return getCustomer(ctx, msg); } private ListenableFuture getCustomer(TbContext ctx, TbMsg msg) { - switch (msg.getOriginator().getEntityType()) { + ListenableFuture entityFuture; + switch (msg.getOriginator().getEntityType()) { // TODO: use EntityServiceRegistry case DEVICE: - return Futures.transformAsync(ctx.getDeviceService().findDeviceByIdAsync(ctx.getTenantId(), new DeviceId(msg.getOriginator().getId())), device -> { - if (device != null) { - if (!device.getCustomerId().isNullUid()) { - return ctx.getCustomerService().findCustomerByIdAsync(ctx.getTenantId(), device.getCustomerId()); - } else { - throw new RuntimeException("Device with name '" + device.getName() + "' is not assigned to Customer."); - } - } else { - return Futures.immediateFuture(null); - } - }, MoreExecutors.directExecutor()); + entityFuture = Futures.immediateFuture(ctx.getDeviceService().findDeviceById(ctx.getTenantId(), (DeviceId) msg.getOriginator())); + break; case ASSET: - return Futures.transformAsync(ctx.getAssetService().findAssetByIdAsync(ctx.getTenantId(), new AssetId(msg.getOriginator().getId())), asset -> { - if (asset != null) { - if (!asset.getCustomerId().isNullUid()) { - return ctx.getCustomerService().findCustomerByIdAsync(ctx.getTenantId(), asset.getCustomerId()); - } else { - throw new RuntimeException("Asset with name '" + asset.getName() + "' is not assigned to Customer."); - } - } else { - return Futures.immediateFuture(null); - } - }, MoreExecutors.directExecutor()); + entityFuture = ctx.getAssetService().findAssetByIdAsync(ctx.getTenantId(), (AssetId) msg.getOriginator()); + break; case ENTITY_VIEW: - return Futures.transformAsync(ctx.getEntityViewService().findEntityViewByIdAsync(ctx.getTenantId(), new EntityViewId(msg.getOriginator().getId())), entityView -> { - if (entityView != null) { - if (!entityView.getCustomerId().isNullUid()) { - return ctx.getCustomerService().findCustomerByIdAsync(ctx.getTenantId(), entityView.getCustomerId()); - } else { - throw new RuntimeException("EntityView with name '" + entityView.getName() + "' is not assigned to Customer."); - } - } else { - return Futures.immediateFuture(null); - } - }, MoreExecutors.directExecutor()); + entityFuture = ctx.getEntityViewService().findEntityViewByIdAsync(ctx.getTenantId(), (EntityViewId) msg.getOriginator()); + break; case USER: - return Futures.transformAsync(ctx.getUserService().findUserByIdAsync(ctx.getTenantId(), new UserId(msg.getOriginator().getId())), user -> { - if (user != null) { - if (!user.getCustomerId().isNullUid()) { - return ctx.getCustomerService().findCustomerByIdAsync(ctx.getTenantId(), user.getCustomerId()); - } else { - throw new RuntimeException("User with name '" + user.getName() + "' is not assigned to Customer."); - } - } else { - return Futures.immediateFuture(null); - } - }, MoreExecutors.directExecutor()); + entityFuture = ctx.getUserService().findUserByIdAsync(ctx.getTenantId(), (UserId) msg.getOriginator()); + break; case EDGE: - return Futures.transformAsync(ctx.getEdgeService().findEdgeByIdAsync(ctx.getTenantId(), new EdgeId(msg.getOriginator().getId())), edge -> { - if (edge != null) { - if (!edge.getCustomerId().isNullUid()) { - return ctx.getCustomerService().findCustomerByIdAsync(ctx.getTenantId(), edge.getCustomerId()); - } else { - throw new RuntimeException("Edge with name '" + edge.getName() + "' is not assigned to Customer."); - } - } else { - return Futures.immediateFuture(null); - } - }, MoreExecutors.directExecutor()); + entityFuture = ctx.getEdgeService().findEdgeByIdAsync(ctx.getTenantId(), (EdgeId) msg.getOriginator()); + break; default: - throw new RuntimeException("Entity with entityType '" + msg.getOriginator().getEntityType() + "' is not supported."); + throw new RuntimeException(msg.getOriginator().getEntityType().getNormalName() + " entities not supported"); } + return Futures.transformAsync(entityFuture, entity -> { + if (entity != null) { + if (!entity.getCustomerId().isNullUid()) { + return ctx.getCustomerService().findCustomerByIdAsync(ctx.getTenantId(), entity.getCustomerId()); + } else { + throw new RuntimeException(msg.getOriginator().getEntityType().getNormalName() + + (entity instanceof HasName ? " with name '" + ((HasName) entity).getName() + "'" : "") + + " is not assigned to Customer"); + } + } else { + return Futures.immediateFuture(null); + } + }, MoreExecutors.directExecutor()); } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantDetailsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantDetailsNode.java index 2b7316a59b..6a8de60e6e 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantDetailsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantDetailsNode.java @@ -53,13 +53,7 @@ public class TbGetTenantDetailsNode extends TbAbstractGetEntityDetailsNode getContactBasedListenableFuture(TbContext ctx, TbMsg msg) { - return Futures.transformAsync(ctx.getTenantService().findTenantByIdAsync(ctx.getTenantId(), ctx.getTenantId()), tenant -> { - if (tenant != null) { - return Futures.immediateFuture(tenant); - } else { - return Futures.immediateFuture(null); - } - }, MoreExecutors.directExecutor()); + protected ListenableFuture getContactBasedListenableFuture(TbContext ctx, TbMsg msg) { + return ctx.getTenantService().findTenantByIdAsync(ctx.getTenantId(), ctx.getTenantId()); } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java index 7137740c74..6f9541f5b1 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java @@ -35,7 +35,6 @@ import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.http.client.HttpComponentsAsyncClientHttpRequestFactory; import org.springframework.http.client.Netty4ClientHttpRequestFactory; -import org.thingsboard.server.common.data.StringUtils; import org.springframework.util.concurrent.ListenableFuture; import org.springframework.util.concurrent.ListenableFutureCallback; import org.springframework.web.client.AsyncRestTemplate; @@ -49,6 +48,7 @@ import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.rule.engine.credentials.BasicCredentials; import org.thingsboard.rule.engine.credentials.ClientCredentials; import org.thingsboard.rule.engine.credentials.CredentialsType; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; @@ -192,7 +192,7 @@ public class TbHttpClient { config.isIgnoreRequestBody()) { entity = new HttpEntity<>(headers); } else { - entity = new HttpEntity<>(msg.getData(), headers); + entity = new HttpEntity<>(getData(msg), headers); } URI uri = buildEncodedUri(endpointUrl); @@ -243,6 +243,18 @@ public class TbHttpClient { return uri; } + private String getData(TbMsg msg) { + String data = msg.getData(); + + if (config.isTrimDoubleQuotes()) { + final String dataBefore = data; + data = data.replaceAll("^\"|\"$", "");; + log.trace("Trimming double quotes. Before trim: [{}], after trim: [{}]", dataBefore, data); + } + + return data; + } + private TbMsg processResponse(TbContext ctx, TbMsg origMsg, ResponseEntity response) { TbMsgMetaData metaData = origMsg.getMetaData(); metaData.putValue(STATUS, response.getStatusCode().name()); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java index e53d3eec77..3fb28d474b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java @@ -37,8 +37,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration findEntityIdAsync(TbContext ctx, EntityId original) { - switch (original.getEntityType()) { case CUSTOMER: return Futures.immediateFuture((CustomerId) original); @@ -40,7 +39,7 @@ public class EntitiesCustomerIdAsyncLoader { case ASSET: return getCustomerAsync(ctx.getAssetService().findAssetByIdAsync(ctx.getTenantId(), (AssetId) original)); case DEVICE: - return getCustomerAsync(ctx.getDeviceService().findDeviceByIdAsync(ctx.getTenantId(), (DeviceId) original)); + return getCustomerAsync(Futures.immediateFuture(ctx.getDeviceService().findDeviceById(ctx.getTenantId(), (DeviceId) original))); default: return Futures.immediateFailedFuture(new TbNodeException("Unexpected original EntityType " + original.getEntityType())); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoader.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoader.java index cb9ee7c8b6..51d83b3bfb 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoader.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoader.java @@ -37,7 +37,7 @@ import java.util.function.Function; public class EntitiesFieldsAsyncLoader { public static ListenableFuture findAsync(TbContext ctx, EntityId original) { - switch (original.getEntityType()) { + switch (original.getEntityType()) { // TODO: use EntityServiceRegistry case TENANT: return getAsync(ctx.getTenantService().findTenantByIdAsync(ctx.getTenantId(), (TenantId) original), EntityFieldsData::new); @@ -51,7 +51,7 @@ public class EntitiesFieldsAsyncLoader { return getAsync(ctx.getAssetService().findAssetByIdAsync(ctx.getTenantId(), (AssetId) original), EntityFieldsData::new); case DEVICE: - return getAsync(ctx.getDeviceService().findDeviceByIdAsync(ctx.getTenantId(), (DeviceId) original), + return getAsync(Futures.immediateFuture(ctx.getDeviceService().findDeviceById(ctx.getTenantId(), (DeviceId) original)), EntityFieldsData::new); case ALARM: return getAsync(ctx.getAlarmService().findAlarmByIdAsync(ctx.getTenantId(), (AlarmId) original), diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbMsgDeduplicationNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbMsgDeduplicationNodeTest.java deleted file mode 100644 index 1091a7d29c..0000000000 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbMsgDeduplicationNodeTest.java +++ /dev/null @@ -1,402 +0,0 @@ -/** - * Copyright © 2016-2023 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.rule.engine.action; - -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.ArgumentMatchers; -import org.mockito.stubbing.Answer; -import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.common.util.ThingsBoardThreadFactory; -import org.thingsboard.rule.engine.api.TbContext; -import org.thingsboard.rule.engine.api.TbNodeConfiguration; -import org.thingsboard.rule.engine.api.TbNodeException; -import org.thingsboard.rule.engine.api.TbRelationTypes; -import org.thingsboard.rule.engine.deduplication.DeduplicationStrategy; -import org.thingsboard.rule.engine.deduplication.TbMsgDeduplicationNode; -import org.thingsboard.rule.engine.deduplication.TbMsgDeduplicationNodeConfiguration; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.id.RuleNodeId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.common.msg.TbMsgMetaData; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -import java.util.ArrayList; -import java.util.List; -import java.util.Random; -import java.util.UUID; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.function.Consumer; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.ArgumentMatchers.nullable; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@Slf4j -public class TbMsgDeduplicationNodeTest { - - private static final String MAIN_QUEUE_NAME = "Main"; - private static final String HIGH_PRIORITY_QUEUE_NAME = "HighPriority"; - private static final String TB_MSG_DEDUPLICATION_TIMEOUT_MSG = "TbMsgDeduplicationNodeMsg"; - - private TbContext ctx; - - private final ThingsBoardThreadFactory factory = ThingsBoardThreadFactory.forName("de-duplication-node-test"); - private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(factory); - private final int deduplicationInterval = 1; - - private TenantId tenantId; - - private TbMsgDeduplicationNode node; - private TbMsgDeduplicationNodeConfiguration config; - private TbNodeConfiguration nodeConfiguration; - - private CountDownLatch awaitTellSelfLatch; - - @BeforeEach - public void init() throws TbNodeException { - ctx = mock(TbContext.class); - - tenantId = TenantId.fromUUID(UUID.randomUUID()); - RuleNodeId ruleNodeId = new RuleNodeId(UUID.randomUUID()); - - when(ctx.getSelfId()).thenReturn(ruleNodeId); - when(ctx.getTenantId()).thenReturn(tenantId); - - doAnswer((Answer) invocationOnMock -> { - String type = (String) (invocationOnMock.getArguments())[1]; - EntityId originator = (EntityId) (invocationOnMock.getArguments())[2]; - TbMsgMetaData metaData = (TbMsgMetaData) (invocationOnMock.getArguments())[3]; - String data = (String) (invocationOnMock.getArguments())[4]; - return TbMsg.newMsg(type, originator, metaData.copy(), data); - }).when(ctx).newMsg(isNull(), eq(TB_MSG_DEDUPLICATION_TIMEOUT_MSG), nullable(EntityId.class), any(TbMsgMetaData.class), any(String.class)); - node = spy(new TbMsgDeduplicationNode()); - config = new TbMsgDeduplicationNodeConfiguration().defaultConfiguration(); - } - - private void invokeTellSelf(int maxNumberOfInvocation) { - invokeTellSelf(maxNumberOfInvocation, false, 0); - } - - private void invokeTellSelf(int maxNumberOfInvocation, boolean delayScheduleTimeout, int delayMultiplier) { - AtomicLong scheduleTimeout = new AtomicLong(deduplicationInterval); - AtomicInteger scheduleCount = new AtomicInteger(0); - doAnswer((Answer) invocationOnMock -> { - scheduleCount.getAndIncrement(); - if (scheduleCount.get() <= maxNumberOfInvocation) { - TbMsg msg = (TbMsg) (invocationOnMock.getArguments())[0]; - executorService.schedule(() -> { - try { - node.onMsg(ctx, msg); - awaitTellSelfLatch.countDown(); - } catch (ExecutionException | InterruptedException | TbNodeException e) { - log.error("Failed to execute tellSelf method call due to: ", e); - } - }, scheduleTimeout.get(), TimeUnit.SECONDS); - if (delayScheduleTimeout) { - scheduleTimeout.set(scheduleTimeout.get() * delayMultiplier); - } - } - - return null; - }).when(ctx).tellSelf(ArgumentMatchers.any(TbMsg.class), ArgumentMatchers.anyLong()); - } - - @AfterEach - public void destroy() { - executorService.shutdown(); - node.destroy(); - } - - @Test - public void given_100_messages_strategy_first_then_verifyOutput() throws TbNodeException, ExecutionException, InterruptedException { - int wantedNumberOfTellSelfInvocation = 2; - int msgCount = 100; - awaitTellSelfLatch = new CountDownLatch(wantedNumberOfTellSelfInvocation); - invokeTellSelf(wantedNumberOfTellSelfInvocation); - - config.setInterval(deduplicationInterval); - config.setMaxPendingMsgs(msgCount); - nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); - node.init(ctx, nodeConfiguration); - - DeviceId deviceId = new DeviceId(UUID.randomUUID()); - long currentTimeMillis = System.currentTimeMillis(); - - List inputMsgs = getTbMsgs(deviceId, msgCount, currentTimeMillis, 500); - for (TbMsg msg : inputMsgs) { - node.onMsg(ctx, msg); - } - - TbMsg msgToReject = createMsg(deviceId, inputMsgs.get(inputMsgs.size() - 1).getMetaDataTs() + 2); - node.onMsg(ctx, msgToReject); - - awaitTellSelfLatch.await(); - - ArgumentCaptor newMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); - ArgumentCaptor successCaptor = ArgumentCaptor.forClass(Runnable.class); - ArgumentCaptor> failureCaptor = ArgumentCaptor.forClass(Consumer.class); - - verify(ctx, times(msgCount)).ack(any()); - verify(ctx, times(1)).tellFailure(eq(msgToReject), any()); - verify(node, times(msgCount + wantedNumberOfTellSelfInvocation + 1)).onMsg(eq(ctx), any()); - verify(ctx, times(1)).enqueueForTellNext(newMsgCaptor.capture(), eq(TbRelationTypes.SUCCESS), successCaptor.capture(), failureCaptor.capture()); - Assertions.assertEquals(inputMsgs.get(0), newMsgCaptor.getValue()); - } - - @Test - public void given_100_messages_strategy_last_then_verifyOutput() throws TbNodeException, ExecutionException, InterruptedException { - int wantedNumberOfTellSelfInvocation = 2; - int msgCount = 100; - awaitTellSelfLatch = new CountDownLatch(wantedNumberOfTellSelfInvocation); - invokeTellSelf(wantedNumberOfTellSelfInvocation); - - config.setStrategy(DeduplicationStrategy.LAST); - config.setInterval(deduplicationInterval); - config.setMaxPendingMsgs(msgCount); - nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); - node.init(ctx, nodeConfiguration); - - DeviceId deviceId = new DeviceId(UUID.randomUUID()); - long currentTimeMillis = System.currentTimeMillis(); - - List inputMsgs = getTbMsgs(deviceId, msgCount, currentTimeMillis, 500); - TbMsg msgWithLatestTs = getMsgWithLatestTs(inputMsgs); - - for (TbMsg msg : inputMsgs) { - node.onMsg(ctx, msg); - } - - TbMsg msgToReject = createMsg(deviceId, inputMsgs.get(inputMsgs.size() - 1).getMetaDataTs() + 2); - node.onMsg(ctx, msgToReject); - - awaitTellSelfLatch.await(); - - ArgumentCaptor newMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); - ArgumentCaptor successCaptor = ArgumentCaptor.forClass(Runnable.class); - ArgumentCaptor> failureCaptor = ArgumentCaptor.forClass(Consumer.class); - - verify(ctx, times(msgCount)).ack(any()); - verify(ctx, times(1)).tellFailure(eq(msgToReject), any()); - verify(node, times(msgCount + wantedNumberOfTellSelfInvocation + 1)).onMsg(eq(ctx), any()); - verify(ctx, times(1)).enqueueForTellNext(newMsgCaptor.capture(), eq(TbRelationTypes.SUCCESS), successCaptor.capture(), failureCaptor.capture()); - Assertions.assertEquals(msgWithLatestTs, newMsgCaptor.getValue()); - } - - @Test - public void given_100_messages_strategy_all_then_verifyOutput() throws TbNodeException, ExecutionException, InterruptedException { - int wantedNumberOfTellSelfInvocation = 2; - int msgCount = 100; - awaitTellSelfLatch = new CountDownLatch(wantedNumberOfTellSelfInvocation); - invokeTellSelf(wantedNumberOfTellSelfInvocation); - - config.setInterval(deduplicationInterval); - config.setStrategy(DeduplicationStrategy.ALL); - config.setOutMsgType(SessionMsgType.POST_ATTRIBUTES_REQUEST.name()); - config.setQueueName(HIGH_PRIORITY_QUEUE_NAME); - nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); - node.init(ctx, nodeConfiguration); - - DeviceId deviceId = new DeviceId(UUID.randomUUID()); - long currentTimeMillis = System.currentTimeMillis(); - - List inputMsgs = getTbMsgs(deviceId, msgCount, currentTimeMillis, 500); - for (TbMsg msg : inputMsgs) { - node.onMsg(ctx, msg); - } - - awaitTellSelfLatch.await(); - - ArgumentCaptor newMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); - ArgumentCaptor successCaptor = ArgumentCaptor.forClass(Runnable.class); - ArgumentCaptor> failureCaptor = ArgumentCaptor.forClass(Consumer.class); - - verify(ctx, times(msgCount)).ack(any()); - verify(node, times(msgCount + wantedNumberOfTellSelfInvocation)).onMsg(eq(ctx), any()); - verify(ctx, times(1)).enqueueForTellNext(newMsgCaptor.capture(), eq(TbRelationTypes.SUCCESS), successCaptor.capture(), failureCaptor.capture()); - - Assertions.assertEquals(1, newMsgCaptor.getAllValues().size()); - TbMsg outMessage = newMsgCaptor.getAllValues().get(0); - Assertions.assertEquals(getMergedData(inputMsgs), outMessage.getData()); - Assertions.assertEquals(deviceId, outMessage.getOriginator()); - Assertions.assertEquals(config.getOutMsgType(), outMessage.getType()); - Assertions.assertEquals(config.getQueueName(), outMessage.getQueueName()); - } - - @Test - public void given_100_messages_strategy_all_then_verifyOutput_2_packs() throws TbNodeException, ExecutionException, InterruptedException { - int wantedNumberOfTellSelfInvocation = 2; - int msgCount = 100; - awaitTellSelfLatch = new CountDownLatch(wantedNumberOfTellSelfInvocation); - invokeTellSelf(wantedNumberOfTellSelfInvocation, true, 3); - - config.setInterval(deduplicationInterval); - config.setStrategy(DeduplicationStrategy.ALL); - config.setOutMsgType(SessionMsgType.POST_ATTRIBUTES_REQUEST.name()); - config.setQueueName(HIGH_PRIORITY_QUEUE_NAME); - nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); - node.init(ctx, nodeConfiguration); - - DeviceId deviceId = new DeviceId(UUID.randomUUID()); - long currentTimeMillis = System.currentTimeMillis(); - - List firstMsgPack = getTbMsgs(deviceId, msgCount / 2, currentTimeMillis, 500); - for (TbMsg msg : firstMsgPack) { - node.onMsg(ctx, msg); - } - long firstPackDeduplicationPackEndTs = firstMsgPack.get(0).getMetaDataTs() + TimeUnit.SECONDS.toMillis(deduplicationInterval); - - List secondMsgPack = getTbMsgs(deviceId, msgCount / 2, firstPackDeduplicationPackEndTs, 500); - for (TbMsg msg : secondMsgPack) { - node.onMsg(ctx, msg); - } - - awaitTellSelfLatch.await(); - - ArgumentCaptor newMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); - ArgumentCaptor successCaptor = ArgumentCaptor.forClass(Runnable.class); - ArgumentCaptor> failureCaptor = ArgumentCaptor.forClass(Consumer.class); - - verify(ctx, times(msgCount)).ack(any()); - verify(node, times(msgCount + wantedNumberOfTellSelfInvocation)).onMsg(eq(ctx), any()); - verify(ctx, times(2)).enqueueForTellNext(newMsgCaptor.capture(), eq(TbRelationTypes.SUCCESS), successCaptor.capture(), failureCaptor.capture()); - - List resultMsgs = newMsgCaptor.getAllValues(); - Assertions.assertEquals(2, resultMsgs.size()); - - TbMsg firstMsg = resultMsgs.get(0); - Assertions.assertEquals(getMergedData(firstMsgPack), firstMsg.getData()); - Assertions.assertEquals(deviceId, firstMsg.getOriginator()); - Assertions.assertEquals(config.getOutMsgType(), firstMsg.getType()); - Assertions.assertEquals(config.getQueueName(), firstMsg.getQueueName()); - - TbMsg secondMsg = resultMsgs.get(1); - Assertions.assertEquals(getMergedData(secondMsgPack), secondMsg.getData()); - Assertions.assertEquals(deviceId, secondMsg.getOriginator()); - Assertions.assertEquals(config.getOutMsgType(), secondMsg.getType()); - Assertions.assertEquals(config.getQueueName(), secondMsg.getQueueName()); - } - - @Test - public void given_100_messages_strategy_last_then_verifyOutput_2_packs() throws TbNodeException, ExecutionException, InterruptedException { - int wantedNumberOfTellSelfInvocation = 2; - int msgCount = 100; - awaitTellSelfLatch = new CountDownLatch(wantedNumberOfTellSelfInvocation); - invokeTellSelf(wantedNumberOfTellSelfInvocation, true, 3); - - config.setInterval(deduplicationInterval); - config.setStrategy(DeduplicationStrategy.LAST); - nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); - node.init(ctx, nodeConfiguration); - - DeviceId deviceId = new DeviceId(UUID.randomUUID()); - long currentTimeMillis = System.currentTimeMillis(); - - List firstMsgPack = getTbMsgs(deviceId, msgCount / 2, currentTimeMillis, 500); - for (TbMsg msg : firstMsgPack) { - node.onMsg(ctx, msg); - } - long firstPackDeduplicationPackEndTs = firstMsgPack.get(0).getMetaDataTs() + TimeUnit.SECONDS.toMillis(deduplicationInterval); - TbMsg msgWithLatestTsInFirstPack = getMsgWithLatestTs(firstMsgPack); - - List secondMsgPack = getTbMsgs(deviceId, msgCount / 2, firstPackDeduplicationPackEndTs, 500); - for (TbMsg msg : secondMsgPack) { - node.onMsg(ctx, msg); - } - TbMsg msgWithLatestTsInSecondPack = getMsgWithLatestTs(secondMsgPack); - - awaitTellSelfLatch.await(); - - ArgumentCaptor newMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); - ArgumentCaptor successCaptor = ArgumentCaptor.forClass(Runnable.class); - ArgumentCaptor> failureCaptor = ArgumentCaptor.forClass(Consumer.class); - - verify(ctx, times(msgCount)).ack(any()); - verify(node, times(msgCount + wantedNumberOfTellSelfInvocation)).onMsg(eq(ctx), any()); - verify(ctx, times(2)).enqueueForTellNext(newMsgCaptor.capture(), eq(TbRelationTypes.SUCCESS), successCaptor.capture(), failureCaptor.capture()); - - List resultMsgs = newMsgCaptor.getAllValues(); - Assertions.assertEquals(2, resultMsgs.size()); - Assertions.assertTrue(resultMsgs.contains(msgWithLatestTsInFirstPack)); - Assertions.assertTrue(resultMsgs.contains(msgWithLatestTsInSecondPack)); - } - - private TbMsg getMsgWithLatestTs(List firstMsgPack) { - int indexOfLastMsgInArray = firstMsgPack.size() - 1; - int indexToSetMaxTs = new Random().nextInt(indexOfLastMsgInArray) + 1; - TbMsg currentMaxTsMsg = firstMsgPack.get(indexOfLastMsgInArray); - TbMsg newLastMsgOfArray = firstMsgPack.get(indexToSetMaxTs); - firstMsgPack.set(indexOfLastMsgInArray, newLastMsgOfArray); - firstMsgPack.set(indexToSetMaxTs, currentMaxTsMsg); - return currentMaxTsMsg; - } - - private List getTbMsgs(DeviceId deviceId, int msgCount, long currentTimeMillis, int initTsStep) { - List inputMsgs = new ArrayList<>(); - var ts = currentTimeMillis + initTsStep; - for (int i = 0; i < msgCount; i++) { - inputMsgs.add(createMsg(deviceId, ts)); - ts += 2; - } - return inputMsgs; - } - - private TbMsg createMsg(DeviceId deviceId, long ts) { - ObjectNode dataNode = JacksonUtil.newObjectNode(); - dataNode.put("deviceId", deviceId.getId().toString()); - TbMsgMetaData metaData = new TbMsgMetaData(); - metaData.putValue("ts", String.valueOf(ts)); - return TbMsg.newMsg( - MAIN_QUEUE_NAME, - SessionMsgType.POST_TELEMETRY_REQUEST.name(), - deviceId, - metaData, - JacksonUtil.toString(dataNode)); - } - - private String getMergedData(List msgs) { - ArrayNode mergedData = JacksonUtil.OBJECT_MAPPER.createArrayNode(); - msgs.forEach(msg -> { - ObjectNode msgNode = JacksonUtil.newObjectNode(); - msgNode.set("msg", JacksonUtil.toJsonNode(msg.getData())); - msgNode.set("metadata", JacksonUtil.valueToTree(msg.getMetaData().getData())); - mergedData.add(msgNode); - }); - return JacksonUtil.toString(mergedData); - } - -} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/edge/TbMsgPushToEdgeNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/edge/TbMsgPushToEdgeNodeTest.java index 155465ae2e..84deafe7e4 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/edge/TbMsgPushToEdgeNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/edge/TbMsgPushToEdgeNodeTest.java @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.SettableFuture; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentMatcher; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; @@ -28,6 +29,8 @@ import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; @@ -106,4 +109,44 @@ public class TbMsgPushToEdgeNodeTest { verify(edgeEventService).saveAsync(any()); } + + @Test + public void testMiscEventsProcessedAsAttributesUpdated() { + List miscEvents = List.of(DataConstants.CONNECT_EVENT, DataConstants.DISCONNECT_EVENT, + DataConstants.ACTIVITY_EVENT, DataConstants.INACTIVITY_EVENT); + for (String event : miscEvents) { + TbMsgMetaData metaData = new TbMsgMetaData(); + metaData.putValue(DataConstants.SCOPE, DataConstants.SERVER_SCOPE); + testEvent(event, metaData, EdgeEventActionType.ATTRIBUTES_UPDATED, "kv"); + } + } + + @Test + public void testMiscEventsProcessedAsTimeseriesUpdated() { + List miscEvents = List.of(DataConstants.CONNECT_EVENT, DataConstants.DISCONNECT_EVENT, + DataConstants.ACTIVITY_EVENT, DataConstants.INACTIVITY_EVENT); + for (String event : miscEvents) { + testEvent(event, new TbMsgMetaData(), EdgeEventActionType.TIMESERIES_UPDATED, "data"); + } + } + + private void testEvent(String event, TbMsgMetaData metaData, EdgeEventActionType expectedType, String dataKey) { + Mockito.when(ctx.getTenantId()).thenReturn(tenantId); + Mockito.when(ctx.getEdgeService()).thenReturn(edgeService); + Mockito.when(ctx.getEdgeEventService()).thenReturn(edgeEventService); + Mockito.when(ctx.getDbCallbackExecutor()).thenReturn(dbCallbackExecutor); + Mockito.when(edgeEventService.saveAsync(any())).thenReturn(SettableFuture.create()); + + TbMsg msg = TbMsg.newMsg(event, new EdgeId(UUID.randomUUID()), metaData, + TbMsgDataType.JSON, "{\"lastConnectTs\":1}", null, null); + + node.onMsg(ctx, msg); + + ArgumentMatcher eventArgumentMatcher = edgeEvent -> + edgeEvent.getAction().equals(expectedType) + && edgeEvent.getBody().get(dataKey).get("lastConnectTs").asInt() == 1; + verify(edgeEventService).saveAsync(Mockito.argThat(eventArgumentMatcher)); + + Mockito.reset(ctx, edgeEventService); + } } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/AbstractAttributeNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/AbstractAttributeNodeTest.java index 45e4f1c27f..ceef71b4c5 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/AbstractAttributeNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/AbstractAttributeNodeTest.java @@ -219,7 +219,7 @@ public abstract class AbstractAttributeNodeTest { void mockFindDevice(Device device) { when(ctx.getDeviceService()).thenReturn(deviceService); - when(deviceService.findDeviceByIdAsync(any(), eq(device.getId()))).thenReturn(Futures.immediateFuture(device)); + when(deviceService.findDeviceById(any(), eq(device.getId()))).thenReturn(device); } void mockFindAsset(Asset asset) { diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbMsgDeduplicationNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbMsgDeduplicationNodeTest.java index 475a98e2a2..1badecfa92 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbMsgDeduplicationNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbMsgDeduplicationNodeTest.java @@ -174,7 +174,16 @@ public class TbMsgDeduplicationNodeTest { verify(ctx, times(1)).tellFailure(eq(msgToReject), any()); verify(node, times(msgCount + wantedNumberOfTellSelfInvocation + 1)).onMsg(eq(ctx), any()); verify(ctx, times(1)).enqueueForTellNext(newMsgCaptor.capture(), eq(TbRelationTypes.SUCCESS), successCaptor.capture(), failureCaptor.capture()); - Assertions.assertEquals(inputMsgs.get(0), newMsgCaptor.getValue()); + + TbMsg firstMsg = inputMsgs.get(0); + TbMsg actualMsg = newMsgCaptor.getValue(); + // msg ids should be different because we create new msg before enqueueForTellNext + Assertions.assertNotEquals(firstMsg.getId(), actualMsg.getId()); + Assertions.assertEquals(firstMsg.getOriginator(), actualMsg.getOriginator()); + Assertions.assertEquals(firstMsg.getCustomerId(), actualMsg.getCustomerId()); + Assertions.assertEquals(firstMsg.getData(), actualMsg.getData()); + Assertions.assertEquals(firstMsg.getMetaData(), actualMsg.getMetaData()); + Assertions.assertEquals(firstMsg.getType(), actualMsg.getType()); } @Test @@ -213,7 +222,15 @@ public class TbMsgDeduplicationNodeTest { verify(ctx, times(1)).tellFailure(eq(msgToReject), any()); verify(node, times(msgCount + wantedNumberOfTellSelfInvocation + 1)).onMsg(eq(ctx), any()); verify(ctx, times(1)).enqueueForTellNext(newMsgCaptor.capture(), eq(TbRelationTypes.SUCCESS), successCaptor.capture(), failureCaptor.capture()); - Assertions.assertEquals(msgWithLatestTs, newMsgCaptor.getValue()); + + TbMsg actualMsg = newMsgCaptor.getValue(); + // msg ids should be different because we create new msg before enqueueForTellNext + Assertions.assertNotEquals(msgWithLatestTs.getId(), actualMsg.getId()); + Assertions.assertEquals(msgWithLatestTs.getOriginator(), actualMsg.getOriginator()); + Assertions.assertEquals(msgWithLatestTs.getCustomerId(), actualMsg.getCustomerId()); + Assertions.assertEquals(msgWithLatestTs.getData(), actualMsg.getData()); + Assertions.assertEquals(msgWithLatestTs.getMetaData(), actualMsg.getMetaData()); + Assertions.assertEquals(msgWithLatestTs.getType(), actualMsg.getType()); } @Test @@ -350,8 +367,24 @@ public class TbMsgDeduplicationNodeTest { List resultMsgs = newMsgCaptor.getAllValues(); Assertions.assertEquals(2, resultMsgs.size()); - Assertions.assertTrue(resultMsgs.contains(msgWithLatestTsInFirstPack)); - Assertions.assertTrue(resultMsgs.contains(msgWithLatestTsInSecondPack)); + + // verify that newMsg is called but content of messages is the same as in the last msg for the first pack. + TbMsg actualMsg = resultMsgs.get(0); + Assertions.assertNotEquals(msgWithLatestTsInFirstPack.getId(), actualMsg.getId()); + Assertions.assertEquals(msgWithLatestTsInFirstPack.getOriginator(), actualMsg.getOriginator()); + Assertions.assertEquals(msgWithLatestTsInFirstPack.getCustomerId(), actualMsg.getCustomerId()); + Assertions.assertEquals(msgWithLatestTsInFirstPack.getData(), actualMsg.getData()); + Assertions.assertEquals(msgWithLatestTsInFirstPack.getMetaData(), actualMsg.getMetaData()); + Assertions.assertEquals(msgWithLatestTsInFirstPack.getType(), actualMsg.getType()); + + // verify that newMsg is called but content of messages is the same as in the last msg for the second pack. + actualMsg = resultMsgs.get(1); + Assertions.assertNotEquals(msgWithLatestTsInSecondPack.getId(), actualMsg.getId()); + Assertions.assertEquals(msgWithLatestTsInSecondPack.getOriginator(), actualMsg.getOriginator()); + Assertions.assertEquals(msgWithLatestTsInSecondPack.getCustomerId(), actualMsg.getCustomerId()); + Assertions.assertEquals(msgWithLatestTsInSecondPack.getData(), actualMsg.getData()); + Assertions.assertEquals(msgWithLatestTsInSecondPack.getMetaData(), actualMsg.getMetaData()); + Assertions.assertEquals(msgWithLatestTsInSecondPack.getType(), actualMsg.getType()); } private TbMsg getMsgWithLatestTs(List firstMsgPack) { diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index bcf1e8ddd7..7ea553fe5c 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -151,6 +151,13 @@ queue: type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) or aws-sqs (AWS SQS) or pubsub (PubSub) or service-bus (Azure Service Bus) or rabbitmq (RabbitMQ) kafka: bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + ssl: + enabled: "${TB_KAFKA_SSL_ENABLED:false}" + truststore.location: "${TB_KAFKA_SSL_TRUSTSTORE_LOCATION:}" + truststore.password: "${TB_KAFKA_SSL_TRUSTSTORE_PASSWORD:}" + keystore.location: "${TB_KAFKA_SSL_KEYSTORE_LOCATION:}" + keystore.password: "${TB_KAFKA_SSL_KEYSTORE_PASSWORD:}" + key.password: "${TB_KAFKA_SSL_KEY_PASSWORD:}" acks: "${TB_KAFKA_ACKS:all}" retries: "${TB_KAFKA_RETRIES:1}" compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip @@ -160,17 +167,24 @@ queue: max.in.flight.requests.per.connection: "${TB_KAFKA_MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION:5}" buffer.memory: "${TB_BUFFER_MEMORY:33554432}" replication_factor: "${TB_QUEUE_KAFKA_REPLICATION_FACTOR:1}" + max_poll_interval_ms: "${TB_QUEUE_KAFKA_MAX_POLL_INTERVAL_MS:300000}" + max_poll_records: "${TB_QUEUE_KAFKA_MAX_POLL_RECORDS:8192}" + max_partition_fetch_bytes: "${TB_QUEUE_KAFKA_MAX_PARTITION_FETCH_BYTES:16777216}" + fetch_max_bytes: "${TB_QUEUE_KAFKA_FETCH_MAX_BYTES:134217728}" + request.timeout.ms: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + session.timeout.ms: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms use_confluent_cloud: "${TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD:false}" confluent: ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" - other: # In this section you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside - - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms - value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) - - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms - value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) + other-inline: "${TB_QUEUE_KAFKA_OTHER_PROPERTIES:}" # In this section you can specify custom parameters (semicolon separated) for Kafka consumer/producer/admin # Example "metrics.recording.level:INFO;metrics.sample.window.ms:30000" + other: # DEPRECATED. In this section you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside + # - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + # value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) + # - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms + # value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) topic-properties: rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" core: "${TB_QUEUE_KAFKA_CORE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" @@ -270,6 +284,14 @@ service: # Unique id for this service (autogenerated if empty) id: "${TB_SERVICE_ID:}" +# Usage statistics parameters +usage: + stats: + report: + enabled: "${USAGE_STATS_REPORT_ENABLED:true}" + enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" + interval: "${USAGE_STATS_REPORT_INTERVAL:10}" + metrics: # Enable/disable actuator metrics. enabled: "${METRICS_ENABLED:false}" diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 56a8419e76..346ec48eae 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -136,6 +136,13 @@ queue: type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) or aws-sqs (AWS SQS) or pubsub (PubSub) or service-bus (Azure Service Bus) or rabbitmq (RabbitMQ) kafka: bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + ssl: + enabled: "${TB_KAFKA_SSL_ENABLED:false}" + truststore.location: "${TB_KAFKA_SSL_TRUSTSTORE_LOCATION:}" + truststore.password: "${TB_KAFKA_SSL_TRUSTSTORE_PASSWORD:}" + keystore.location: "${TB_KAFKA_SSL_KEYSTORE_LOCATION:}" + keystore.password: "${TB_KAFKA_SSL_KEYSTORE_PASSWORD:}" + key.password: "${TB_KAFKA_SSL_KEY_PASSWORD:}" acks: "${TB_KAFKA_ACKS:all}" retries: "${TB_KAFKA_RETRIES:1}" compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip @@ -145,17 +152,24 @@ queue: max.in.flight.requests.per.connection: "${TB_KAFKA_MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION:5}" buffer.memory: "${TB_BUFFER_MEMORY:33554432}" replication_factor: "${TB_QUEUE_KAFKA_REPLICATION_FACTOR:1}" + max_poll_interval_ms: "${TB_QUEUE_KAFKA_MAX_POLL_INTERVAL_MS:300000}" + max_poll_records: "${TB_QUEUE_KAFKA_MAX_POLL_RECORDS:8192}" + max_partition_fetch_bytes: "${TB_QUEUE_KAFKA_MAX_PARTITION_FETCH_BYTES:16777216}" + fetch_max_bytes: "${TB_QUEUE_KAFKA_FETCH_MAX_BYTES:134217728}" + request.timeout.ms: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + session.timeout.ms: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms use_confluent_cloud: "${TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD:false}" confluent: ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" - other: # In this section you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside - - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms - value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) - - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms - value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) + other-inline: "${TB_QUEUE_KAFKA_OTHER_PROPERTIES:}" # In this section you can specify custom parameters (semicolon separated) for Kafka consumer/producer/admin # Example "metrics.recording.level:INFO;metrics.sample.window.ms:30000" + other: # DEPRECATED. In this section you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside + # - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + # value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) + # - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms + # value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) topic-properties: rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" core: "${TB_QUEUE_KAFKA_CORE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" @@ -255,6 +269,14 @@ service: # Unique id for this service (autogenerated if empty) id: "${TB_SERVICE_ID:}" +# Usage statistics parameters +usage: + stats: + report: + enabled: "${USAGE_STATS_REPORT_ENABLED:true}" + enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" + interval: "${USAGE_STATS_REPORT_INTERVAL:10}" + metrics: # Enable/disable actuator metrics. enabled: "${METRICS_ENABLED:false}" diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 70d7a895fd..4e8167d89d 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -217,6 +217,13 @@ queue: type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) or aws-sqs (AWS SQS) or pubsub (PubSub) or service-bus (Azure Service Bus) or rabbitmq (RabbitMQ) kafka: bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + ssl: + enabled: "${TB_KAFKA_SSL_ENABLED:false}" + truststore.location: "${TB_KAFKA_SSL_TRUSTSTORE_LOCATION:}" + truststore.password: "${TB_KAFKA_SSL_TRUSTSTORE_PASSWORD:}" + keystore.location: "${TB_KAFKA_SSL_KEYSTORE_LOCATION:}" + keystore.password: "${TB_KAFKA_SSL_KEYSTORE_PASSWORD:}" + key.password: "${TB_KAFKA_SSL_KEY_PASSWORD:}" acks: "${TB_KAFKA_ACKS:all}" retries: "${TB_KAFKA_RETRIES:1}" compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip @@ -226,17 +233,24 @@ queue: max.in.flight.requests.per.connection: "${TB_KAFKA_MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION:5}" buffer.memory: "${TB_BUFFER_MEMORY:33554432}" replication_factor: "${TB_QUEUE_KAFKA_REPLICATION_FACTOR:1}" + max_poll_interval_ms: "${TB_QUEUE_KAFKA_MAX_POLL_INTERVAL_MS:300000}" + max_poll_records: "${TB_QUEUE_KAFKA_MAX_POLL_RECORDS:8192}" + max_partition_fetch_bytes: "${TB_QUEUE_KAFKA_MAX_PARTITION_FETCH_BYTES:16777216}" + fetch_max_bytes: "${TB_QUEUE_KAFKA_FETCH_MAX_BYTES:134217728}" + request.timeout.ms: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + session.timeout.ms: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms use_confluent_cloud: "${TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD:false}" confluent: ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" - other: # In this section you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside - - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms - value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) - - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms - value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) + other-inline: "${TB_QUEUE_KAFKA_OTHER_PROPERTIES:}" # In this section you can specify custom parameters (semicolon separated) for Kafka consumer/producer/admin # Example "metrics.recording.level:INFO;metrics.sample.window.ms:30000" + other: # DEPRECATED. In this section you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside + # - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + # value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) + # - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms + # value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) topic-properties: rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" core: "${TB_QUEUE_KAFKA_CORE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" @@ -337,6 +351,14 @@ service: # Unique id for this service (autogenerated if empty) id: "${TB_SERVICE_ID:}" +# Usage statistics parameters +usage: + stats: + report: + enabled: "${USAGE_STATS_REPORT_ENABLED:true}" + enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" + interval: "${USAGE_STATS_REPORT_INTERVAL:10}" + metrics: # Enable/disable actuator metrics. enabled: "${METRICS_ENABLED:false}" diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index 99907e797e..1e0b1ebcd4 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -114,7 +114,7 @@ transport: # Server SSL credentials credentials: # Server credentials type (PEM - pem certificate file; KEYSTORE - java keystore) - type: "${MQTT_SSL_CREDENTIALS_TYPE:PEM}" + type: "${MQTT_SSL_CREDENTIALS_TYPE:PEM}" # PEM server credentials pem: # Path to the server certificate file (holds server certificate or certificate chain, may include server private key) @@ -166,6 +166,13 @@ queue: type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) or aws-sqs (AWS SQS) or pubsub (PubSub) or service-bus (Azure Service Bus) or rabbitmq (RabbitMQ) kafka: bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + ssl: + enabled: "${TB_KAFKA_SSL_ENABLED:false}" + truststore.location: "${TB_KAFKA_SSL_TRUSTSTORE_LOCATION:}" + truststore.password: "${TB_KAFKA_SSL_TRUSTSTORE_PASSWORD:}" + keystore.location: "${TB_KAFKA_SSL_KEYSTORE_LOCATION:}" + keystore.password: "${TB_KAFKA_SSL_KEYSTORE_PASSWORD:}" + key.password: "${TB_KAFKA_SSL_KEY_PASSWORD:}" acks: "${TB_KAFKA_ACKS:all}" retries: "${TB_KAFKA_RETRIES:1}" compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip @@ -175,17 +182,24 @@ queue: max.in.flight.requests.per.connection: "${TB_KAFKA_MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION:5}" buffer.memory: "${TB_BUFFER_MEMORY:33554432}" replication_factor: "${TB_QUEUE_KAFKA_REPLICATION_FACTOR:1}" + max_poll_interval_ms: "${TB_QUEUE_KAFKA_MAX_POLL_INTERVAL_MS:300000}" + max_poll_records: "${TB_QUEUE_KAFKA_MAX_POLL_RECORDS:8192}" + max_partition_fetch_bytes: "${TB_QUEUE_KAFKA_MAX_PARTITION_FETCH_BYTES:16777216}" + fetch_max_bytes: "${TB_QUEUE_KAFKA_FETCH_MAX_BYTES:134217728}" + request.timeout.ms: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + session.timeout.ms: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms use_confluent_cloud: "${TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD:false}" confluent: ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" - other: # In this section you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside - - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms - value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) - - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms - value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) + other-inline: "${TB_QUEUE_KAFKA_OTHER_PROPERTIES:}" # In this section you can specify custom parameters (semicolon separated) for Kafka consumer/producer/admin # Example "metrics.recording.level:INFO;metrics.sample.window.ms:30000" + other: # DEPRECATED. In this section you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside + # - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + # value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) + # - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms + # value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) topic-properties: rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" core: "${TB_QUEUE_KAFKA_CORE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" @@ -285,6 +299,14 @@ service: # Unique id for this service (autogenerated if empty) id: "${TB_SERVICE_ID:}" +# Usage statistics parameters +usage: + stats: + report: + enabled: "${USAGE_STATS_REPORT_ENABLED:true}" + enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" + interval: "${USAGE_STATS_REPORT_INTERVAL:10}" + metrics: # Enable/disable actuator metrics. enabled: "${METRICS_ENABLED:false}" diff --git a/transport/snmp/src/main/resources/tb-snmp-transport.yml b/transport/snmp/src/main/resources/tb-snmp-transport.yml index 16bfb6397a..9f086bcbc5 100644 --- a/transport/snmp/src/main/resources/tb-snmp-transport.yml +++ b/transport/snmp/src/main/resources/tb-snmp-transport.yml @@ -112,6 +112,13 @@ queue: type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) or aws-sqs (AWS SQS) or pubsub (PubSub) or service-bus (Azure Service Bus) or rabbitmq (RabbitMQ) kafka: bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + ssl: + enabled: "${TB_KAFKA_SSL_ENABLED:false}" + truststore.location: "${TB_KAFKA_SSL_TRUSTSTORE_LOCATION:}" + truststore.password: "${TB_KAFKA_SSL_TRUSTSTORE_PASSWORD:}" + keystore.location: "${TB_KAFKA_SSL_KEYSTORE_LOCATION:}" + keystore.password: "${TB_KAFKA_SSL_KEYSTORE_PASSWORD:}" + key.password: "${TB_KAFKA_SSL_KEY_PASSWORD:}" acks: "${TB_KAFKA_ACKS:all}" retries: "${TB_KAFKA_RETRIES:1}" compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip @@ -121,17 +128,24 @@ queue: max.in.flight.requests.per.connection: "${TB_KAFKA_MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION:5}" buffer.memory: "${TB_BUFFER_MEMORY:33554432}" replication_factor: "${TB_QUEUE_KAFKA_REPLICATION_FACTOR:1}" + max_poll_interval_ms: "${TB_QUEUE_KAFKA_MAX_POLL_INTERVAL_MS:300000}" + max_poll_records: "${TB_QUEUE_KAFKA_MAX_POLL_RECORDS:8192}" + max_partition_fetch_bytes: "${TB_QUEUE_KAFKA_MAX_PARTITION_FETCH_BYTES:16777216}" + fetch_max_bytes: "${TB_QUEUE_KAFKA_FETCH_MAX_BYTES:134217728}" + request.timeout.ms: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + session.timeout.ms: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms use_confluent_cloud: "${TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD:false}" confluent: ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" - other: # In this section you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside - - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms - value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) - - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms - value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) + other-inline: "${TB_QUEUE_KAFKA_OTHER_PROPERTIES:}" # In this section you can specify custom parameters (semicolon separated) for Kafka consumer/producer/admin # Example "metrics.recording.level:INFO;metrics.sample.window.ms:30000" + other: # DEPRECATED. In this section you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside + # - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + # value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) + # - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms + # value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) topic-properties: rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" core: "${TB_QUEUE_KAFKA_CORE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" @@ -235,6 +249,14 @@ service: # Unique id for this service (autogenerated if empty) id: "${TB_SERVICE_ID:}" +# Usage statistics parameters +usage: + stats: + report: + enabled: "${USAGE_STATS_REPORT_ENABLED:true}" + enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" + interval: "${USAGE_STATS_REPORT_INTERVAL:10}" + metrics: # Enable/disable actuator metrics. enabled: "${METRICS_ENABLED:false}" diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 0c4bcf9b6d..aa10ec6d8a 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -31,6 +31,7 @@ "@flowjs/flow.js": "^2.14.1", "@flowjs/ngx-flow": "~0.6.0", "@geoman-io/leaflet-geoman-free": "^2.13.0", + "@iplab/ngx-color-picker": "^15.0.2", "@juggle/resize-observer": "^3.4.0", "@mat-datetimepicker/core": "~11.0.3", "@material-ui/core": "4.12.3", @@ -73,7 +74,6 @@ "moment": "^2.29.4", "moment-timezone": "^0.5.42", "ngx-clipboard": "^15.1.0", - "ngx-color-picker": "^14.0.0", "ngx-daterangepicker-material": "^6.0.4", "ngx-drag-drop": "^15.0.1", "ngx-flowchart": "https://github.com/thingsboard/ngx-flowchart.git#release/2.0.0", @@ -97,7 +97,7 @@ "screenfull": "^6.0.2", "split.js": "^1.6.5", "systemjs": "6.11.0", - "tinycolor2": "~1.4.2", + "tinycolor2": "^1.6.0", "tinymce": "~5.10.7", "tooltipster": "^4.2.8", "ts-transformer-keys": "^0.4.4", diff --git a/ui-ngx/src/app/core/auth/auth.service.ts b/ui-ngx/src/app/core/auth/auth.service.ts index 311de3d7a0..d1be94fdb6 100644 --- a/ui-ngx/src/app/core/auth/auth.service.ts +++ b/ui-ngx/src/app/core/auth/auth.service.ts @@ -297,16 +297,6 @@ export class AuthService { } else if (authState.authUser.isPublic) { result = this.router.parseUrl(`dashboard/${authState.lastPublicDashboardId}`); } - } else if (authState.authUser.authority === Authority.SYS_ADMIN) { - this.adminService.checkUpdates().subscribe((updateMessage) => { - if (updateMessage && updateMessage.updateAvailable) { - this.store.dispatch(new ActionNotificationShow( - {message: updateMessage.message, - type: 'info', - verticalPosition: 'bottom', - horizontalPosition: 'right'})); - } - }); } } } else { diff --git a/ui-ngx/src/app/core/http/admin.service.ts b/ui-ngx/src/app/core/http/admin.service.ts index 6874d21d74..cd83c3c8c9 100644 --- a/ui-ngx/src/app/core/http/admin.service.ts +++ b/ui-ngx/src/app/core/http/admin.service.ts @@ -21,6 +21,7 @@ import { HttpClient } from '@angular/common/http'; import { AdminSettings, AutoCommitSettings, + FeaturesInfo, JwtSettings, MailServerSettings, RepositorySettings, @@ -131,4 +132,8 @@ export class AdminService { public checkUpdates(config?: RequestConfig): Observable { return this.http.get(`/api/admin/updates`, defaultHttpOptionsFromConfig(config)); } + + public getFeaturesInfo(config?: RequestConfig): Observable { + return this.http.get('/api/admin/featuresInfo', defaultHttpOptionsFromConfig(config)); + } } diff --git a/ui-ngx/src/app/core/http/user-settings.service.ts b/ui-ngx/src/app/core/http/user-settings.service.ts index 8aed9d1345..55ce524750 100644 --- a/ui-ngx/src/app/core/http/user-settings.service.ts +++ b/ui-ngx/src/app/core/http/user-settings.service.ts @@ -16,10 +16,16 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { initialUserSettings, UserSettings } from '@shared/models/user-settings.models'; +import { + DocumentationLink, DocumentationLinks, + GettingStarted, + initialUserSettings, + UserSettings, + UserSettingsType +} from '@shared/models/user-settings.models'; import { map } from 'rxjs/operators'; import { HttpClient } from '@angular/common/http'; -import { defaultHttpOptionsFromConfig } from '@core/http/http-utils'; +import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; @Injectable({ providedIn: 'root' @@ -51,4 +57,24 @@ export class UserSettingsService { defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true})); } + public getDocumentationLinks(config?: RequestConfig): Observable { + return this.http.get(`/api/user/settings/${UserSettingsType.DOC_LINKS}`, + defaultHttpOptionsFromConfig(config)); + } + + public updateDocumentationLinks(documentationLinks: DocumentationLinks, config?: RequestConfig): Observable { + return this.http.put(`/api/user/settings/${UserSettingsType.DOC_LINKS}`, documentationLinks, + defaultHttpOptionsFromConfig(config)); + } + + public getGettingStarted(config?: RequestConfig): Observable { + return this.http.get(`/api/user/settings/${UserSettingsType.GETTING_STARTED}`, + defaultHttpOptionsFromConfig(config)); + } + + public updateGettingStarted(gettingStarted: GettingStarted, config?: RequestConfig): Observable { + return this.http.put(`/api/user/settings/${UserSettingsType.GETTING_STARTED}`, gettingStarted, + defaultHttpOptionsFromConfig(config)); + } + } diff --git a/ui-ngx/src/app/core/services/dashboard-utils.service.ts b/ui-ngx/src/app/core/services/dashboard-utils.service.ts index 394e55ff4c..a56b8f1708 100644 --- a/ui-ngx/src/app/core/services/dashboard-utils.service.ts +++ b/ui-ngx/src/app/core/services/dashboard-utils.service.ts @@ -238,6 +238,7 @@ export class DashboardUtilsService { backgroundColor: '#eeeeee', columns: 24, margin: 10, + outerMargin: true, backgroundSizeMode: '100%' }; } @@ -282,6 +283,7 @@ export class DashboardUtilsService { layout.gridSettings.margin = layout.gridSettings.margins[0]; delete layout.gridSettings.margins; } + layout.gridSettings.outerMargin = isDefined(layout.gridSettings.outerMargin) ? layout.gridSettings.outerMargin : true; layout.gridSettings.margin = isDefined(layout.gridSettings.margin) ? layout.gridSettings.margin : 10; } @@ -604,6 +606,47 @@ export class DashboardUtilsService { delete entityAlias.entityType; delete entityAlias.entityFilter; } + entityAlias = this.validateAndUpdateEntityAliasSingleTypeFilters(entityAlias); + return entityAlias; + } + + private validateAndUpdateEntityAliasSingleTypeFilters(entityAlias: EntityAlias): EntityAlias { + if (entityAlias.filter.type === AliasFilterType.deviceType) { + if (entityAlias.filter.deviceType) { + if (!entityAlias.filter.deviceTypes) { + entityAlias.filter.deviceTypes = []; + } + entityAlias.filter.deviceTypes.push(entityAlias.filter.deviceType); + delete entityAlias.filter.deviceType; + } + } + if (entityAlias.filter.type === AliasFilterType.assetType) { + if (entityAlias.filter.assetType) { + if (!entityAlias.filter.assetTypes) { + entityAlias.filter.assetTypes = []; + } + entityAlias.filter.assetTypes.push(entityAlias.filter.assetType); + delete entityAlias.filter.assetType; + } + } + if (entityAlias.filter.type === AliasFilterType.entityViewType) { + if (entityAlias.filter.entityViewType) { + if (!entityAlias.filter.entityViewTypes) { + entityAlias.filter.entityViewTypes = []; + } + entityAlias.filter.entityViewTypes.push(entityAlias.filter.entityViewType); + delete entityAlias.filter.entityViewType; + } + } + if (entityAlias.filter.type === AliasFilterType.edgeType) { + if (entityAlias.filter.edgeType) { + if (!entityAlias.filter.edgeTypes) { + entityAlias.filter.edgeTypes = []; + } + entityAlias.filter.edgeTypes.push(entityAlias.filter.edgeType); + delete entityAlias.filter.edgeType; + } + } return entityAlias; } diff --git a/ui-ngx/src/app/core/services/script/node-script-test.service.ts b/ui-ngx/src/app/core/services/script/node-script-test.service.ts index bf119c2d46..1a33275111 100644 --- a/ui-ngx/src/app/core/services/script/node-script-test.service.ts +++ b/ui-ngx/src/app/core/services/script/node-script-test.service.ts @@ -46,10 +46,14 @@ export class NodeScriptTestService { let msgType: string; if (debugIn) { if (debugIn.data) { - msg = JSON.parse(debugIn.data); + try { + msg = JSON.parse(debugIn.data); + } catch (e) {} } if (debugIn.metadata) { - metadata = JSON.parse(debugIn.metadata); + try { + metadata = JSON.parse(debugIn.metadata); + } catch (e) {} } msgType = debugIn.msgType; } diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html index f01c550444..fb84ca4481 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html @@ -22,7 +22,7 @@ (focusin)="onFocus()" [matAutocomplete]="userAutocomplete"> search - @@ -41,10 +41,12 @@ - - - {{ translate.get('user.no-users-matching', {entity: searchText}) | async }} - + +
+ + {{ translate.get('user.no-users-matching', {entity: searchText}) | async }} + +
diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html index d7e3366a35..8618592ae0 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
- + diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html index 85bdf96ea3..0db5bd9584 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+

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

diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-table-config.ts b/ui-ngx/src/app/modules/home/components/alarm/alarm-table-config.ts index 9b67da4b71..e60fe212f3 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-table-config.ts @@ -120,18 +120,11 @@ export class AlarmTableConfig extends EntityTableConfig color: alarmSeverityColors.get(entity.severity) }))); this.columns.push( - new EntityTableColumn('assignee', 'alarm.assignee', '200px', + new EntityTableColumn('assignee', 'alarm.assignee', '240px', (entity) => { return this.getAssigneeTemplate(entity) }, - (entity) => { - return { - display: 'flex', - justifyContent: 'start', - alignItems: 'center', - height: 'inherit' - } - }, + () => ({}), false, () => ({}), (entity) => undefined, @@ -203,8 +196,10 @@ export class AlarmTableConfig extends EntityTableConfig ${this.getUserDisplayName(entity)} ` : - `account_circle - ${this.translate.instant('alarm.unassigned')}` + ` + account_circle + ${this.translate.instant('alarm.unassigned')} + ` } ` } diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.scss b/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.scss index 8de701f3e3..09a2c62c4c 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.scss +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.scss @@ -21,10 +21,10 @@ } } .assignee-cell { - display: flex; - justify-content: flex-start; - align-items: center; + line-height: normal; .assigned-container { + display: inline-block; + vertical-align: baseline; max-width: 180px; overflow: hidden; white-space: nowrap; @@ -44,14 +44,19 @@ font-weight: 700; } } - .material-icons.unassigned-icon { - width: 28px; - height: 28px; - font-size: 28px; - margin-right: 8px; - color: rgba(0, 0, 0, 0.38); - overflow: visible; + .unassigned-container { + display: inline-flex; + align-items: center; + .material-icons.unassigned-icon { + width: 28px; + height: 28px; + font-size: 28px; + margin-right: 8px; + color: rgba(0, 0, 0, 0.38); + overflow: visible; + } } + } } } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html index 9c1732791c..2090e5f42c 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html @@ -132,6 +132,9 @@ {{ 'dashboard.max-margin-message' | translate }} + + {{ 'dashboard.apply-outer-margin' | translate }} + {{ 'dashboard.autofill-height' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts index f1407d6aaf..4844211355 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts @@ -156,6 +156,7 @@ export class DashboardSettingsDialogComponent extends DialogComponent
{ + this.addEntity(null); + }, 0); + } + } } ngAfterViewInit() { diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.ts index 7ed0a8b3c9..9f4927402e 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.ts @@ -84,47 +84,63 @@ export class EntityFilterViewComponent implements ControlValueAccessor { this.filterDisplayValue = this.translate.instant('alias.filter-type-state-entity-description'); break; case AliasFilterType.assetType: - const assetType = this.filter.assetType; + const assetTypesQuoted = []; + this.filter.assetTypes.forEach((filterAssetType) => { + assetTypesQuoted.push(`'${filterAssetType}'`); + }); + const assetTypes = assetTypesQuoted.join(', '); prefix = this.filter.assetNameFilter; if (prefix && prefix.length) { this.filterDisplayValue = this.translate.instant('alias.filter-type-asset-type-and-name-description', - {assetType, prefix}); + {assetTypes, prefix}); } else { this.filterDisplayValue = this.translate.instant('alias.filter-type-asset-type-description', - {assetType}); + {assetTypes}); } break; case AliasFilterType.deviceType: - const deviceType = this.filter.deviceType; + const deviceTypesQuoted = []; + this.filter.deviceTypes.forEach((filterDeviceType) => { + deviceTypesQuoted.push(`'${filterDeviceType}'`); + }); + const deviceTypes = deviceTypesQuoted.join(', '); prefix = this.filter.deviceNameFilter; if (prefix && prefix.length) { this.filterDisplayValue = this.translate.instant('alias.filter-type-device-type-and-name-description', - {deviceType, prefix}); + {deviceTypes, prefix}); } else { this.filterDisplayValue = this.translate.instant('alias.filter-type-device-type-description', - {deviceType}); + {deviceTypes}); } break; case AliasFilterType.edgeType: - const edgeType = this.filter.edgeType; + const edgeTypesQuoted = []; + this.filter.edgeTypes.forEach((filterEdgeType) => { + edgeTypesQuoted.push(`'${filterEdgeType}'`); + }); + const edgeTypes = edgeTypesQuoted.join(', '); prefix = this.filter.edgeNameFilter; if (prefix && prefix.length) { this.filterDisplayValue = this.translate.instant('alias.filter-type-edge-type-and-name-description', - {edgeType, prefix}); + {edgeTypes, prefix}); } else { this.filterDisplayValue = this.translate.instant('alias.filter-type-edge-type-description', - {edgeType}); + {edgeTypes}); } break; case AliasFilterType.entityViewType: - const entityView = this.filter.entityViewType; + const entityViewTypesQuoted = []; + this.filter.entityViewTypes.forEach((entityViewType) => { + entityViewTypesQuoted.push(`'${entityViewType}'`); + }); + const entityViewTypes = entityViewTypesQuoted.join(', '); prefix = this.filter.entityViewNameFilter; if (prefix && prefix.length) { this.filterDisplayValue = this.translate.instant('alias.filter-type-entity-view-type-and-name-description', - {entityView, prefix}); + {entityViewTypes, prefix}); } else { this.filterDisplayValue = this.translate.instant('alias.filter-type-entity-view-type-description', - {entityView}); + {entityViewTypes}); } break; case AliasFilterType.relationsQuery: diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html index c28dabf959..9cde6f4431 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html @@ -83,10 +83,10 @@
- - + + asset.name-starts-with - - + + device.name-starts-with - - + + entity-view.name-starts-with - - + + edge.name-starts-with diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.ts index 92fe48ee44..32a0377d42 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.ts @@ -136,25 +136,25 @@ export class EntityFilterComponent implements ControlValueAccessor, OnInit { break; case AliasFilterType.assetType: this.filterFormGroup = this.fb.group({ - assetType: [filter ? filter.assetType : null, [Validators.required]], + assetTypes: [filter ? filter.assetTypes : null, [Validators.required]], assetNameFilter: [filter ? filter.assetNameFilter : '', []], }); break; case AliasFilterType.deviceType: this.filterFormGroup = this.fb.group({ - deviceType: [filter ? filter.deviceType : null, [Validators.required]], + deviceTypes: [filter ? filter.deviceTypes : null, [Validators.required]], deviceNameFilter: [filter ? filter.deviceNameFilter : '', []], }); break; case AliasFilterType.edgeType: this.filterFormGroup = this.fb.group({ - edgeType: [filter ? filter.edgeType : null, [Validators.required]], + edgeTypes: [filter ? filter.edgeTypes : null, [Validators.required]], edgeNameFilter: [filter ? filter.edgeNameFilter : '', []], }); break; case AliasFilterType.entityViewType: this.filterFormGroup = this.fb.group({ - entityViewType: [filter ? filter.entityViewType : null, [Validators.required]], + entityViewTypes: [filter ? filter.entityViewTypes : null, [Validators.required]], entityViewNameFilter: [filter ? filter.entityViewNameFilter : '', []], }); break; diff --git a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html index 2d24bc479f..69f72c8aaf 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html @@ -69,7 +69,7 @@ style="min-width: 48px;" type="button" (click)="duplicateFilter($index)" - matTooltip="{{ 'filter.duplicate-filter' | translate }}" + matTooltip="{{ 'filter.duplicate-filter-action' | translate }}" matTooltipPosition="above"> content_copy diff --git a/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html index 096cecd91a..0a0a5e3cdb 100644 --- a/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html +++ b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html @@ -25,13 +25,13 @@
-
+
- +
diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html index cd711f9e12..afbaa924ac 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html @@ -27,36 +27,80 @@ {{ 'device-profile.provision-strategy-required' | translate }}
-
- - device-profile.provision-device-key - - - - {{ 'device-profile.provision-device-key-required' | translate }} - - - - device-profile.provision-device-secret - - - - {{ 'device-profile.provision-device-secret-required' | translate }} - - -
+
+ + + + + + + +
+ + +
+ + {{ 'device-profile.provision-strategy-x509.allow-create-new-devices' | translate }} + +
+ + device-profile.provision-strategy-x509.certificate-value + + + {{ 'device-profile.provision-strategy-x509.certificate-value-required' | translate }} + + + + device-profile.provision-strategy-x509.cn-regex-variable + + + + {{ 'device-profile.provision-strategy-x509.cn-regex-variable-required' | translate }} + + device-profile.provision-strategy-x509.cn-regex-variable-hint + +
+ +
+ + device-profile.provision-device-key + + + + {{ 'device-profile.provision-device-key-required' | translate }} + + + + device-profile.provision-device-secret + + + + {{ 'device-profile.provision-device-secret-required' | translate }} + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts index 872571dc20..3387d71704 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts @@ -32,7 +32,7 @@ import { DeviceProvisionType, deviceProvisionTypeTranslationMap } from '@shared/models/device.models'; -import { generateSecret, isDefinedAndNotNull } from '@core/utils'; +import { generateSecret, isBoolean, isDefinedAndNotNull } from '@core/utils'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -86,14 +86,36 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu this.provisionConfigurationFormGroup = this.fb.group({ type: [DeviceProvisionType.DISABLED, Validators.required], provisionDeviceSecret: [{value: null, disabled: true}, Validators.required], - provisionDeviceKey: [{value: null, disabled: true}, Validators.required] + provisionDeviceKey: [{value: null, disabled: true}, Validators.required], + certificateValue: [{value: null, disabled: true}, Validators.required], + certificateRegExPattern: [{value: null, disabled: true}, Validators.required], + allowCreateNewDevicesByX509Certificate: [{value: null, disabled: true}, Validators.required] }); this.provisionConfigurationFormGroup.get('type').valueChanges.subscribe((type) => { if (type === DeviceProvisionType.DISABLED) { - this.provisionConfigurationFormGroup.get('provisionDeviceSecret').disable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('provisionDeviceSecret').patchValue(null, {emitEvent: false}); - this.provisionConfigurationFormGroup.get('provisionDeviceKey').disable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('provisionDeviceKey').patchValue(null); + for (const field in this.provisionConfigurationFormGroup.controls) { + if (field !== 'type') { + const control = this.provisionConfigurationFormGroup.get(field); + control.disable({emitEvent: false}); + control.patchValue(null, {emitEvent: false}); + } + } + } else if (type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + const certificateValue: string = this.provisionConfigurationFormGroup.get('certificateValue').value; + if (!certificateValue || !certificateValue.length) { + this.provisionConfigurationFormGroup.get('certificateValue').patchValue(null, {emitEvent: false}); + } + const certificateRegExPattern: string = this.provisionConfigurationFormGroup.get('certificateRegExPattern').value; + if (!certificateRegExPattern || !certificateRegExPattern.length) { + this.provisionConfigurationFormGroup.get('certificateRegExPattern').patchValue('(.*)', {emitEvent: false}); + } + const allowCreateNewDevicesByX509Certificate: boolean | null = this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').value; + if (!isBoolean(allowCreateNewDevicesByX509Certificate)) { + this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').patchValue(true, {emitEvent: false}); + } + this.provisionConfigurationFormGroup.get('certificateValue').enable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('certificateRegExPattern').enable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').enable({emitEvent: false}); } else { const provisionDeviceSecret: string = this.provisionConfigurationFormGroup.get('provisionDeviceSecret').value; if (!provisionDeviceSecret || !provisionDeviceSecret.length) { @@ -120,16 +142,19 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu } writeValue(value: DeviceProvisionConfiguration | null): void { - if (isDefinedAndNotNull(value)){ + if (isDefinedAndNotNull(value)) { + if (value.type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + value.certificateValue = value.provisionDeviceSecret; + } this.provisionConfigurationFormGroup.patchValue(value, {emitEvent: false}); } else { this.provisionConfigurationFormGroup.patchValue({type: DeviceProvisionType.DISABLED}); } } - setDisabledState(isDisabled: boolean){ + setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; - if (this.disabled){ + if (this.disabled) { this.provisionConfigurationFormGroup.disable({emitEvent: false}); } else { if (this.provisionConfigurationFormGroup.get('type').value !== DeviceProvisionType.DISABLED) { @@ -150,8 +175,12 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu private updateModel(): void { let deviceProvisionConfiguration: DeviceProvisionConfiguration = null; + this.resetFormControls(this.provisionConfigurationFormGroup.value); if (this.provisionConfigurationFormGroup.valid) { deviceProvisionConfiguration = this.provisionConfigurationFormGroup.getRawValue(); + if (deviceProvisionConfiguration.type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + deviceProvisionConfiguration.provisionDeviceSecret = deviceProvisionConfiguration.certificateValue; + } } this.propagateChange(deviceProvisionConfiguration); } @@ -166,4 +195,15 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu horizontalPosition: 'right' })); } + + private resetFormControls(value: DeviceProvisionConfiguration) { + if (value.type === DeviceProvisionType.CHECK_PRE_PROVISIONED_DEVICES || value.type === DeviceProvisionType.ALLOW_CREATE_NEW_DEVICES) { + this.provisionConfigurationFormGroup.get('certificateValue').reset({value: null, disabled: true}, {emitEvent: false}); + this.provisionConfigurationFormGroup.get('certificateRegExPattern').reset({value: null, disabled: true}, {emitEvent: false}); + this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').reset({value: null, disabled: true}, {emitEvent: false}); + } else if (value.type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + this.provisionConfigurationFormGroup.get('provisionDeviceSecret').reset({value: null, disabled: true}, {emitEvent: false}); + this.provisionConfigurationFormGroup.get('provisionDeviceKey').reset({value: null, disabled: true}, {emitEvent: false}); + } + } } diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts index 9adcf9c168..db6539318d 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts @@ -104,7 +104,9 @@ export class DeviceProfileComponent extends EntityComponent { const deviceProvisionConfiguration: DeviceProvisionConfiguration = { type: entity?.provisionType ? entity.provisionType : DeviceProvisionType.DISABLED, provisionDeviceKey: entity?.provisionDeviceKey, - provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret + provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret, + certificateRegExPattern: entity?.profileData?.provisionConfiguration?.certificateRegExPattern, + allowCreateNewDevicesByX509Certificate: entity?.profileData?.provisionConfiguration?.allowCreateNewDevicesByX509Certificate }; const form = this.fb.group( { @@ -185,7 +187,9 @@ export class DeviceProfileComponent extends EntityComponent { const deviceProvisionConfiguration: DeviceProvisionConfiguration = { type: entity?.provisionType ? entity.provisionType : DeviceProvisionType.DISABLED, provisionDeviceKey: entity?.provisionDeviceKey, - provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret + provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret, + certificateRegExPattern: entity?.profileData?.provisionConfiguration?.certificateRegExPattern, + allowCreateNewDevicesByX509Certificate: entity?.profileData?.provisionConfiguration?.allowCreateNewDevicesByX509Certificate }; this.entityForm.patchValue({name: entity.name}); this.entityForm.patchValue({type: entity.type}, {emitEvent: false}); diff --git a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html index 17feca514b..45cf2040b2 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html @@ -169,6 +169,6 @@ {{ 'device-profile.mqtt-send-ack-on-validation-exception' | translate }} -
+
diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html index 81a1206800..0bbb6a5926 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html @@ -410,6 +410,9 @@ {{ 'tenant-profile.too-small-value-one' | translate}} + + {{ 'tenant-profile.queue-size-is-limited-by-system-configuration' | translate}} +
@@ -480,6 +483,9 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts index e070d760ad..cbb5c7444e 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts @@ -73,6 +73,7 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA tenantEntityExportRateLimit: [null, []], tenantEntityImportRateLimit: [null, []], tenantNotificationRequestsRateLimit: [null, []], + tenantNotificationRequestsPerRuleRateLimit: [null, []], maxTransportMessages: [null, [Validators.required, Validators.min(0)]], maxTransportDataPoints: [null, [Validators.required, Validators.min(0)]], maxREExecutions: [null, [Validators.required, Validators.min(0)]], diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts index 09ae3f5139..26326e9f63 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts @@ -34,7 +34,8 @@ export enum RateLimitsType { CASSANDRA_QUERY_TENANT_RATE_LIMITS_CONFIGURATION = 'CASSANDRA_QUERY_TENANT_RATE_LIMITS_CONFIGURATION', TENANT_ENTITY_EXPORT_RATE_LIMIT = 'TENANT_ENTITY_EXPORT_RATE_LIMIT', TENANT_ENTITY_IMPORT_RATE_LIMIT = 'TENANT_ENTITY_IMPORT_RATE_LIMIT', - TENANT_NOTIFICATION_REQUEST_RATE_LIMIT = 'TENANT_NOTIFICATION_REQUEST_RATE_LIMIT' + TENANT_NOTIFICATION_REQUEST_RATE_LIMIT = 'TENANT_NOTIFICATION_REQUEST_RATE_LIMIT', + TENANT_NOTIFICATION_REQUESTS_PER_RULE_RATE_LIMIT = 'TENANT_NOTIFICATION_REQUESTS_PER_RULE_RATE_LIMIT' } export const rateLimitsLabelTranslationMap = new Map( @@ -52,6 +53,7 @@ export const rateLimitsLabelTranslationMap = new Map( [RateLimitsType.TENANT_ENTITY_EXPORT_RATE_LIMIT, 'tenant-profile.tenant-entity-export-rate-limit'], [RateLimitsType.TENANT_ENTITY_IMPORT_RATE_LIMIT, 'tenant-profile.tenant-entity-import-rate-limit'], [RateLimitsType.TENANT_NOTIFICATION_REQUEST_RATE_LIMIT, 'tenant-profile.tenant-notification-request-rate-limit'], + [RateLimitsType.TENANT_NOTIFICATION_REQUESTS_PER_RULE_RATE_LIMIT, 'tenant-profile.tenant-notification-requests-per-rule-rate-limit'], ] ); @@ -70,6 +72,7 @@ export const rateLimitsDialogTitleTranslationMap = new Map> = export const HOME_COMPONENTS_MODULE_TOKEN: InjectionToken> = new InjectionToken>('HOME_COMPONENTS_MODULE_TOKEN'); +export const WIDGET_COMPONENTS_MODULE_TOKEN: InjectionToken> = + new InjectionToken>('WIDGET_COMPONENTS_MODULE_TOKEN'); + export const COMPLEX_FILTER_PREDICATE_DIALOG_COMPONENT_TOKEN: InjectionToken> = new InjectionToken>('COMPLEX_FILTER_PREDICATE_DIALOG_COMPONENT_TOKEN'); diff --git a/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.ts b/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.ts index 50edca8ce7..6aab7d147a 100644 --- a/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.ts @@ -120,32 +120,34 @@ export class ComplexVersionCreateComponent extends PageComponent implements OnIn this.popoverComponent.updatePosition(); } - this.versionCreateResultSubscription = this.versionCreateResult$.subscribe((result) => { - let message: string; - if (!result.error) { - if (result.done && !result.added && !result.modified && !result.removed) { - message = this.translate.instant('version-control.nothing-to-commit'); + this.versionCreateResultSubscription = this.versionCreateResult$.subscribe({ + next: (result) => { + let message: string; + if (!result.error) { + if (result.done && !result.added && !result.modified && !result.removed) { + message = this.translate.instant('version-control.nothing-to-commit'); + } else { + message = this.translate.instant('version-control.version-create-result', + {added: result.added, modified: result.modified, removed: result.removed}); + } } else { - message = this.translate.instant('version-control.version-create-result', - {added: result.added, modified: result.modified, removed: result.removed}); - } - } else { message = result.error; - } - this.resultMessage = this.sanitizer.bypassSecurityTrustHtml(message); - this.versionCreateResult = result; - this.versionCreateBranch = request.branch; - this.cd.detectChanges(); - if (this.popoverComponent) { - this.popoverComponent.updatePosition(); - } - }, - (error) => { - this.hasError = true; - this.resultMessage = this.sanitizer.bypassSecurityTrustHtml(parseHttpErrorMessage(error, this.translate).message); - this.cd.detectChanges(); - if (this.popoverComponent) { - this.popoverComponent.updatePosition(); + } + this.resultMessage = this.sanitizer.bypassSecurityTrustHtml(message); + this.versionCreateResult = result; + this.versionCreateBranch = request.branch; + this.cd.detectChanges(); + if (this.popoverComponent) { + this.popoverComponent.updatePosition(); + } + }, + error: (error) => { + this.hasError = true; + this.resultMessage = this.sanitizer.bypassSecurityTrustHtml(parseHttpErrorMessage(error, this.translate).message); + this.cd.detectChanges(); + if (this.popoverComponent) { + this.popoverComponent.updatePosition(); + } } }); } diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.ts b/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.ts index e87d712534..b41e51c5c2 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.ts @@ -18,12 +18,12 @@ import { Component, forwardRef, Input, OnInit } from '@angular/core'; import { AbstractControl, ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, UntypedFormArray, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, - NG_VALIDATORS, - NG_VALUE_ACCESSOR, Validator, Validators } from '@angular/forms'; @@ -104,6 +104,9 @@ export class EntityTypesVersionCreateComponent extends PageComponent implements this.entityTypesVersionCreateFormGroup.disable({emitEvent: false}); } else { this.entityTypesVersionCreateFormGroup.enable({emitEvent: false}); + (this.entityTypesVersionCreateFormGroup.get('entityTypes') as UntypedFormArray).controls.forEach( + control => this.updateEntityTypeValidators(control) + ); } } @@ -147,7 +150,7 @@ export class EntityTypesVersionCreateComponent extends PageComponent implements } ); this.updateEntityTypeValidators(entityTypeControl); - entityTypeControl.get('config').get('allEntities').valueChanges.subscribe(() => { + entityTypeControl.get('config.allEntities').valueChanges.subscribe(() => { this.updateEntityTypeValidators(entityTypeControl); }); return entityTypeControl; @@ -229,7 +232,7 @@ export class EntityTypesVersionCreateComponent extends PageComponent implements allowedEntityTypes(entityTypeControl?: AbstractControl): Array { let res = [...exportableEntityTypes]; const currentEntityType: EntityType = entityTypeControl?.get('entityType')?.value; - const value: [{entityType: string, config: EntityTypeVersionCreateConfig}] = + const value: [{entityType: string; config: EntityTypeVersionCreateConfig}] = this.entityTypesVersionCreateFormGroup.get('entityTypes').value || []; const usedEntityTypes = value.map(val => val.entityType).filter(val => val); res = res.filter(entityType => !usedEntityTypes.includes(entityType) || entityType === currentEntityType); @@ -237,7 +240,7 @@ export class EntityTypesVersionCreateComponent extends PageComponent implements } private updateModel() { - const value: [{entityType: string, config: EntityTypeVersionCreateConfig}] = + const value: [{entityType: string; config: EntityTypeVersionCreateConfig}] = this.entityTypesVersionCreateFormGroup.get('entityTypes').value || []; let modelValue: {[entityType: string]: EntityTypeVersionCreateConfig} = null; if (value && value.length) { diff --git a/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.service.ts b/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.service.ts index 73b67017ff..356b31a312 100644 --- a/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.service.ts +++ b/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.service.ts @@ -16,7 +16,7 @@ import { Inject, Injectable, Type } from '@angular/core'; import { Observable } from 'rxjs'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; import { TranslateService } from '@ngx-translate/core'; import { AuthService } from '@core/auth/auth.service'; import { DynamicComponentFactoryService } from '@core/services/dynamic-component-factory.service'; @@ -44,7 +44,8 @@ export class CustomDialogService { ) { } - customDialog(template: string, controller: (instance: CustomDialogComponent) => void, data?: any): Observable { + customDialog(template: string, controller: (instance: CustomDialogComponent) => void, data?: any, + config?: MatDialogConfig): Observable { return this.dynamicComponentFactoryService.createDynamicComponentFactory( class CustomDialogComponentInstance extends CustomDialogComponent {}, template, @@ -55,13 +56,17 @@ export class CustomDialogService { customComponentFactory: factory, data }; + let dialogConfig: MatDialogConfig = { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: dialogData + }; + if (config) { + dialogConfig = {...dialogConfig, ...config}; + } return this.dialog.open( CustomDialogContainerComponent, - { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], - data: dialogData - }).afterClosed().pipe( + dialogConfig).afterClosed().pipe( tap(() => { this.dynamicComponentFactoryService.destroyDynamicComponentFactory(factory); }) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.html index f91caf9baa..9a23e86bf8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.html @@ -91,14 +91,18 @@ {{ getUserInitials(alarm) }} + matTooltipPosition="above"> {{ getUserDisplayName(alarm) }} - + account_circle - alarm.unassigned + + alarm.unassigned + +
+
+ + +
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.scss new file mode 100644 index 0000000000..da03a5e40b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.scss @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .tb-add-doc-link-dialog { + width: 480px; + .mat-toolbar-single-row { + padding: 0 24px; + } + h2 { + color: rgba(0, 0, 0, 0.76); + } + .mat-icon { + color: rgba(0, 0, 0, 0.54); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.ts new file mode 100644 index 0000000000..8a7413b7dd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.ts @@ -0,0 +1,58 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES 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, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { DocumentationLink } from '@shared/models/user-settings.models'; + +@Component({ + selector: 'tb-add-doc-link-dialog', + templateUrl: './add-doc-link-dialog.component.html', + styleUrls: ['./add-doc-link-dialog.component.scss'] +}) +export class AddDocLinkDialogComponent extends + DialogComponent implements OnInit { + + addDocLinkFormGroup: UntypedFormGroup; + + constructor(protected store: Store, + protected router: Router, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + public fb: UntypedFormBuilder) { + super(store, router, dialogRef); + } + + ngOnInit(): void { + this.addDocLinkFormGroup = this.fb.group({ + docLink: [{ icon: 'notifications' }, [Validators.required]] + }); + } + + cancel(): void { + this.dialogRef.close(null); + } + + add(docLink: DocumentationLink): void { + this.dialogRef.close(docLink); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.html new file mode 100644 index 0000000000..01c02757a7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.html @@ -0,0 +1,77 @@ + + + + + {{ 'widgets.cluster-info.service-id' | translate }} + + + {{ serviceData.serviceId }} + + + + + {{ 'widgets.cluster-info.service-type' | translate }} + + + {{ serviceData.serviceType }} + + + + + {{ 'widgets.system-info.cpu' | translate }} + + + {{ serviceData.cpuUsage }}% + + + + + {{ 'widgets.system-info.ram' | translate }} + + + {{ serviceData.memoryUsage }}% + + + + + {{ 'widgets.system-info.disk' | translate }} + + + {{ serviceData.discUsage }}% + + + + +
+widgets.cluster-info.no-data diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.scss new file mode 100644 index 0000000000..0d76956dfc --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.scss @@ -0,0 +1,71 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + span.no-data-found { + font-weight: 400; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + } + + .tb-resource-usage { + cursor: default; + &.tb-status-warn, &.tb-status-critical { + position: relative; + overflow: visible; + &:before { + position: absolute; + top: 15px; + left: -24px; + font-family: 'Material Icons Round'; + font-size: 20px; + line-height: 1; + } + } + &.tb-status-warn { + color: #FAA405; + &:before { + content: "warning"; + color: #FAA405; + } + } + &.tb-status-critical { + color: #D12730; + &:before { + content: "warning"; + color: #D12730; + } + } + } +} + +:host ::ng-deep { + .mdc-data-table__header-cell { + font-weight: 500; + font-size: 11px; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + } + .mdc-data-table__cell { + font-weight: 400; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.ts new file mode 100644 index 0000000000..7940d2c21a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.ts @@ -0,0 +1,237 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES 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, NgZone, OnInit, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { CollectionViewer, DataSource } from '@angular/cdk/collections'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { emptyPageData, PageData } from '@shared/models/page/page-data'; +import { MAX_SAFE_PAGE_SIZE, PageLink } from '@shared/models/page/page-link'; +import { catchError, map, publishReplay, refCount, share } from 'rxjs/operators'; +import { MatSort } from '@angular/material/sort'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { TelemetryWebsocketService } from '@core/ws/telemetry-websocket.service'; +import { EntityDataUpdate, TelemetrySubscriber } from '@shared/models/telemetry/telemetry.models'; +import { AliasFilterType } from '@shared/models/alias.models'; +import { EntityKeyType } from '@shared/models/query/query.models'; +import { TranslateService } from '@ngx-translate/core'; + +export interface SystemInfoData { + serviceId: string; + serviceType: string; + cpuUsage: number; + cpuCount: number; + memoryUsage: number; + totalMemory: number; + discUsage: number; + totalDiscSpace: number; +} + +@Component({ + selector: 'tb-cluster-info-table', + templateUrl: './cluster-info-table.component.html', + styleUrls: ['./cluster-info-table.component.scss'] +}) +export class ClusterInfoTableComponent extends PageComponent implements OnInit, AfterViewInit { + + @ViewChild(MatSort) sort: MatSort; + + displayedColumns = ['serviceId', 'serviceType', 'cpuUsage', 'memoryUsage', 'discUsage']; + + dataSource: SystemInfoDataSource; + + pageLink: PageLink; + + constructor(protected store: Store, + private telemetryWsService: TelemetryWebsocketService, + private translate: TranslateService, + private zone: NgZone) { + super(store); + } + + ngOnInit() { + this.dataSource = new SystemInfoDataSource(this.telemetryWsService, this.zone); + const sortOrder: SortOrder = { + property: 'serviceId', + direction: Direction.ASC + }; + this.pageLink = new PageLink(MAX_SAFE_PAGE_SIZE, 0, null, sortOrder); + this.dataSource.loadData(this.pageLink); + } + + ngAfterViewInit() { + this.sort.sortChange.subscribe(() => this.updateData()); + } + + updateData() { + this.pageLink.sortOrder.property = this.sort.active; + this.pageLink.sortOrder.direction = Direction[this.sort.direction.toUpperCase()]; + this.dataSource.loadData(this.pageLink); + } + + statusClass(value: number): string { + let status = ''; + if (value > 85) { + status = 'tb-status-critical'; + } else if (value > 75) { + status = 'tb-status-warn'; + } + return status; + } + + infoTooltip(serviceData: SystemInfoData, type: 'cpu' | 'ram' | 'disc'): string { + let tooltip = ''; + let value: number; + switch (type) { + case 'cpu': + value = serviceData.cpuUsage; + tooltip += value + ' / 100 %'; + break; + case 'ram': + value = serviceData.memoryUsage; + const memoryUsage = serviceData.memoryUsage; + const totalMemory = serviceData.totalMemory / 1073741824; + const usedMemory = (totalMemory * (memoryUsage / 100)).toFixed(0); + tooltip += usedMemory + ' / ' + totalMemory.toFixed(0) + ' Gb'; + break; + case 'disc': + value = serviceData.discUsage; + const discUsage = serviceData.discUsage; + const totalDiscSpace = serviceData.totalDiscSpace / 1073741824; + const usedDisc = (totalDiscSpace * (discUsage / 100)).toFixed(0); + tooltip += usedDisc + ' / ' + totalDiscSpace.toFixed(0) + ' Gb'; + break; + } + if (value > 85) { + switch (type) { + case 'cpu': + tooltip += '\n\n' + this.translate.instant('widgets.system-info.cpu-critical-text'); + break; + case 'ram': + tooltip += '\n\n' + this.translate.instant('widgets.system-info.ram-critical-text'); + break; + case 'disc': + tooltip += '\n\n' + this.translate.instant('widgets.system-info.disk-critical-text'); + break; + } + } else if (value > 75) { + switch (type) { + case 'cpu': + tooltip += '\n\n' + this.translate.instant('widgets.system-info.cpu-warning-text'); + break; + case 'ram': + tooltip += '\n\n' + this.translate.instant('widgets.system-info.ram-warning-text'); + break; + case 'disc': + tooltip += '\n\n' + this.translate.instant('widgets.system-info.disk-warning-text'); + break; + } + } + return tooltip; + } +} + +export class SystemInfoDataSource implements DataSource { + + private systemDataSubject = new BehaviorSubject([]); + private pageDataSubject = new BehaviorSubject>(emptyPageData()); + + public pageData$ = this.pageDataSubject.asObservable(); + + private allSystemData: Observable>; + + private telemetrySubscriber: TelemetrySubscriber; + + constructor(private telemetryWsService: TelemetryWebsocketService, + private zone: NgZone) { + } + + connect(collectionViewer: CollectionViewer): Observable> { + return this.systemDataSubject.asObservable(); + } + + disconnect(collectionViewer: CollectionViewer): void { + this.systemDataSubject.complete(); + this.pageDataSubject.complete(); + if (this.telemetrySubscriber) { + this.telemetrySubscriber.unsubscribe(); + this.telemetrySubscriber = null; + } + } + + loadData(pageLink: PageLink): void { + this.getAllSystemData().pipe( + map((data) => pageLink.filterData(data)), + catchError(() => of(emptyPageData())), + ).subscribe( + (pageData) => { + this.onSystemData(pageData.data); + this.pageDataSubject.next(pageData); + } + ); + } + + getAllSystemData(): Observable> { + if (!this.allSystemData) { + this.telemetrySubscriber = TelemetrySubscriber.createEntityFilterLatestSubscription( + this.telemetryWsService, { type: AliasFilterType.apiUsageState }, this.zone, + [ { key: 'clusterSystemData', type: EntityKeyType.TIME_SERIES } ]); + this.telemetrySubscriber.subscribe(); + this.allSystemData = this.telemetrySubscriber.entityData$.pipe( + map((update) => this.toSystemInfoData(update)), + publishReplay(1), + refCount() + ); + } + return this.allSystemData; + } + + private toSystemInfoData(entityDataUpdate: EntityDataUpdate): Array { + const entityData = entityDataUpdate.data?.data ? entityDataUpdate.data?.data : entityDataUpdate.update; + if (entityData && entityData.length) { + const latest = entityData[0].latest; + if (latest && latest[EntityKeyType.TIME_SERIES]) { + const latestTs = latest[EntityKeyType.TIME_SERIES]; + const tsValue = latestTs.clusterSystemData; + if (tsValue && tsValue.value) { + return JSON.parse(tsValue.value); + } + } + } + return []; + } + + protected onSystemData(data: SystemInfoData[]) { + this.systemDataSubject.next(data); + } + + isEmpty(): Observable { + return this.systemDataSubject.pipe( + map((entities) => !entities.length), + share() + ); + } + + total(): Observable { + return this.pageDataSubject.pipe( + map((pageData) => pageData.totalElements), + share() + ); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.html new file mode 100644 index 0000000000..9c028856be --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.html @@ -0,0 +1,72 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.scss new file mode 100644 index 0000000000..427aa8bf3a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.scss @@ -0,0 +1,118 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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-content { + width: 100%; + height: 100%; + } + + .tb-title { + margin-top: 8px; + font-style: normal; + font-weight: 500; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + @media #{$mat-md-lg} { + font-size: 12px; + line-height: 16px; + } + } + + .tb-info-icon { + color: rgba(0, 0, 0, 0.12); + font-size: 16px; + width: 16px; + height: 16px; + line-height: 15px; + vertical-align: middle; + } + + .tb-features-list { + overflow: auto; + } + + .mat-grid-tile.tb-feature-tile { + overflow: visible; + } + + .tb-feature-button { + height: 50px; + display: flex; + overflow: hidden; + flex-direction: column; + justify-content: center; + padding: 12px; + background: #FFFFFF; + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: 0 5px 16px rgba(0, 0, 0, 0.04); + border-radius: 10px; + @media #{$mat-md-lg} { + height: 22px; + padding: 0 8px; + } + &:hover { + border: 1px solid rgba(0, 0, 0, 0.12); + box-shadow: 0 4px 10px rgba(23, 33, 90, 0.08); + } + .tb-feature-text { + font-weight: 400; + font-size: 14px; + line-height: 20px; + color: rgba(0, 0, 0, 0.87); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + @media #{$mat-md-lg} { + font-size: 11px; + line-height: 16px; + } + } + .tb-feature-status { + display: inline-block; + font-family: 'Material Icons Round'; + font-size: 20px; + line-height: 20px; + color: rgba(0, 0, 0, 0.12); + font-weight: 600; + position: relative; + background: #F4F4F4; + border-radius: 4px; + width: 24px; + height: 24px; + padding: 2px; + margin-right: 8px; + vertical-align: middle; + @media #{$mat-md-lg} { + font-size: 14px; + line-height: 14px; + width: 18px; + height: 18px; + margin-right: 4px; + } + } + &.configured { + .tb-feature-status { + color: #198038; + background: #F3F6FA; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.ts new file mode 100644 index 0000000000..be58092f1f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.ts @@ -0,0 +1,104 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES 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, OnDestroy, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { AdminService } from '@core/http/admin.service'; +import { FeaturesInfo } from '@shared/models/settings.models'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; +import { of, Subscription } from 'rxjs'; +import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout'; +import { TranslateService } from '@ngx-translate/core'; +import { MediaBreakpoints } from '@shared/models/constants'; + +@Component({ + selector: 'tb-configured-features', + templateUrl: './configured-features.component.html', + styleUrls: ['./configured-features.component.scss'] +}) +export class ConfiguredFeaturesComponent extends PageComponent implements OnInit, OnDestroy { + + authUser = getCurrentAuthUser(this.store); + featuresInfo: FeaturesInfo; + rowHeight = '50px'; + gutterSize = '12px'; + bottomColspan = 2; + lastColspan = 2; + + private observeBreakpointSubscription: Subscription; + + constructor(protected store: Store, + private cd: ChangeDetectorRef, + private adminService: AdminService, + private translate: TranslateService, + private breakpointObserver: BreakpointObserver) { + super(store); + } + + ngOnInit() { + const isMdLg = this.breakpointObserver.isMatched(MediaBreakpoints['md-lg']); + const isLtMd = this.breakpointObserver.isMatched(MediaBreakpoints['lt-md']); + this.rowHeight = isMdLg ? '22px' : '50px'; + this.gutterSize = isMdLg ? '8px' : '12px'; + this.bottomColspan = isLtMd ? 3 : 2; + this.lastColspan = isLtMd ? 6 : 2; + this.observeBreakpointSubscription = this.breakpointObserver + .observe([MediaBreakpoints['md-lg'], MediaBreakpoints['lt-md']]) + .subscribe((state: BreakpointState) => { + if (state.breakpoints[MediaBreakpoints['md-lg']]) { + this.rowHeight = '22px'; + this.gutterSize = '8px'; + } else { + this.rowHeight = '50px'; + this.gutterSize = '12px'; + } + if (state.breakpoints[MediaBreakpoints['lt-md']]) { + this.bottomColspan = 3; + this.lastColspan = 6; + } else { + this.bottomColspan = 2; + this.lastColspan = 2; + } + this.cd.markForCheck(); + } + ); + (this.authUser.authority === Authority.SYS_ADMIN ? + this.adminService.getFeaturesInfo() : of(null)).subscribe( + (featuresInfo) => { + this.featuresInfo = featuresInfo; + this.cd.markForCheck(); + } + ); + } + + ngOnDestroy() { + if (this.observeBreakpointSubscription) { + this.observeBreakpointSubscription.unsubscribe(); + } + super.ngOnDestroy(); + } + + featureTooltip(configured: boolean): string { + if (configured) { + return this.translate.instant('widgets.configured-features.feature-configured'); + } else { + return this.translate.instant('widgets.configured-features.feature-not-configured'); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.html new file mode 100644 index 0000000000..c64958a2ec --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.html @@ -0,0 +1,70 @@ + +
+ +
+ + +
+
+ +
+ +
+ + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.scss new file mode 100644 index 0000000000..1d9de4fc38 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.scss @@ -0,0 +1,61 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .tb-edit-doc-link { + padding: 16px; + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: 0 5px 16px rgba(0, 0, 0, 0.04); + border-radius: 10px; + &.edit-mode { + border: 1px solid rgba(48, 86, 128, 0.32); + } + } + .tb-doc-link { + height: 55px; + display: flex; + flex-direction: column; + justify-content: center; + padding: 8px; + background: #FFFFFF; + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: 0 5px 16px rgba(0, 0, 0, 0.04); + border-radius: 10px; + .tb-doc-container { + display: flex; + flex-direction: row; + align-items: center; + .tb-doc-icon-container { + height: 40px; + padding: 8px; + background: #F3F6FA; + border-radius: 6px; + margin-right: 8px; + } + .tb-doc-text { + font-weight: 400; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.87); + } + } + } + .tb-edit-buttons { + .mat-icon { + color: rgba(0, 0, 0, 0.38); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.ts new file mode 100644 index 0000000000..488330cf0d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.ts @@ -0,0 +1,190 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES 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, forwardRef, Input, OnInit, Output, SkipSelf } from '@angular/core'; +import { + ControlValueAccessor, FormGroupDirective, + NG_VALUE_ACCESSOR, NgForm, + UntypedFormBuilder, UntypedFormControl, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DocumentationLink } from '@shared/models/user-settings.models'; +import { ErrorStateMatcher } from '@angular/material/core'; + +@Component({ + selector: 'tb-doc-link', + templateUrl: './doc-link.component.html', + styleUrls: ['./doc-link.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DocLinkComponent), + multi: true + }, + {provide: ErrorStateMatcher, useExisting: DocLinkComponent} + ] +}) +export class DocLinkComponent extends PageComponent implements OnInit, ControlValueAccessor, ErrorStateMatcher { + + @Input() + disabled: boolean; + + @Input() + addOnly = false; + + @Input() + disableEdit = false; + + @Output() + docLinkAdded = new EventEmitter(); + + @Output() + docLinkAddCanceled = new EventEmitter(); + + @Output() + docLinkUpdated = new EventEmitter(); + + @Output() + docLinkDeleted = new EventEmitter(); + + @Output() + editModeChanged = new EventEmitter(); + + editMode = false; + addMode = false; + + docLink: DocumentationLink; + + private propagateChange = null; + + public editDocLinkFormGroup: UntypedFormGroup; + + private submitted = false; + + constructor(protected store: Store, + private fb: UntypedFormBuilder, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher) { + super(store); + } + + ngOnInit(): void { + this.addMode = this.addOnly; + this.editDocLinkFormGroup = this.fb.group({ + icon: [null, [Validators.required]], + name: [null, [Validators.required]], + link: [null, [Validators.required]] + }); + } + + isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.editDocLinkFormGroup.disable({emitEvent: false}); + } else { + this.editDocLinkFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: DocumentationLink): void { + this.docLink = value; + this.editDocLinkFormGroup.patchValue( + value, {emitEvent: false} + ); + if (!this.editDocLinkFormGroup.valid) { + this.addMode = true; + this.editModeChanged.emit(true); + } + } + + switchToEditMode() { + if (!this.disableEdit && !this.editMode) { + this.submitted = false; + this.editDocLinkFormGroup.patchValue( + this.docLink, {emitEvent: false} + ); + this.editMode = true; + this.editModeChanged.emit(true); + } + } + + apply() { + this.submitted = true; + this.updateModel(); + if (this.editDocLinkFormGroup.valid) { + this.editMode = false; + this.editModeChanged.emit(false); + this.docLinkUpdated.next(this.editDocLinkFormGroup.value); + } + } + + cancelEdit() { + this.submitted = false; + this.editMode = false; + this.editModeChanged.emit(false); + } + + add() { + this.submitted = true; + this.updateModel(); + if (this.editDocLinkFormGroup.valid) { + if (!this.addOnly) { + this.addMode = false; + this.editModeChanged.emit(false); + } + this.docLinkAdded.next(this.editDocLinkFormGroup.value); + } + } + + cancelAdd() { + this.editModeChanged.emit(false); + this.docLinkAddCanceled.emit(); + } + + delete() { + this.docLinkDeleted.emit(); + } + + isEditing() { + return this.editMode || this.addMode; + } + + private updateModel() { + if (this.editDocLinkFormGroup.valid) { + this.docLink = this.editDocLinkFormGroup.value; + this.propagateChange(this.editDocLinkFormGroup.value); + } else { + this.propagateChange(null); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.html new file mode 100644 index 0000000000..96c5642feb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.html @@ -0,0 +1,50 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.scss new file mode 100644 index 0000000000..ddc5a40eec --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.scss @@ -0,0 +1,159 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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-content { + width: 100%; + height: 100%; + } + + .tb-card-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-end; + } + + .tb-title-link { + font-style: normal; + font-weight: 500; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + position: relative; + border-bottom: none; + @media #{$mat-md-lg} { + font-size: 12px; + line-height: 16px; + } + &:hover, &:focus { + border-bottom: none; + } + &::after { + content: 'arrow_forward'; + display: inline-block; + transform: rotate(315deg); + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 18px; + color: rgba(0, 0, 0, 0.12); + vertical-align: bottom; + margin-left: 6px; + } + &:hover::after { + color: inherit; + } + } + + .tb-title-icon { + color: rgba(0, 0, 0, 0.38); + width: 32px; + height: 32px; + padding: 4px; + } + + .tb-docs-list { + overflow: auto; + } + + .mat-grid-tile.tb-docs-tile { + overflow: visible; + } + + .tb-doc-button { + height: 55px; + display: flex; + overflow: hidden; + flex-direction: column; + justify-content: center; + padding: 8px; + background: #FFFFFF; + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: 0 5px 16px rgba(0, 0, 0, 0.04); + border-radius: 10px; + @media #{$mat-md-lg} { + height: 18px; + padding: 0 8px; + } + &:hover { + border: 1px solid rgba(0, 0, 0, 0.12); + box-shadow: 0 4px 10px rgba(23, 33, 90, 0.08); + } + .tb-doc-container { + display: flex; + flex-direction: row; + align-items: center; + .tb-doc-icon-container { + height: 40px; + padding: 8px; + background: #F3F6FA; + border-radius: 6px; + margin-right: 8px; + @media #{$mat-md-lg} { + display: none; + } + } + .tb-doc-text { + font-weight: 400; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.87); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + @media #{$mat-md-lg} { + font-size: 11px; + line-height: 16px; + } + } + } + } + + .tb-add-doc-button { + height: 55px; + cursor: pointer; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: #FFFFFF; + padding: 8px; + border: 2px dashed rgba(0, 0, 0, 0.08); + border-radius: 10px; + @media #{$mat-md-lg} { + height: 18px; + padding: 0 8px; + .tb-add-icon { + height: 12px; + width: 12px; + font-size: 12px; + } + } + .tb-add-icon { + color: rgba(0, 0, 0, 0.12); + } + &:hover { + .tb-add-icon { + color: rgba(0, 0, 0, 0.38); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.ts new file mode 100644 index 0000000000..1c7c5885b5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.ts @@ -0,0 +1,206 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Authority } from '@shared/models/authority.enum'; +import { map, Subscription } from 'rxjs'; +import { DocumentationLink, DocumentationLinks } from '@shared/models/user-settings.models'; +import { UserSettingsService } from '@core/http/user-settings.service'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { + ImportDialogCsvComponent, + ImportDialogCsvData +} from '@home/components/import-export/import-dialog-csv.component'; +import { MatDialog } from '@angular/material/dialog'; +import { AddDocLinkDialogComponent } from '@home/components/widget/lib/home-page/add-doc-link-dialog.component'; +import { + EditDocLinksDialogComponent, + EditDocLinksDialogData +} from '@home/components/widget/lib/home-page/edit-doc-links-dialog.component'; +import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout'; +import { MediaBreakpoints } from '@shared/models/constants'; + +const defaultDocLinksMap = new Map( + [ + [Authority.SYS_ADMIN, { + links: [ + { + icon: 'rocket', + name: 'Getting started', + link: 'https://thingsboard.io/docs/getting-started-guides/helloworld/' + }, + { + icon: 'title', + name: 'Tenant profiles', + link: 'https://thingsboard.io/docs/user-guide/tenant-profiles/' + }, + { + icon: 'insert_chart', + name: 'API', + link: 'https://thingsboard.io/docs/api/' + }, + { + icon: 'now_widgets', + name: 'Widgets Library', + link: 'https://thingsboard.io/docs/user-guide/ui/widget-library/' + } + ] + }], + [Authority.TENANT_ADMIN, { + links: [ + { + icon: 'rocket', + name: 'Getting started', + link: 'https://thingsboard.io/docs/getting-started-guides/helloworld/' + }, + { + icon: 'settings_ethernet', + name: 'Rule engine', + link: 'https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/' + }, + { + icon: 'insert_chart', + name: 'API', + link: 'https://thingsboard.io/docs/api/' + }, + { + icon: 'devices', + name: 'Device profiles', + link: 'https://thingsboard.io/docs/user-guide/device-profiles/' + } + ] + }], + [Authority.CUSTOMER_USER, { + links: [] + }] + ] +); + +interface DocLinksWidgetSettings { + columns: number; +} + +@Component({ + selector: 'tb-doc-links-widget', + templateUrl: './doc-links-widget.component.html', + styleUrls: ['./doc-links-widget.component.scss'] +}) +export class DocLinksWidgetComponent extends PageComponent implements OnInit, OnDestroy { + + @Input() + ctx: WidgetContext; + + settings: DocLinksWidgetSettings; + columns: number; + rowHeight = '55px'; + gutterSize = '12px'; + + documentationLinks: DocumentationLinks; + authUser = getCurrentAuthUser(this.store); + + private observeBreakpointSubscription: Subscription; + + constructor(protected store: Store, + private cd: ChangeDetectorRef, + private userSettingsService: UserSettingsService, + private dialog: MatDialog, + private breakpointObserver: BreakpointObserver) { + super(store); + } + + ngOnInit() { + this.settings = this.ctx.settings; + this.columns = this.settings.columns || 3; + const isMdLg = this.breakpointObserver.isMatched(MediaBreakpoints['md-lg']); + this.rowHeight = isMdLg ? '18px' : '55px'; + this.gutterSize = isMdLg ? '8px' : '12px'; + this.observeBreakpointSubscription = this.breakpointObserver + .observe(MediaBreakpoints['md-lg']) + .subscribe((state: BreakpointState) => { + if (state.matches) { + this.rowHeight = '18px'; + this.gutterSize = '8px'; + } else { + this.rowHeight = '55px'; + this.gutterSize = '12px'; + } + this.cd.markForCheck(); + } + ); + this.loadDocLinks(); + } + + ngOnDestroy() { + if (this.observeBreakpointSubscription) { + this.observeBreakpointSubscription.unsubscribe(); + } + super.ngOnDestroy(); + } + + private loadDocLinks() { + this.userSettingsService.getDocumentationLinks().pipe( + map((documentationLinks) => { + if (!documentationLinks || !documentationLinks.links) { + return defaultDocLinksMap.get(this.authUser.authority); + } else { + return documentationLinks; + } + }) + ).subscribe( + (documentationLinks) => { + this.documentationLinks = documentationLinks; + this.cd.markForCheck(); + } + ); + } + + edit() { + this.dialog.open(EditDocLinksDialogComponent, { + disableClose: true, + autoFocus: false, + data: { + docLinks: this.documentationLinks + }, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] + }).afterClosed().subscribe( + (result) => { + if (result) { + this.loadDocLinks(); + } + }); + } + + addLink() { + this.dialog.open(AddDocLinkDialogComponent, { + disableClose: true, + autoFocus: false, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] + }).afterClosed().subscribe( + (docLink) => { + if (docLink) { + this.documentationLinks.links.push(docLink); + this.cd.markForCheck(); + this.userSettingsService.updateDocumentationLinks(this.documentationLinks).subscribe(); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.html new file mode 100644 index 0000000000..13cbf35539 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.html @@ -0,0 +1,63 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.scss new file mode 100644 index 0000000000..495c6f4037 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.scss @@ -0,0 +1,65 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .tb-edit-doc-links-dialog { + width: 480px; + .mat-mdc-dialog-content { + max-height: 50vh; + } + .mat-toolbar-single-row { + padding: 0 24px; + } + h2 { + color: rgba(0, 0, 0, 0.76); + } + .mat-icon { + color: rgba(0, 0, 0, 0.54); + } + .mat-mdc-dialog-content { + padding: 24px 24px 8px 24px; + } + .mdc-dialog__actions { + padding: 0 24px 24px; + } + } + .tb-drag-handle { + height: 24px; + margin-left: 12px; + .mat-icon { + color: rgba(0, 0, 0, 0.38); + } + } + .tb-add-doc-button { + height: 55px; + cursor: pointer; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: #FFFFFF; + padding: 8px; + border: 2px dashed rgba(0, 0, 0, 0.08); + border-radius: 10px; + .tb-add-icon { + color: rgba(0, 0, 0, 0.12); + } + &:hover { + .tb-add-icon { + color: rgba(0, 0, 0, 0.38); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.ts new file mode 100644 index 0000000000..eca41713c4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.ts @@ -0,0 +1,114 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { AbstractControl, UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { DocumentationLink, DocumentationLinks } from '@shared/models/user-settings.models'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { UserSettingsService } from '@core/http/user-settings.service'; + +export interface EditDocLinksDialogData { + docLinks: DocumentationLinks; +} + +@Component({ + selector: 'tb-edit-doc-links-dialog', + templateUrl: './edit-doc-links-dialog.component.html', + styleUrls: ['./edit-doc-links-dialog.component.scss'] +}) +export class EditDocLinksDialogComponent extends + DialogComponent implements OnInit { + + updated = false; + addMode = false; + editMode = false; + + docLinks = this.data.docLinks; + addingDocLink: Partial; + + editDocLinksFormGroup: UntypedFormGroup; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: EditDocLinksDialogData, + public dialogRef: MatDialogRef, + public fb: UntypedFormBuilder, + private userSettingsService: UserSettingsService) { + super(store, router, dialogRef); + } + + ngOnInit(): void { + const docLinksControls: Array = []; + for (const docLink of this.docLinks.links) { + docLinksControls.push(this.fb.control(docLink, [Validators.required])); + } + this.editDocLinksFormGroup = this.fb.group({ + links: this.fb.array(docLinksControls) + }); + } + + docLinksFormArray(): UntypedFormArray { + return this.editDocLinksFormGroup.get('links') as UntypedFormArray; + } + + trackByDocLink(index: number, docLinkControl: AbstractControl): any { + return docLinkControl; + } + + docLinkDrop(event: CdkDragDrop) { + const docLinksArray = this.editDocLinksFormGroup.get('links') as UntypedFormArray; + const docLink = docLinksArray.at(event.previousIndex); + docLinksArray.removeAt(event.previousIndex); + docLinksArray.insert(event.currentIndex, docLink); + this.update(); + } + + addLink() { + this.addingDocLink = { icon: 'notifications' }; + this.addMode = true; + } + + linkAdded(docLink: DocumentationLink) { + this.addMode = false; + const docLinksArray = this.editDocLinksFormGroup.get('links') as UntypedFormArray; + const docLinkControl = this.fb.control(docLink, [Validators.required]); + docLinksArray.push(docLinkControl); + this.update(); + } + + deleteLink(index: number) { + (this.editDocLinksFormGroup.get('links') as UntypedFormArray).removeAt(index); + this.update(); + } + + update() { + if (this.editDocLinksFormGroup.valid) { + const docLinks: DocumentationLinks = this.editDocLinksFormGroup.value; + this.userSettingsService.updateDocumentationLinks(docLinks).subscribe(() => { + this.updated = true; + }); + } + } + + close(): void { + this.dialogRef.close(this.updated); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.html new file mode 100644 index 0000000000..ac57d77c3a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.html @@ -0,0 +1,34 @@ + +
+
+ + +
+
+
+ done +
+
+
+
widgets.getting-started.done-welcome-title
+
widgets.getting-started.done-welcome-text
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.scss new file mode 100644 index 0000000000..e440fa89a0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.scss @@ -0,0 +1,62 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-getting-started-completed-dialog { + .mat-mdc-dialog-container .mdc-dialog__surface { + border-radius: 8px; + background-image: url(/assets/home/greetings_bg.svg); + background-repeat: no-repeat; + } + + .tb-getting-started-completed-dialog-container { + width: 500px; + height: 290px; + } + + .tb-done-container { + width: 70px; + height: 70px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.05); + } + + .tb-done-icon { + width: 62px; + height: 62px; + line-height: 56px; + font-size: 36px; + text-align: center; + border-radius: 50%; + border: 2px solid #305680; + background: #FFFFFF; + } + + .tb-done-welcome-title { + font-weight: 600; + font-size: 20px; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.87); + padding-bottom: 8px; + } + + .tb-done-welcome-text { + font-weight: 400; + font-size: 16px; + line-height: 24px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.54); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.ts new file mode 100644 index 0000000000..087b70f3d8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.ts @@ -0,0 +1,46 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES 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, ViewEncapsulation } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UntypedFormGroup } from '@angular/forms'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'tb-getting-started-completed-dialog', + templateUrl: './getting-started-completed-dialog.component.html', + styleUrls: ['./getting-started-completed-dialog.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class GettingStartedCompletedDialogComponent extends + DialogComponent { + + addDocLinkFormGroup: UntypedFormGroup; + + constructor(protected store: Store, + protected router: Router, + public dialogRef: MatDialogRef) { + super(store, router, dialogRef); + dialogRef.addPanelClass('tb-getting-started-completed-dialog'); + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.html new file mode 100644 index 0000000000..ef61c9133f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.html @@ -0,0 +1,100 @@ + +
widgets.getting-started.get-started
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.scss new file mode 100644 index 0000000000..193fb5e793 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.scss @@ -0,0 +1,121 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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%; + + .tb-title { + font-weight: 600; + font-size: 20px; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.76); + padding-bottom: 24px; + @media #{$mat-md-lg} { + font-weight: 500; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + padding-bottom: 20px; + } + } + .tb-get-started-container { + flex: 1; + overflow: auto; + } +} + +:host ::ng-deep { + + .tb-get-started .mat-vertical-stepper-header { + border-radius: 12px; + padding: 12px; + height: auto; + } + + .tb-get-started .mat-vertical-stepper-header[aria-selected="true"] { + background: rgba(0, 0, 0, 0.03); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + + .tb-get-started .mat-vertical-content-container { + margin-left: 0; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; + background: rgba(0, 0, 0, 0.03); + } + + .tb-get-started .mat-stepper-vertical-line::before { + content: none; + } + + .tb-get-started .mat-step-label { + width: 100%; + white-space: inherit; + font-weight: 400; + font-size: 14px; + line-height: 20px; + color: rgba(0, 0, 0, 0.76); + } + + .tb-get-started .mat-step-label.mat-step-label-selected { + font-weight: 500; + font-size: 16px; + line-height: 24px; + color: rgba(0, 0, 0, 0.87); + } + + .tb-get-started .mat-vertical-content p { + font-style: normal; + font-weight: 400; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.54); + } + + @media #{$mat-md-lg} { + .tb-get-started .mat-step-label { + font-size: 12px; + line-height: 16px; + } + .tb-get-started .mat-step-label.mat-step-label-selected { + font-size: 13px; + line-height: 16px; + } + + .tb-get-started .mdc-button { + font-size: 12px; + line-height: 16px; + } + + .tb-get-started .mat-vertical-content p { + font-size: 12px; + line-height: 16px; + letter-spacing: 0.25px; + } + + .tb-get-started .mat-vertical-content { + padding: 0 16px 16px 16px; + } + + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.ts new file mode 100644 index 0000000000..9c07e62978 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.ts @@ -0,0 +1,134 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UserSettingsService } from '@core/http/user-settings.service'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { MatDialog } from '@angular/material/dialog'; +import { + GettingStartedCompletedDialogComponent +} from '@home/components/widget/lib/home-page/getting-started-completed-dialog.component'; +import { GettingStarted } from '@shared/models/user-settings.models'; +import { CdkStep, StepperSelectionEvent } from '@angular/cdk/stepper'; +import { isUndefined } from '@core/utils'; +import { MatStepper } from '@angular/material/stepper'; +import { first } from 'rxjs/operators'; +import { Authority } from '@shared/models/authority.enum'; + +@Component({ + selector: 'tb-getting-started-widget', + templateUrl: './getting-started-widget.component.html', + styleUrls: ['./getting-started-widget.component.scss'] +}) +export class GettingStartedWidgetComponent extends PageComponent implements OnInit, OnDestroy { + + @ViewChild('matStepper') + matStepper: MatStepper; + + @Input() + ctx: WidgetContext; + + authority = Authority; + + authUser = getCurrentAuthUser(this.store); + gettingStarted: GettingStarted = { + lastSelectedIndex: 0, + maxSelectedIndex: 0 + }; + allCompleted = false; + + constructor(protected store: Store, + private cd: ChangeDetectorRef, + private userSettingsService: UserSettingsService, + private dialog: MatDialog) { + super(store); + } + + ngOnInit() { + this.userSettingsService.getGettingStarted().subscribe( + (gettingStarted) => { + if (gettingStarted) { + this.gettingStarted = gettingStarted; + if (isUndefined(this.gettingStarted.lastSelectedIndex)) { + this.gettingStarted.lastSelectedIndex = 0; + } + if (isUndefined(this.gettingStarted.maxSelectedIndex)) { + this.gettingStarted.maxSelectedIndex = 0; + } + if (this.gettingStarted.lastSelectedIndex > 0 && this.gettingStarted.lastSelectedIndex < this.matStepper.steps.length) { + const animationDuration = this.matStepper.animationDuration; + this.matStepper.animationDuration = '0'; + this.matStepper.selectedIndex = this.gettingStarted.lastSelectedIndex; + this.matStepper.animationDone.pipe(first()).subscribe(() => { + setTimeout(() => {this.matStepper.animationDuration = animationDuration;}, 0); + }); + } + if (this.matStepper.steps.length) { + this.updateCompletedSteps(); + this.cd.markForCheck(); + } else { + this.matStepper.steps.changes.subscribe( + () => { + this.updateCompletedSteps(); + this.cd.markForCheck(); + } + ); + } + } + } + ); + } + + ngOnDestroy() { + super.ngOnDestroy(); + } + + isSelected(step: CdkStep) { + return this.matStepper?.selected === step; + } + + gettingStartedCompleted() { + this.dialog.open(GettingStartedCompletedDialogComponent, { + disableClose: true, + autoFocus: false, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] + }).afterClosed().subscribe(); + } + + updateSelectedIndex(event: StepperSelectionEvent) { + if (this.gettingStarted.lastSelectedIndex !== event.selectedIndex) { + this.gettingStarted.lastSelectedIndex = event.selectedIndex; + if (event.selectedIndex > this.gettingStarted.maxSelectedIndex) { + this.gettingStarted.maxSelectedIndex = event.selectedIndex; + this.updateCompletedSteps(); + } + this.userSettingsService + .updateGettingStarted(this.gettingStarted, {ignoreLoading: true}).subscribe(); + } + } + + updateCompletedSteps() { + if (this.gettingStarted.maxSelectedIndex >= this.matStepper.steps.length-1) { + this.allCompleted = true; + } + } + +} 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 new file mode 100644 index 0000000000..3be30dad30 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/home-page-widgets.module.ts @@ -0,0 +1,61 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES 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 '@app/shared/shared.module'; +import { ClusterInfoTableComponent } from '@home/components/widget/lib/home-page/cluster-info-table.component'; +import { ConfiguredFeaturesComponent } from '@home/components/widget/lib/home-page/configured-features.component'; +import { VersionInfoComponent } from '@home/components/widget/lib/home-page/version-info.component'; +import { DocLinksWidgetComponent } from '@home/components/widget/lib/home-page/doc-links-widget.component'; +import { DocLinkComponent } from '@home/components/widget/lib/home-page/doc-link.component'; +import { AddDocLinkDialogComponent } from '@home/components/widget/lib/home-page/add-doc-link-dialog.component'; +import { EditDocLinksDialogComponent } from '@home/components/widget/lib/home-page/edit-doc-links-dialog.component'; +import { GettingStartedWidgetComponent } from '@home/components/widget/lib/home-page/getting-started-widget.component'; +import { + GettingStartedCompletedDialogComponent +} from '@home/components/widget/lib/home-page/getting-started-completed-dialog.component'; + +@NgModule({ + declarations: + [ + ClusterInfoTableComponent, + ConfiguredFeaturesComponent, + VersionInfoComponent, + DocLinksWidgetComponent, + DocLinkComponent, + AddDocLinkDialogComponent, + EditDocLinksDialogComponent, + GettingStartedWidgetComponent, + GettingStartedCompletedDialogComponent + ], + imports: [ + CommonModule, + SharedModule + ], + exports: [ + ClusterInfoTableComponent, + ConfiguredFeaturesComponent, + VersionInfoComponent, + DocLinksWidgetComponent, + DocLinkComponent, + AddDocLinkDialogComponent, + EditDocLinksDialogComponent, + GettingStartedWidgetComponent, + GettingStartedCompletedDialogComponent + ] +}) +export class HomePageWidgetsModule { } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.html new file mode 100644 index 0000000000..fb5dde16d2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.html @@ -0,0 +1,63 @@ + + + +
+
check
+
widgets.version-info.version-is-up-to-date
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.scss new file mode 100644 index 0000000000..133a0d498e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.scss @@ -0,0 +1,150 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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-content { + width: 100%; + height: 100%; + } + + .tb-card-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + @media #{$mat-md-lg} { + margin-bottom: 4px; + } + } + + .tb-title { + font-style: normal; + font-weight: 500; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + @media #{$mat-md-lg} { + font-size: 12px; + line-height: 16px; + } + } + + .tb-contact-us { + @media #{$mat-md-lg} { + height: 24px; + font-size: 12px; + } + } + + .tb-version-info-container { + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: 0 5px 16px rgba(0, 0, 0, 0.04); + border-radius: 10px; + padding: 8px 16px 4px 16px; + overflow: hidden; + white-space: nowrap; + @media #{$mat-lt-md} { + padding: 16px; + } + @media #{$mat-md-lg} { + padding: 4px; + } + &.up-to-date { + box-shadow: none; + border: none; + background: #F3F6FA; + white-space: initial; + text-align: center; + } + } + + .tb-version-link { + font-weight: 400; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + position: relative; + border-bottom: none; + @media #{$mat-md-lg} { + font-size: 11px; + line-height: 16px; + } + &:hover, &:focus { + border-bottom: none; + } + &::after { + content: 'arrow_forward'; + display: inline-block; + transform: rotate(315deg); + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 18px; + color: rgba(0, 0, 0, 0.12); + vertical-align: bottom; + margin-left: 6px; + } + &:hover::after { + color: inherit; + } + } + + .tb-version { + font-weight: 500; + font-size: 18px; + line-height: 24px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.87); + @media #{$mat-md-lg} { + font-size: 12px; + line-height: 14px; + } + } + + .tb-upgrade { + @media #{$mat-md-lg} { + height: 24px; + font-size: 12px; + padding: 0; + overflow: hidden; + } + } + + .check-icon { + color: #198038; + font-weight: 600; + @media #{$mat-md-lg} { + font-size: 16px; + } + } + + .up-to-date-text { + font-weight: 400; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + @media #{$mat-md-lg} { + font-size: 11px; + line-height: 16px; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.ts new file mode 100644 index 0000000000..27acef15f4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.ts @@ -0,0 +1,52 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES 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, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { AdminService } from '@core/http/admin.service'; +import { UpdateMessage } from '@shared/models/settings.models'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; +import { of } from 'rxjs'; + +@Component({ + selector: 'tb-version-info', + templateUrl: './version-info.component.html', + styleUrls: ['./version-info.component.scss'] +}) +export class VersionInfoComponent extends PageComponent implements OnInit { + + authUser = getCurrentAuthUser(this.store); + updateMessage: UpdateMessage; + + constructor(protected store: Store, + private cd: ChangeDetectorRef, + private adminService: AdminService) { + super(store); + } + + ngOnInit() { + (this.authUser.authority === Authority.SYS_ADMIN ? + this.adminService.checkUpdates() : of(null)).subscribe( + (updateMessage) => { + this.updateMessage = updateMessage; + this.cd.markForCheck(); + } + ); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html index a2169cc129..99740ac601 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html @@ -31,7 +31,7 @@ entity.entity - {{ entity.entityName }} + {{ entity.entityParseName }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts index 9822f6dca9..f2b3d09b5c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts @@ -20,7 +20,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { Router } from '@angular/router'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormattedData } from '@shared/models/widget.models'; export interface SelectEntityDialogData { @@ -33,14 +33,13 @@ export interface SelectEntityDialogData { styleUrls: ['./select-entity-dialog.component.scss'] }) export class SelectEntityDialogComponent extends DialogComponent { - - selectEntityFormGroup: UntypedFormGroup; + selectEntityFormGroup: FormGroup; constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: SelectEntityDialogData, public dialogRef: MatDialogRef, - public fb: UntypedFormBuilder) { + public fb: FormBuilder) { super(store, router, dialogRef); this.selectEntityFormGroup = this.fb.group( diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts index a78dd27431..7b63176203 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts @@ -36,7 +36,7 @@ import { Observable, of } from 'rxjs'; import { Polyline } from './polyline'; import { Polygon } from './polygon'; import { Circle } from './circle'; -import { createTooltip, isCutPolygon, isJSON } from '@home/components/widget/lib/maps/maps-utils'; +import { createTooltip, entitiesParseName, isCutPolygon, isJSON } from '@home/components/widget/lib/maps/maps-utils'; import { checkLngLat, createLoadingDiv } from '@home/components/widget/lib/maps/common-maps-utils'; import { WidgetContext } from '@home/models/widget-component.models'; import { @@ -182,20 +182,40 @@ export default abstract class LeafletMap { private selectEntityWithoutLocationDialog(shapes: L.PM.SUPPORTED_SHAPES): Observable { let entities; + let labelSettings; switch (shapes) { case 'Polygon': case 'Rectangle': entities = this.datasources.filter(pData => !this.isValidPolygonPosition(pData)); + labelSettings = { + showLabel: this.options.showPolygonLabel, + useLabelFunction: this.options.usePolygonLabelFunction, + parsedLabelFunction: this.options.parsedPolygonLabelFunction, + label: this.options.polygonLabel + }; break; case 'Marker': entities = this.datasources.filter(mData => !this.extractPosition(mData)); + labelSettings = { + showLabel: this.options.showLabel, + useLabelFunction: this.options.useLabelFunction, + parsedLabelFunction: this.options.parsedLabelFunction, + label: this.options.label + }; break; case 'Circle': entities = this.datasources.filter(mData => !this.isValidCircle(mData)); + labelSettings = { + showLabel: this.options.showCircleLabel, + useLabelFunction: this.options.useCircleLabelFunction, + parsedLabelFunction: this.options.parsedCircleLabelFunction, + label: this.options.circleLabel + }; break; default: return of(null); } + entities = entitiesParseName(entities, labelSettings); if (entities.length === 1) { return of(entities[0]); } @@ -219,38 +239,38 @@ export default abstract class LeafletMap { let customTranslation; switch (type) { case 'tbMarker': - tooltipText = this.translateService.instant('widgets.maps.tooltips.placeMarker', {entityName: data.entityName}); + tooltipText = this.translateService.instant('widgets.maps.tooltips.placeMarker', {entityName: data.entityParseName}); // @ts-ignore this.map.pm.Draw.tbMarker._hintMarker.setTooltipContent(tooltipText); break; case 'tbCircle': - tooltipText = this.translateService.instant('widgets.maps.tooltips.startCircle', {entityName: data.entityName}); + tooltipText = this.translateService.instant('widgets.maps.tooltips.startCircle', {entityName: data.entityParseName}); // @ts-ignore this.map.pm.Draw.tbCircle._hintMarker.setTooltipContent(tooltipText); customTranslation = { tooltips: { - finishCircle: this.translateService.instant('widgets.maps.tooltips.finishCircle', {entityName: data.entityName}) + finishCircle: this.translateService.instant('widgets.maps.tooltips.finishCircle', {entityName: data.entityParseName}) } }; break; case 'tbRectangle': - tooltipText = this.translateService.instant('widgets.maps.tooltips.firstVertex', {entityName: data.entityName}); + tooltipText = this.translateService.instant('widgets.maps.tooltips.firstVertex', {entityName: data.entityParseName}); // @ts-ignore this.map.pm.Draw.tbRectangle._hintMarker.setTooltipContent(tooltipText); customTranslation = { tooltips: { - finishRect: this.translateService.instant('widgets.maps.tooltips.finishRect', {entityName: data.entityName}) + finishRect: this.translateService.instant('widgets.maps.tooltips.finishRect', {entityName: data.entityParseName}) } }; break; case 'tbPolygon': - tooltipText = this.translateService.instant('widgets.maps.tooltips.firstVertex', {entityName: data.entityName}); + tooltipText = this.translateService.instant('widgets.maps.tooltips.firstVertex', {entityName: data.entityParseName}); // @ts-ignore this.map.pm.Draw.tbPolygon._hintMarker.setTooltipContent(tooltipText); customTranslation = { tooltips: { - continueLine: this.translateService.instant('widgets.maps.tooltips.continueLine', {entityName: data.entityName}), - finishPoly: this.translateService.instant('widgets.maps.tooltips.finishPoly', {entityName: data.entityName}) + continueLine: this.translateService.instant('widgets.maps.tooltips.continueLine', {entityName: data.entityParseName}), + finishPoly: this.translateService.instant('widgets.maps.tooltips.finishPoly', {entityName: data.entityParseName}) } }; break; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts index 4b4de38712..4aad829eac 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts @@ -16,9 +16,12 @@ import L from 'leaflet'; import { + GenericFunction, ShowTooltipAction, WidgetToolipSettings } from './map-models'; -import { Datasource } from '@app/shared/models/widget.models'; +import { Datasource, FormattedData } from '@app/shared/models/widget.models'; +import { fillDataPattern, processDataPattern, safeExecute } from '@core/utils'; +import { parseWithTranslation } from '@home/components/widget/lib/maps/common-maps-utils'; export function createTooltip(target: L.Layer, settings: Partial, @@ -83,3 +86,27 @@ export function isJSON(data: string): boolean { return false; } } + +interface labelSettings { + showLabel: boolean; + useLabelFunction: boolean; + parsedLabelFunction: GenericFunction; + label: string; +} + +export function entitiesParseName(entities: FormattedData[], labelSettings: labelSettings): FormattedData[] { + const div = document.createElement('div'); + for (const entity of entities) { + if (labelSettings?.showLabel) { + const pattern = labelSettings.useLabelFunction ? safeExecute(labelSettings.parsedLabelFunction, + [entity, entities, entity.dsIndex]) : labelSettings.label; + const markerLabelText = parseWithTranslation.prepareProcessPattern(pattern, true); + const replaceInfoLabelMarker = processDataPattern(pattern, entity); + div.innerHTML = fillDataPattern(markerLabelText, replaceInfoLabelMarker, entity); + entity.entityParseName = div.textContent || div.innerText || ''; + } else { + entity.entityParseName = entity.entityName; + } + } + return entities; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.html index 1c3df367c4..16fb6090e8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -, private utils: UtilsService, @Inject(HOME_COMPONENTS_MODULE_TOKEN) public homeComponentsModule: Type, + @Inject(WIDGET_COMPONENTS_MODULE_TOKEN) public widgetComponentsModule: Type, private cd: ChangeDetectorRef) { super(store); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html index 256f6e7f9f..0a757fd34f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html @@ -85,6 +85,39 @@ {{key.label}} +
+ + {{key.label}} + + + {{key.settings.icon}} + + icon + + + + + {{ getErrorMessageText(key.settings,'required') }} + + + {{ getErrorMessageText(key.settings,'invalidJSON') | translate }} + + +
any; @@ -90,6 +95,7 @@ interface MultipleInputWidgetDataKeySettings { invalidDateErrorMessage?: string; minValueErrorMessage?: string; maxValueErrorMessage?: string; + invalidJsonErrorMessage?: string; useCustomIcon: boolean; icon: string; customIcon: string ; @@ -103,6 +109,9 @@ interface MultipleInputWidgetDataKeySettings { useSetValueFunction?: boolean; setValueFunctionBody?: string; setValueFunction?: ConvertSetValueFunction; + dialogTitle?: string; + saveButtonLabel?: string; + cancelButtonLabel?: string; } interface MultipleInputWidgetDataKey extends DataKey { @@ -161,7 +170,8 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni private fb: UntypedFormBuilder, private attributeService: AttributeService, private translate: TranslateService, - private sanitizer: DomSanitizer) { + private sanitizer: DomSanitizer, + private dialog: MatDialog) { super(store); } @@ -425,6 +435,13 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni case 'select': value = keyValue !== null ? keyValue.toString() : null; break; + case 'JSON': + try { + value = JSON.parse(keyValue); + } catch (e) { + value = keyValue ? keyValue : null; + } + break; default: value = keyValue; } @@ -520,6 +537,10 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni errorMessage = keySettings.invalidDateErrorMessage; defaultMessage = 'widgets.input-widgets.invalid-date'; break; + case 'invalidJSON': + errorMessage = keySettings.invalidJsonErrorMessage; + defaultMessage = 'widgets.input-widgets.json-invalid'; + break; default: return ''; } @@ -670,8 +691,8 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni } }); if (tasks.length) { - forkJoin(tasks).subscribe( - () => { + forkJoin(tasks).subscribe({ + next: () => { this.multipleInputFormGroup.markAsPristine(); this.ctx.detectChanges(); this.isSavingInProgress = false; @@ -680,13 +701,13 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni 1000, 'bottom', 'left', this.toastTargetId); } }, - () => { + error: () => { this.isSavingInProgress = false; if (this.settings.showResultMessage) { this.ctx.showErrorToast(this.translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', this.toastTargetId); } - }); + }}); } else { this.multipleInputFormGroup.markAsPristine(); this.ctx.detectChanges(); @@ -718,4 +739,31 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni }); this.multipleInputFormGroup.markAsPristine(); } + + openEditJSONDialog($event: Event, key: MultipleInputWidgetDataKey, source: MultipleInputWidgetSource) { + if ($event) { + $event.stopPropagation(); + } + const formControl = this.multipleInputFormGroup.controls[key.formId]; + this.dialog.open(JsonObjectEditDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + jsonValue: formControl.value, + title: key.settings.dialogTitle, + saveLabel: key.settings.saveButtonLabel, + cancelLabel: key.settings.cancelButtonLabel + } + }).afterClosed().subscribe( + (res) => { + if (!isEqual(res, formControl.value)) { + formControl.patchValue(res); + formControl.markAsDirty(); + if(!this.settings.showActionButtons) { + this.inputChanged(source, key); + } + } + } + ); + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts index 17f46fda3c..36a2bca18d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts @@ -22,12 +22,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { isDefined, isNumber } from '@core/utils'; import { CanvasDigitalGaugeOptions } from '@home/components/widget/lib/canvas-digital-gauge'; -import * as tinycolor_ from 'tinycolor2'; +import tinycolor from 'tinycolor2'; import { ResizeObserver } from '@juggle/resize-observer'; import GenericOptions = CanvasGauges.GenericOptions; -const tinycolor = tinycolor_; - interface KnobSettings { minValue: number; maxValue: number; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts index 430aafb996..8a048100d4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts @@ -20,7 +20,7 @@ import { WidgetContext } from '@home/models/widget-component.models'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { isDefined } from '@core/utils'; -import * as tinycolor_ from 'tinycolor2'; +import tinycolor from 'tinycolor2'; import { UtilsService } from '@core/services/utils.service'; import { IWidgetSubscription, SubscriptionInfo, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; import { DatasourceType, widgetType } from '@shared/models/widget.models'; @@ -28,8 +28,6 @@ import { EntityType } from '@shared/models/entity-type.models'; import { ResizeObserver } from '@juggle/resize-observer'; import Timeout = NodeJS.Timeout; -const tinycolor = tinycolor_; - const checkStatusPollingInterval = 10000; type RetrieveValueMethod = 'attribute' | 'timeseries'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/home-page/doc-links-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/home-page/doc-links-widget-settings.component.html new file mode 100644 index 0000000000..08d2d8d8e0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/home-page/doc-links-widget-settings.component.html @@ -0,0 +1,23 @@ + +
+ + widgets.documentation.columns + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/home-page/doc-links-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/home-page/doc-links-widget-settings.component.ts new file mode 100644 index 0000000000..7e0205a1e2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/home-page/doc-links-widget-settings.component.ts @@ -0,0 +1,52 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; + +@Component({ + selector: 'tb-doc-links-widget-settings', + templateUrl: './doc-links-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'] +}) +export class DocLinksWidgetSettingsComponent extends WidgetSettingsComponent { + + docLinksWidgetSettingsForm: UntypedFormGroup; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.docLinksWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return { + columns: 3 + }; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.docLinksWidgetSettingsForm = this.fb.group({ + columns: [settings.columns, [Validators.required, Validators.min(1), Validators.max(20)]] + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html index 186583d5c1..3c62810000 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html @@ -66,6 +66,9 @@ {{ 'widgets.input-widgets.datakey-value-type-select' | translate }} + + {{ 'widgets.input-widgets.datakey-value-type-json' | translate }} + @@ -179,6 +182,28 @@ widgets.input-widgets.invalid-date-error-message + + widgets.input-widgets.invalid-JSON-error-message + + + +
+ widgets.input-widgets.dialog-editor-settings + + widgets.input-widgets.title + + +
+ + widgets.input-widgets.save-button-label + + + + widgets.input-widgets.cancel-button-label + + +
widgets.input-widgets.icon-settings diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts index 56ab12605d..7eff1bdc80 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts @@ -24,6 +24,7 @@ import { dataKeySelectOptionValidator } from '@home/components/widget/lib/settings/input/datakey-select-option.component'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { MultipleInputWidgetDataKeyValueType } from '@home/components/widget/lib/multiple-input-widget.component'; @Component({ selector: 'tb-update-multiple-attributes-key-settings', @@ -62,6 +63,11 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings minValueErrorMessage: '', maxValueErrorMessage: '', invalidDateErrorMessage: '', + invalidJsonErrorMessage: '', + + dialogTitle: '', + saveButtonLabel: '', + cancelButtonLabel: '', useCustomIcon: false, icon: '', @@ -107,6 +113,13 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings minValueErrorMessage: [settings.minValueErrorMessage, []], maxValueErrorMessage: [settings.maxValueErrorMessage, []], invalidDateErrorMessage: [settings.invalidDateErrorMessage, []], + invalidJsonErrorMessage: [settings.invalidJsonErrorMessage, []], + + // Dialog settings + + dialogTitle: [settings.dialogTitle, []], + saveButtonLabel: [settings.saveButtonLabel, []], + cancelButtonLabel: [settings.cancelButtonLabel, []], // Icon settings @@ -130,7 +143,8 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings protected updateValidators(emitEvent: boolean) { const dataKeyHidden: boolean = this.updateMultipleAttributesKeySettingsForm.get('dataKeyHidden').value; - const dataKeyValueType: string = this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; + const dataKeyValueType: MultipleInputWidgetDataKeyValueType = + this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; const required: boolean = this.updateMultipleAttributesKeySettingsForm.get('required').value; const isEditable: string = this.updateMultipleAttributesKeySettingsForm.get('isEditable').value; const useCustomIcon: boolean = this.updateMultipleAttributesKeySettingsForm.get('useCustomIcon').value; @@ -165,6 +179,11 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings this.updateMultipleAttributesKeySettingsForm.get('maxValueErrorMessage').enable({emitEvent: false}); } else if (dataKeyValueType === 'dateTime' || dataKeyValueType === 'date' || dataKeyValueType === 'time') { this.updateMultipleAttributesKeySettingsForm.get('invalidDateErrorMessage').enable({emitEvent: false}); + } else if (dataKeyValueType === 'JSON') { + this.updateMultipleAttributesKeySettingsForm.get('invalidJsonErrorMessage').enable({emitEvent: false}); + this.updateMultipleAttributesKeySettingsForm.get('dialogTitle').enable({emitEvent: false}); + this.updateMultipleAttributesKeySettingsForm.get('saveButtonLabel').enable({emitEvent: false}); + this.updateMultipleAttributesKeySettingsForm.get('cancelButtonLabel').enable({emitEvent: false}); } if (required) { this.updateMultipleAttributesKeySettingsForm.get('requiredErrorMessage').enable({emitEvent: false}); @@ -243,8 +262,9 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings displayErrorMessagesSection(): boolean { const dataKeyHidden: boolean = this.updateMultipleAttributesKeySettingsForm.get('dataKeyHidden').value; const required: boolean = this.updateMultipleAttributesKeySettingsForm.get('required').value; - const dataKeyValueType: string = this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; - return !dataKeyHidden && (required || (['integer', 'double', 'dateTime', 'date', 'time'].includes(dataKeyValueType))); + const dataKeyValueType: MultipleInputWidgetDataKeyValueType = + this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; + return !dataKeyHidden && (required || (['integer', 'double', 'dateTime', 'date', 'time', 'JSON'].includes(dataKeyValueType))); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts index ef7668f306..73ff1433a9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts @@ -259,6 +259,9 @@ import { import { TripAnimationPointSettingsComponent } from '@home/components/widget/lib/settings/map/trip-animation-point-settings.component'; +import { + DocLinksWidgetSettingsComponent +} from '@home/components/widget/lib/settings/home-page/doc-links-widget-settings.component'; @NgModule({ declarations: [ @@ -357,7 +360,8 @@ import { TripAnimationPointSettingsComponent, MapWidgetSettingsComponent, RouteMapWidgetSettingsComponent, - TripAnimationWidgetSettingsComponent + TripAnimationWidgetSettingsComponent, + DocLinksWidgetSettingsComponent ], imports: [ CommonModule, @@ -460,7 +464,8 @@ import { TripAnimationPointSettingsComponent, MapWidgetSettingsComponent, RouteMapWidgetSettingsComponent, - TripAnimationWidgetSettingsComponent + TripAnimationWidgetSettingsComponent, + DocLinksWidgetSettingsComponent ] }) export class WidgetSettingsModule { @@ -527,5 +532,6 @@ export const widgetSettingsComponentsMap: {[key: string]: Type -
+
diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts index 88b5ddca91..2c79ef7b68 100644 --- a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts @@ -285,7 +285,7 @@ export class DashboardWidgets implements Iterable { return this.dashboardWidgets.find((dashboardWidget) => dashboardWidget.widgetId === widgetId); } - private updateRowsAndSort() { + updateRowsAndSort() { let maxRows = this.dashboard.gridsterOpts.maxRows; this.activeDashboardWidgets.forEach((dashboardWidget) => { const bottom = dashboardWidget.y + dashboardWidget.rows; @@ -300,6 +300,13 @@ export class DashboardWidgets implements Iterable { this.dashboardWidgets.sort((widget1, widget2) => { const row1 = widget1.widgetOrder; const row2 = widget2.widgetOrder; + if (isDefined(row1) && isUndefined(row2)) { + return -1; + } else if (isUndefined(row1) && isDefined(row2)) { + return 1; + } else if (isUndefined(row1) && isUndefined(row2)) { + return 0; + } let res = row1 - row2; if (res === 0) { res = widget1.x - widget2.x; @@ -627,10 +634,12 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { order = this.widgetLayout.mobileOrder; } else if (isDefined(this.widget.config.mobileOrder) && this.widget.config.mobileOrder >= 0) { order = this.widget.config.mobileOrder; - } else if (this.widgetLayout) { - order = this.widgetLayout.row; - } else { - order = this.widget.row; + } else if (!this.dashboard.isMobileSize) { + if (this.widgetLayout) { + order = this.widgetLayout.row; + } else { + order = this.widget.row; + } } return order; } diff --git a/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts b/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts index bbc8d76951..033747f6d3 100644 --- a/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts @@ -33,6 +33,8 @@ import { } from '@home/models/entity/entities-table-config.models'; import { ActivatedRoute } from '@angular/router'; +export type EntitiesTableAction = 'add'; + export interface IEntitiesTableComponent { entitiesTableConfig: EntityTableConfig>; translations: EntityTypeTranslation; diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge.component.html b/ui-ngx/src/app/modules/home/pages/edge/edge.component.html index 15759508c2..ef0c452a7b 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge.component.html +++ b/ui-ngx/src/app/modules/home/pages/edge/edge.component.html @@ -40,49 +40,48 @@ [fxShow]="!isEdit && (edgeScope === 'customer' || edgeScope === 'tenant') && isAssignedToCustomer(entity)"> {{ (entity?.customerIsPublic ? 'edge.make-private' : 'edge.unassign-from-customer') | translate }} + + + + + -
- - - - - -
-
+
+ + + {{ 'notification.new-platform-version-trigger-settings' | translate }} +
+
+ + notification.description + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.ts index 1461cf5db3..ea3c59992c 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.ts @@ -94,6 +94,7 @@ export class RuleNotificationDialogComponent extends ruleEngineEventsTemplateForm: FormGroup; entitiesLimitTemplateForm: FormGroup; apiUsageLimitTemplateForm: FormGroup; + newPlatformVersionTemplateForm: FormGroup; triggerType = TriggerType; triggerTypes: TriggerType[]; @@ -294,6 +295,12 @@ export class RuleNotificationDialogComponent extends }) }); + this.newPlatformVersionTemplateForm = this.fb.group({ + triggerConfig: this.fb.group({ + + }) + }); + this.triggerTypeFormsMap = new Map([ [TriggerType.ALARM, this.alarmTemplateForm], [TriggerType.ALARM_COMMENT, this.alarmCommentTemplateForm], @@ -302,7 +309,8 @@ export class RuleNotificationDialogComponent extends [TriggerType.ALARM_ASSIGNMENT, this.alarmAssignmentTemplateForm], [TriggerType.RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT, this.ruleEngineEventsTemplateForm], [TriggerType.ENTITIES_LIMIT, this.entitiesLimitTemplateForm], - [TriggerType.API_USAGE_LIMIT, this.apiUsageLimitTemplateForm] + [TriggerType.API_USAGE_LIMIT, this.apiUsageLimitTemplateForm], + [TriggerType.NEW_PLATFORM_VERSION, this.newPlatformVersionTemplateForm] ]); if (data.isAdd || data.isCopy) { @@ -434,10 +442,16 @@ export class RuleNotificationDialogComponent extends } private allowTriggerTypes(): TriggerType[] { + const sysAdminAllowTriggerTypes = new Set([ + TriggerType.ENTITIES_LIMIT, + TriggerType.API_USAGE_LIMIT, + TriggerType.NEW_PLATFORM_VERSION, + ]); + if (this.isSysAdmin()) { - return [TriggerType.ENTITIES_LIMIT, TriggerType.API_USAGE_LIMIT]; + return Array.from(sysAdminAllowTriggerTypes); } - return Object.values(TriggerType).filter(type => type !== TriggerType.ENTITIES_LIMIT && type !== TriggerType.API_USAGE_LIMIT); + return Object.values(TriggerType).filter(type => !sysAdminAllowTriggerTypes.has(type)); } get allowEntityTypeForEntityAction(): EntityType[] { diff --git a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html index 3aa7c98069..7fbde30dce 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html @@ -59,10 +59,8 @@
- +
notification.at-least-one-should-be-selected
+ [stepControl]="emailTemplateForm" #emailStep="matStep"> {{ 'notification.delivery-method.email' | translate }}
@@ -228,8 +226,14 @@ {{ 'notification.subject-required' | translate }} - notification.message + notification.message + + {{ 'notification.message-required' | translate }} + @@ -246,7 +250,7 @@ trigger-text="{{ 'notification.see-documentation' | translate }}">
- + notification.message