diff --git a/application/src/main/data/json/system/widget_bundles/charts.json b/application/src/main/data/json/system/widget_bundles/charts.json index 979b241e67..a66975d1e2 100644 --- a/application/src/main/data/json/system/widget_bundles/charts.json +++ b/application/src/main/data/json/system/widget_bundles/charts.json @@ -12,10 +12,11 @@ "line_chart", "bar_chart", "point_chart", + "state_chart", + "bar_chart_with_labels", + "range_chart", "charts.basic_timeseries", "charts.state_chart", - "range_chart", - "bar_chart_with_labels", "charts.timeseries_bars_flot", "cards.aggregated_value_card", "charts.bars", diff --git a/application/src/main/data/json/system/widget_types/state_chart.json b/application/src/main/data/json/system/widget_types/state_chart.json index a78fc06f64..0af2631813 100644 --- a/application/src/main/data/json/system/widget_types/state_chart.json +++ b/application/src/main/data/json/system/widget_types/state_chart.json @@ -1,26 +1,34 @@ { - "fqn": "charts.state_chart", - "name": "State Chart", + "fqn": "state_chart", + "name": "State chart", "deprecated": false, - "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAB9VBMVEUAAAAhlvMilvMymeE+nNVDoetInslNq/VZqOdpuPd3d3d5p5Z5suB6enp8fHyBgYGDg4ODqoqEhISIiIiKioqKuN2MjIyNjY2Ojo6QkJCRkZGSkpKUlJSVlZWWlpaXl5eYmJiZmZmampqcnJydnZ2enp6goKCgr3KhoaGioqKisXSjo6OjvsmkpKSlpaWlwdempqanp6eoqKiosGOo1vqpqampsGOqqqqqsWOrq6usrKysw9Wurq6wsLCysrK0tLS1tbW1wbK2tra3t7e4wau5ubm6urq7u7u8vLy9vb2+vr6/xbTAwMDAxbjCwsLC3ejDw8PExMTFxcXGxsbHx8fIv3jIyMjJycnKysrK5vzMzc7Nzc3Ozs7Pz8/QuDnRzcHS0tLT09PT39HU1NTU6/3VzLLV1dXV3sjW1tbX19fYuTHY2NjZ2dna0Ira2trb29vc3Nzex4De3t7f39/guyvhvCfhvCvh4eHh6Nbi4uLjvCTj4+PkyXbk5OTlvCTm5ubm693o6Ojpxl7q6urr6+vswTTs7Ozt7e3uvhju7u7vvhjv7+/wvxjw8PDx8fHyvxX0yTv09PT19fX29vb39/f4wyL4+Pj5+fn6+vr75J37+/v8whP8/Pz9/f39/v/+/v7/wQf/xRb/3HT/5JH/9tz/++/////APs7XAAAAAWJLR0Smt7AblQAAA1RJREFUeNrt3dlT01AUBvAEd8UNl2oLrdrFolZArUul1hWlVhQXFAUF1xaxIqi4FUQUrDsUClZi4nb+Th96S9NQkjDOOKZ+3wuZw8md++M25OXMlKOccJQnTUYocdRqdw8UAqTfOjG4lvr7uowOqbtAtG6o5OiGFoNDapuJHO8tFK4xOKS9msTVgoUiaQjPclnSl01snZ/y4ly2yBNZbRarbc+3yvdpt/hLawOPJp9ur7O0mRiEmzFkY1M6N/4I8qJpulzR2sBx1sgRjQuyA2pk+STdylw2VqR/FPFfWdcCvjhduiM7kVF2db1Nuspu7JGes+I76SRba7f0Wfnn/6F6It+mfo7m8TvYvh7KTiT3PQIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggPwXkFa7e7AQIP3WiUFzdl7LuBDFvJZxIYp5LeNCFPNaBn7Yc+e1KlheSYcrFCniL7LZqPl8cbp0TjavNcqu9rZJB9gdPdIJVnwjbWG1ndI95UzWM9V5rS9Ti3P4VWy1+5jXAgQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAMK+FeS3Ma2FeC/NamNfCCxEQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBA/hZkTKSkwSGn7VUJOmOLV241NiRSRZ2uYcvt+Io+Y0N83USm+MqGWEnU2JCqXqI1ZE+QhYiIy36tXR5INpOQbB5kftcmK85mtdey2iJekeWqX6/3kc+TLCQTrq6eEuZCgKTsbnNXBsIZOERJMedNQnry73XpW8UAKVhIyBPScVfQE9RqEaP7iMT6g+pdw7vKbxKl3IJqV8K7OUqXPHvG9UMGykk2lz1d4g5yvNXo6QqZiA41aHT5OwQTUWBJSrWrvlMwj1jFU2f1Q8K1dCysCUmVhdenNLvMRKZt3hGNriEnxfxlGqt9aPYRkb9bP6Q1SMFrmltMukKuMT2QpckWjc/WhP2lYB/TgjzdHyCKVM/gGXnsp+qY5hbba+hIVA/ETL0+9SepsoNiTs/igGpXZIhKqVv9QVJARIfXIWqfiM1nS+qBnHdae1V7Qss8ngEijRO5a6sMCAtdqv+HfgPwpNPbU6ipOwAAAABJRU5ErkJggg==", + "image": "tb-image:Y2hhcnRfKDUpLnN2Zw==:IlN0YXRlIGNoYXJ0IiBzeXN0ZW0gd2lkZ2V0IGltYWdl;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF80NDM3Xzk2ODU4KSI+CjxwYXRoIGQ9Ik0yLjc2NTYyIDEuODE2NDFMMC44ODI4MTIgN0gwLjExMzI4MUwyLjI4MTI1IDEuMzEyNUgyLjc3NzM0TDIuNzY1NjIgMS44MTY0MVpNNC4zNDM3NSA3TDIuNDU3MDMgMS44MTY0MUwyLjQ0NTMxIDEuMzEyNUgyLjk0MTQxTDUuMTE3MTkgN0g0LjM0Mzc1Wk00LjI0NjA5IDQuODk0NTNWNS41MTE3MkgxLjA1MDc4VjQuODk0NTNINC4yNDYwOVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPHBhdGggZD0iTTMuNjg3NSAzMi41NDY0SDIuMjQ2MDlMMi4yMzgyOCAzMS45NDA5SDMuNTQ2ODhDMy43NjMwMiAzMS45NDA5IDMuOTUxODIgMzEuOTA0NSA0LjExMzI4IDMxLjgzMTVDNC4yNzQ3NCAzMS43NTg2IDQuMzk5NzQgMzEuNjU0NSA0LjQ4ODI4IDMxLjUxOUM0LjU3OTQzIDMxLjM4MSA0LjYyNSAzMS4yMTcgNC42MjUgMzEuMDI2OUM0LjYyNSAzMC44MTg1IDQuNTg0NjQgMzAuNjQ5MyA0LjUwMzkxIDMwLjUxOUM0LjQyNTc4IDMwLjM4NjIgNC4zMDQ2OSAzMC4yODk5IDQuMTQwNjIgMzAuMjNDMy45NzkxNyAzMC4xNjc1IDMuNzczNDQgMzAuMTM2MiAzLjUyMzQ0IDMwLjEzNjJIMi40MTQwNlYzNS4yMDY1SDEuNjYwMTZWMjkuNTE5SDMuNTIzNDRDMy44MTUxIDI5LjUxOSA0LjA3NTUyIDI5LjU0OSA0LjMwNDY5IDI5LjYwODlDNC41MzM4NSAyOS42NjYyIDQuNzI3ODYgMjkuNzU3MyA0Ljg4NjcyIDI5Ljg4MjNDNS4wNDgxOCAzMC4wMDQ3IDUuMTcwNTcgMzAuMTYxIDUuMjUzOTEgMzAuMzUxMUM1LjMzNzI0IDMwLjU0MTIgNS4zNzg5MSAzMC43NjkgNS4zNzg5MSAzMS4wMzQ3QzUuMzc4OTEgMzEuMjY5IDUuMzE5MDEgMzEuNDgxMyA1LjE5OTIyIDMxLjY3MTRDNS4wNzk0MyAzMS44NTg5IDQuOTEyNzYgMzIuMDEyNSA0LjY5OTIyIDMyLjEzMjNDNC40ODgyOCAzMi4yNTIxIDQuMjQwODkgMzIuMzI4OSAzLjk1NzAzIDMyLjM2MjhMMy42ODc1IDMyLjU0NjRaTTMuNjUyMzQgMzUuMjA2NUgxLjk0OTIyTDIuMzc1IDM0LjU5MzNIMy42NTIzNEMzLjg5MTkzIDM0LjU5MzMgNC4wOTUwNSAzNC41NTE2IDQuMjYxNzIgMzQuNDY4M0M0LjQzMDk5IDM0LjM4NDkgNC41NTk5IDM0LjI2NzcgNC42NDg0NCAzNC4xMTY3QzQuNzM2OTggMzMuOTYzMSA0Ljc4MTI1IDMzLjc4MjEgNC43ODEyNSAzMy41NzM3QzQuNzgxMjUgMzMuMzYyOCA0Ljc0MzQ5IDMzLjE4MDUgNC42Njc5NyAzMy4wMjY5QzQuNTkyNDUgMzIuODczMiA0LjQ3Mzk2IDMyLjc1NDcgNC4zMTI1IDMyLjY3MTRDNC4xNTEwNCAzMi41ODgxIDMuOTQyNzEgMzIuNTQ2NCAzLjY4NzUgMzIuNTQ2NEgyLjYxMzI4TDIuNjIxMDkgMzEuOTQwOUg0LjA4OTg0TDQuMjUgMzIuMTU5N0M0LjUyMzQ0IDMyLjE4MzEgNC43NTUyMSAzMi4yNjEyIDQuOTQ1MzEgMzIuMzk0QzUuMTM1NDIgMzIuNTI0MyA1LjI3OTk1IDMyLjY5MDkgNS4zNzg5MSAzMi44OTRDNS40ODA0NyAzMy4wOTcyIDUuNTMxMjUgMzMuMzIxMSA1LjUzMTI1IDMzLjU2NTlDNS41MzEyNSAzMy45MjAxIDUuNDUzMTIgMzQuMjE5NiA1LjI5Njg4IDM0LjQ2NDRDNS4xNDMyMyAzNC43MDY1IDQuOTI1NzggMzQuODkxNCA0LjY0NDUzIDM1LjAxOUM0LjM2MzI4IDM1LjE0NCA0LjAzMjU1IDM1LjIwNjUgMy42NTIzNCAzNS4yMDY1WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8cGF0aCBkPSJNNC4wOTM3NSA2MS42MDVINC44NDM3NUM0LjgwNDY5IDYxLjk2NDQgNC43MDE4MiA2Mi4yODYgNC41MzUxNiA2Mi41Njk4QzQuMzY4NDkgNjIuODUzNyA0LjEzMjgxIDYzLjA3ODkgMy44MjgxMiA2My4yNDU2QzMuNTIzNDQgNjMuNDA5NyAzLjE0MzIzIDYzLjQ5MTcgMi42ODc1IDYzLjQ5MTdDMi4zNTQxNyA2My40OTE3IDIuMDUwNzggNjMuNDI5MiAxLjc3NzM0IDYzLjMwNDJDMS41MDY1MSA2My4xNzkyIDEuMjczNDQgNjMuMDAyMSAxLjA3ODEyIDYyLjc3MjlDMC44ODI4MTIgNjIuNTQxMiAwLjczMTc3MSA2Mi4yNjM4IDAuNjI1IDYxLjk0MDlDMC41MjA4MzMgNjEuNjE1NCAwLjQ2ODc1IDYxLjI1MzQgMC40Njg3NSA2MC44NTVWNjAuMjg4NkMwLjQ2ODc1IDU5Ljg5MDEgMC41MjA4MzMgNTkuNTI5NSAwLjYyNSA1OS4yMDY1QzAuNzMxNzcxIDU4Ljg4MSAwLjg4NDExNSA1OC42MDI0IDEuMDgyMDMgNTguMzcwNkMxLjI4MjU1IDU4LjEzODggMS41MjM0NCA1Ny45NjA0IDEuODA0NjkgNTcuODM1NEMyLjA4NTk0IDU3LjcxMDQgMi40MDIzNCA1Ny42NDc5IDIuNzUzOTEgNTcuNjQ3OUMzLjE4MzU5IDU3LjY0NzkgMy41NDY4OCA1Ny43Mjg3IDMuODQzNzUgNTcuODkwMUM0LjE0MDYyIDU4LjA1MTYgNC4zNzEwOSA1OC4yNzU2IDQuNTM1MTYgNTguNTYyQzQuNzAxODIgNTguODQ1OSA0LjgwNDY5IDU5LjE3NTMgNC44NDM3NSA1OS41NTAzSDQuMDkzNzVDNC4wNTcyOSA1OS4yODQ3IDMuOTg5NTggNTkuMDU2OCAzLjg5MDYyIDU4Ljg2NjdDMy43OTE2NyA1OC42NzQgMy42NTEwNCA1OC41MjU2IDMuNDY4NzUgNTguNDIxNEMzLjI4NjQ2IDU4LjMxNzIgMy4wNDgxOCA1OC4yNjUxIDIuNzUzOTEgNTguMjY1MUMyLjUwMTMgNTguMjY1MSAyLjI3ODY1IDU4LjMxMzMgMi4wODU5NCA1OC40MDk3QzEuODk1ODMgNTguNTA2IDEuNzM1NjggNTguNjQyNyAxLjYwNTQ3IDU4LjgxOThDMS40Nzc4NiA1OC45OTY5IDEuMzgxNTEgNTkuMjA5MSAxLjMxNjQxIDU5LjQ1NjVDMS4yNTEzIDU5LjcwMzkgMS4yMTg3NSA1OS45Nzg3IDEuMjE4NzUgNjAuMjgwOFY2MC44NTVDMS4yMTg3NSA2MS4xMzM2IDEuMjQ3NCA2MS4zOTUzIDEuMzA0NjkgNjEuNjQwMUMxLjM2NDU4IDYxLjg4NDkgMS40NTQ0MyA2Mi4wOTk4IDEuNTc0MjIgNjIuMjg0N0MxLjY5NDAxIDYyLjQ2OTYgMS44NDYzNSA2Mi42MTU0IDIuMDMxMjUgNjIuNzIyMkMyLjIxNjE1IDYyLjgyNjMgMi40MzQ5IDYyLjg3ODQgMi42ODc1IDYyLjg3ODRDMy4wMDc4MSA2Mi44Nzg0IDMuMjYzMDIgNjIuODI3NiAzLjQ1MzEyIDYyLjcyNjFDMy42NDMyMyA2Mi42MjQ1IDMuNzg2NDYgNjIuNDc4NyAzLjg4MjgxIDYyLjI4ODZDMy45ODE3NyA2Mi4wOTg1IDQuMDUyMDggNjEuODcwNiA0LjA5Mzc1IDYxLjYwNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPHBhdGggZD0iTTIuMTk5MjIgOTEuNjIwMUgxLjAxMTcyTDEuMDE5NTMgOTEuMDA2OEgyLjE5OTIyQzIuNjA1NDcgOTEuMDA2OCAyLjk0NDAxIDkwLjkyMjIgMy4yMTQ4NCA5MC43NTI5QzMuNDg1NjggOTAuNTgxMSAzLjY4ODggOTAuMzQxNSAzLjgyNDIyIDkwLjAzNDJDMy45NjIyNCA4OS43MjQzIDQuMDMxMjUgODkuMzYyMyA0LjAzMTI1IDg4Ljk0ODJWODguNjAwNkM0LjAzMTI1IDg4LjI3NTEgMy45OTIxOSA4Ny45ODYgMy45MTQwNiA4Ny43MzM0QzMuODM1OTQgODcuNDc4MiAzLjcyMTM1IDg3LjI2MzMgMy41NzAzMSA4Ny4wODg5QzMuNDE5MjcgODYuOTExOCAzLjIzNDM4IDg2Ljc3NzcgMy4wMTU2MiA4Ni42ODY1QzIuNzk5NDggODYuNTk1NCAyLjU1MDc4IDg2LjU0OTggMi4yNjk1MyA4Ni41NDk4SDAuOTg4MjgxVjg1LjkzMjZIMi4yNjk1M0MyLjY0MTkzIDg1LjkzMjYgMi45ODE3NyA4NS45OTUxIDMuMjg5MDYgODYuMTIwMUMzLjU5NjM1IDg2LjI0MjUgMy44NjA2OCA4Ni40MjA5IDQuMDgyMDMgODYuNjU1M0M0LjMwNTk5IDg2Ljg4NyA0LjQ3Nzg2IDg3LjE2ODMgNC41OTc2NiA4Ny40OTlDNC43MTc0NSA4Ny44MjcxIDQuNzc3MzQgODguMTk2OSA0Ljc3NzM0IDg4LjYwODRWODguOTQ4MkM0Ljc3NzM0IDg5LjM1OTcgNC43MTc0NSA4OS43MzA4IDQuNTk3NjYgOTAuMDYxNUM0LjQ3Nzg2IDkwLjM4OTYgNC4zMDQ2OSA5MC42Njk2IDQuMDc4MTIgOTAuOTAxNEMzLjg1NDE3IDkxLjEzMzEgMy41ODMzMyA5MS4zMTE1IDMuMjY1NjIgOTEuNDM2NUMyLjk1MDUyIDkxLjU1ODkgMi41OTUwNSA5MS42MjAxIDIuMTk5MjIgOTEuNjIwMVpNMS40MTQwNiA4NS45MzI2VjkxLjYyMDFIMC42NjAxNTZWODUuOTMyNkgxLjQxNDA2WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8cGF0aCBkPSJNNS4yNzM0NCAxMTkuMjE0VjExOS44MjdIMi4yNjE3MlYxMTkuMjE0SDUuMjczNDRaTTIuNDE0MDYgMTE0LjE0VjExOS44MjdIMS42NjAxNlYxMTQuMTRIMi40MTQwNlpNNC44NzUgMTE2LjU4NVYxMTcuMTk4SDIuMjYxNzJWMTE2LjU4NUg0Ljg3NVpNNS4yMzQzOCAxMTQuMTRWMTE0Ljc1N0gyLjI2MTcyVjExNC4xNEg1LjIzNDM4WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8cGF0aCBkPSJNMTEgNC4xNjExM0wxODYgNC4xNjExNiIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiLz4KPHBhdGggZD0iTTExIDMzLjE2MTFMMTg2IDMzLjE2MTIiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjEyIi8+CjxwYXRoIGQ9Ik0xMSA2MS4xNjExTDE4NiA2MS4xNjEyIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC4xMiIvPgo8cGF0aCBkPSJNMTEgODkuMTYxMUwxODYgODkuMTYxMiIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiLz4KPHBhdGggZD0iTTExIDExOC4xNjFMMTg2IDExOC4xNjEiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjEyIi8+CjxsaW5lIHgxPSI5LjIiIHkxPSIxNDUuOTYxIiB4Mj0iMTg4LjgiIHkyPSIxNDUuOTYxIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC43IiBzdHJva2Utd2lkdGg9IjAuNCIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8bGluZSB4MT0iMjYuNDUwMiIgeTE9IjE0OC4wNzIiIHgyPSIyNi40NTAyIiB5Mj0iMTQ3LjI1IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8cGF0aCBkPSJNMTguNTA5MSAxNTIuMDI1VjE1Mi44OTNDMTguNTA5MSAxNTMuMzU5IDE4LjQ2NzQgMTUzLjc1MiAxOC4zODQxIDE1NC4wNzJDMTguMzAwNyAxNTQuMzkzIDE4LjE4MDkgMTU0LjY1IDE4LjAyNDcgMTU0Ljg0NkMxNy44Njg0IDE1NS4wNDEgMTcuNjc5NiAxNTUuMTgzIDE3LjQ1ODMgMTU1LjI3MUMxNy4yMzk1IDE1NS4zNTcgMTYuOTkyMSAxNTUuNCAxNi43MTYxIDE1NS40QzE2LjQ5NzMgMTU1LjQgMTYuMjk1NSAxNTUuMzczIDE2LjExMDYgMTU1LjMxOEMxNS45MjU3IDE1NS4yNjQgMTUuNzU5MSAxNTUuMTc2IDE1LjYxMDYgMTU1LjA1N0MxNS40NjQ4IDE1NC45MzQgMTUuMzM5OCAxNTQuNzc1IDE1LjIzNTYgMTU0LjU4QzE1LjEzMTUgMTU0LjM4NSAxNS4wNTIgMTU0LjE0OCAxNC45OTczIDE1My44NjlDMTQuOTQyNyAxNTMuNTkgMTQuOTE1MyAxNTMuMjY1IDE0LjkxNTMgMTUyLjg5M1YxNTIuMDI1QzE0LjkxNTMgMTUxLjU1OSAxNC45NTcgMTUxLjE2OSAxNS4wNDAzIDE1MC44NTRDMTUuMTI2MiAxNTAuNTM4IDE1LjI0NzMgMTUwLjI4NiAxNS40MDM2IDE1MC4wOTZDMTUuNTU5OCAxNDkuOTAzIDE1Ljc0NzMgMTQ5Ljc2NSAxNS45NjYxIDE0OS42ODJDMTYuMTg3NCAxNDkuNTk4IDE2LjQzNDggMTQ5LjU1NyAxNi43MDgzIDE0OS41NTdDMTYuOTI5NiAxNDkuNTU3IDE3LjEzMjggMTQ5LjU4NCAxNy4zMTc3IDE0OS42MzlDMTcuNTA1MiAxNDkuNjkxIDE3LjY3MTggMTQ5Ljc3NSAxNy44MTc3IDE0OS44OTNDMTcuOTYzNSAxNTAuMDA3IDE4LjA4NzIgMTUwLjE2MSAxOC4xODg3IDE1MC4zNTRDMTguMjkyOSAxNTAuNTQ0IDE4LjM3MjMgMTUwLjc3NyAxOC40MjcgMTUxLjA1M0MxOC40ODE3IDE1MS4zMjkgMTguNTA5MSAxNTEuNjUzIDE4LjUwOTEgMTUyLjAyNVpNMTcuNzgyNSAxNTMuMDFWMTUxLjkwNEMxNy43ODI1IDE1MS42NDkgMTcuNzY2OSAxNTEuNDI1IDE3LjczNTYgMTUxLjIzMkMxNy43MDcgMTUxLjAzNyAxNy42NjQgMTUwLjg3IDE3LjYwNjcgMTUwLjczMkMxNy41NDk0IDE1MC41OTQgMTcuNDc2NSAxNTAuNDgyIDE3LjM4OCAxNTAuMzk2QzE3LjMwMiAxNTAuMzExIDE3LjIwMTggMTUwLjI0OCAxNy4wODcyIDE1MC4yMDlDMTYuOTc1MiAxNTAuMTY3IDE2Ljg0ODkgMTUwLjE0NiAxNi43MDgzIDE1MC4xNDZDMTYuNTM2NCAxNTAuMTQ2IDE2LjM4NDEgMTUwLjE3OSAxNi4yNTEyIDE1MC4yNDRDMTYuMTE4NCAxNTAuMzA3IDE2LjAwNjUgMTUwLjQwNyAxNS45MTUzIDE1MC41NDVDMTUuODI2OCAxNTAuNjgzIDE1Ljc1OTEgMTUwLjg2NCAxNS43MTIyIDE1MS4wODhDMTUuNjY1MyAxNTEuMzEyIDE1LjY0MTkgMTUxLjU4NCAxNS42NDE5IDE1MS45MDRWMTUzLjAxQzE1LjY0MTkgMTUzLjI2NSAxNS42NTYyIDE1My40OSAxNS42ODQ4IDE1My42ODZDMTUuNzE2MSAxNTMuODgxIDE1Ljc2MTcgMTU0LjA1IDE1LjgyMTYgMTU0LjE5M0MxNS44ODE1IDE1NC4zMzQgMTUuOTU0NCAxNTQuNDUgMTYuMDQwMyAxNTQuNTQxQzE2LjEyNjIgMTU0LjYzMiAxNi4yMjUyIDE1NC43IDE2LjMzNzIgMTU0Ljc0NEMxNi40NTE4IDE1NC43ODYgMTYuNTc4MSAxNTQuODA3IDE2LjcxNjEgMTU0LjgwN0MxNi44OTMyIDE1NC44MDcgMTcuMDQ4MSAxNTQuNzczIDE3LjE4MDkgMTU0LjcwNUMxNy4zMTM3IDE1NC42MzcgMTcuNDI0NCAxNTQuNTMyIDE3LjUxMyAxNTQuMzg5QzE3LjYwNDEgMTU0LjI0MyAxNy42NzE4IDE1NC4wNTcgMTcuNzE2MSAxNTMuODNDMTcuNzYwNCAxNTMuNjAxIDE3Ljc4MjUgMTUzLjMyNyAxNy43ODI1IDE1My4wMVpNMjEuODk2NCAxNDkuNjA0VjE1NS4zMjJIMjEuMTczN1YxNTAuNTA2TDE5LjcxNjcgMTUxLjAzN1YxNTAuMzg1TDIxLjc4MzEgMTQ5LjYwNEgyMS44OTY0Wk0yNy4xMTI0IDE0OS42MzVWMTU1LjMyMkgyNi4zNTg1VjE0OS42MzVIMjcuMTEyNFpNMjkuNDk1MiAxNTIuMTkzVjE1Mi44MTFIMjYuOTQ4M1YxNTIuMTkzSDI5LjQ5NTJaTTI5Ljg4MTkgMTQ5LjYzNVYxNTAuMjUySDI2Ljk0ODNWMTQ5LjYzNUgyOS44ODE5Wk0zMi40MjE2IDE1NS40QzMyLjEyNzMgMTU1LjQgMzEuODYwNCAxNTUuMzUxIDMxLjYyMDggMTU1LjI1MkMzMS4zODM4IDE1NS4xNSAzMS4xNzk0IDE1NS4wMDggMzEuMDA3NSAxNTQuODI2QzMwLjgzODMgMTU0LjY0NCAzMC43MDgxIDE1NC40MjggMzAuNjE2OSAxNTQuMTc4QzMwLjUyNTggMTUzLjkyOCAzMC40ODAyIDE1My42NTQgMzAuNDgwMiAxNTMuMzU3VjE1My4xOTNDMzAuNDgwMiAxNTIuODUgMzAuNTMxIDE1Mi41NDQgMzAuNjMyNSAxNTIuMjc1QzMwLjczNDEgMTUyLjAwNSAzMC44NzIxIDE1MS43NzUgMzEuMDQ2NiAxNTEuNTg4QzMxLjIyMTEgMTUxLjQgMzEuNDE5IDE1MS4yNTggMzEuNjQwMyAxNTEuMTYyQzMxLjg2MTcgMTUxLjA2NiAzMi4wOTA5IDE1MS4wMTggMzIuMzI3OCAxNTEuMDE4QzMyLjYyOTkgMTUxLjAxOCAzMi44OTAzIDE1MS4wNyAzMy4xMDkxIDE1MS4xNzRDMzMuMzMwNSAxNTEuMjc4IDMzLjUxMTQgMTUxLjQyNCAzMy42NTIxIDE1MS42MTFDMzMuNzkyNyAxNTEuNzk2IDMzLjg5NjkgMTUyLjAxNSAzMy45NjQ2IDE1Mi4yNjhDMzQuMDMyMyAxNTIuNTE4IDM0LjA2NjEgMTUyLjc5MSAzNC4wNjYxIDE1My4wODhWMTUzLjQxMkgzMC45MDk5VjE1Mi44MjJIMzMuMzQzNVYxNTIuNzY4QzMzLjMzMzEgMTUyLjU4IDMzLjI5NCAxNTIuMzk4IDMzLjIyNjMgMTUyLjIyMUMzMy4xNjEyIDE1Mi4wNDQgMzMuMDU3IDE1MS44OTggMzIuOTEzOCAxNTEuNzgzQzMyLjc3MDYgMTUxLjY2OSAzMi41NzUyIDE1MS42MTEgMzIuMzI3OCAxNTEuNjExQzMyLjE2MzggMTUxLjYxMSAzMi4wMTI3IDE1MS42NDYgMzEuODc0NyAxNTEuNzE3QzMxLjczNjcgMTUxLjc4NSAzMS42MTgyIDE1MS44ODYgMzEuNTE5MyAxNTIuMDIxQzMxLjQyMDMgMTUyLjE1NyAzMS4zNDM1IDE1Mi4zMjIgMzEuMjg4OCAxNTIuNTE4QzMxLjIzNDEgMTUyLjcxMyAzMS4yMDY4IDE1Mi45MzggMzEuMjA2OCAxNTMuMTkzVjE1My4zNTdDMzEuMjA2OCAxNTMuNTU4IDMxLjIzNDEgMTUzLjc0NyAzMS4yODg4IDE1My45MjRDMzEuMzQ2MSAxNTQuMDk4IDMxLjQyODEgMTU0LjI1MiAzMS41MzQ5IDE1NC4zODVDMzEuNjQ0MyAxNTQuNTE4IDMxLjc3NTggMTU0LjYyMiAzMS45Mjk0IDE1NC42OTdDMzIuMDg1NyAxNTQuNzczIDMyLjI2MjcgMTU0LjgxMSAzMi40NjA3IDE1NC44MTFDMzIuNzE1OSAxNTQuODExIDMyLjkzMiAxNTQuNzU4IDMzLjEwOTEgMTU0LjY1NEMzMy4yODYyIDE1NC41NSAzMy40NDExIDE1NC40MTEgMzMuNTczOSAxNTQuMjM2TDM0LjAxMTQgMTU0LjU4NEMzMy45MjAzIDE1NC43MjIgMzMuODA0NCAxNTQuODU0IDMzLjY2MzggMTU0Ljk3OUMzMy41MjMyIDE1NS4xMDQgMzMuMzUgMTU1LjIwNSAzMy4xNDQzIDE1NS4yODNDMzIuOTQxMSAxNTUuMzYxIDMyLjcwMDIgMTU1LjQgMzIuNDIxNiAxNTUuNFpNMzQuOTg4NiAxNDkuMzIySDM1LjcxNTJWMTU0LjUwMkwzNS42NTI3IDE1NS4zMjJIMzQuOTg4NlYxNDkuMzIyWk0zOC41NzA2IDE1My4xNzRWMTUzLjI1NkMzOC41NzA2IDE1My41NjMgMzguNTM0MiAxNTMuODQ4IDM4LjQ2MTMgMTU0LjExMUMzOC4zODgzIDE1NC4zNzIgMzguMjgxNiAxNTQuNTk4IDM4LjE0MDkgMTU0Ljc5MUMzOC4wMDAzIDE1NC45ODQgMzcuODI4NCAxNTUuMTMzIDM3LjYyNTMgMTU1LjI0QzM3LjQyMjIgMTU1LjM0NyAzNy4xODkxIDE1NS40IDM2LjkyNjEgMTU1LjRDMzYuNjU3OSAxNTUuNCAzNi40MjIyIDE1NS4zNTUgMzYuMjE5MSAxNTUuMjY0QzM2LjAxODUgMTU1LjE3IDM1Ljg0OTMgMTU1LjAzNiAzNS43MTEzIDE1NC44NjFDMzUuNTczMiAxNTQuNjg3IDM1LjQ2MjYgMTU0LjQ3NiAzNS4zNzkyIDE1NC4yMjlDMzUuMjk4NSAxNTMuOTgxIDM1LjI0MjUgMTUzLjcwMiAzNS4yMTEzIDE1My4zOTNWMTUzLjAzM0MzNS4yNDI1IDE1Mi43MjEgMzUuMjk4NSAxNTIuNDQxIDM1LjM3OTIgMTUyLjE5M0MzNS40NjI2IDE1MS45NDYgMzUuNTczMiAxNTEuNzM1IDM1LjcxMTMgMTUxLjU2MUMzNS44NDkzIDE1MS4zODMgMzYuMDE4NSAxNTEuMjQ5IDM2LjIxOTEgMTUxLjE1OEMzNi40MTk2IDE1MS4wNjQgMzYuNjUyNyAxNTEuMDE4IDM2LjkxODMgMTUxLjAxOEMzNy4xODM5IDE1MS4wMTggMzcuNDE5NiAxNTEuMDcgMzcuNjI1MyAxNTEuMTc0QzM3LjgzMSAxNTEuMjc1IDM4LjAwMjkgMTUxLjQyMSAzOC4xNDA5IDE1MS42MTFDMzguMjgxNiAxNTEuODAxIDM4LjM4ODMgMTUyLjAyOSAzOC40NjEzIDE1Mi4yOTVDMzguNTM0MiAxNTIuNTU4IDM4LjU3MDYgMTUyLjg1MSAzOC41NzA2IDE1My4xNzRaTTM3Ljg0NDEgMTUzLjI1NlYxNTMuMTc0QzM3Ljg0NDEgMTUyLjk2MyAzNy44MjQ1IDE1Mi43NjUgMzcuNzg1NSAxNTIuNThDMzcuNzQ2NCAxNTIuMzkzIDM3LjY4MzkgMTUyLjIyOSAzNy41OTggMTUyLjA4OEMzNy41MTIgMTUxLjk0NSAzNy4zOTg4IDE1MS44MzMgMzcuMjU4MSAxNTEuNzUyQzM3LjExNzUgMTUxLjY2OSAzNi45NDQzIDE1MS42MjcgMzYuNzM4NiAxNTEuNjI3QzM2LjU1NjMgMTUxLjYyNyAzNi4zOTc1IDE1MS42NTggMzYuMjYyIDE1MS43MjFDMzYuMTI5MiAxNTEuNzgzIDM2LjAxNTkgMTUxLjg2OCAzNS45MjIyIDE1MS45NzVDMzUuODI4NCAxNTIuMDc5IDM1Ljc1MTYgMTUyLjE5OSAzNS42OTE3IDE1Mi4zMzRDMzUuNjM0NCAxNTIuNDY3IDM1LjU5MTUgMTUyLjYwNSAzNS41NjI4IDE1Mi43NDhWMTUzLjY4OUMzNS42MDQ1IDE1My44NzIgMzUuNjcyMiAxNTQuMDQ4IDM1Ljc2NTkgMTU0LjIxN0MzNS44NjIzIDE1NC4zODMgMzUuOTg5OSAxNTQuNTIgMzYuMTQ4OCAxNTQuNjI3QzM2LjMxMDIgMTU0LjczNCAzNi41MDk0IDE1NC43ODcgMzYuNzQ2NCAxNTQuNzg3QzM2Ljk0MTcgMTU0Ljc4NyAzNy4xMDg0IDE1NC43NDggMzcuMjQ2NCAxNTQuNjdDMzcuMzg3IDE1NC41ODkgMzcuNTAwMyAxNTQuNDc5IDM3LjU4NjMgMTU0LjMzOEMzNy42NzQ4IDE1NC4xOTcgMzcuNzM5OSAxNTQuMDM1IDM3Ljc4MTYgMTUzLjg1QzM3LjgyMzIgMTUzLjY2NSAzNy44NDQxIDE1My40NjcgMzcuODQ0MSAxNTMuMjU2WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8ZyBjbGlwLXBhdGg9InVybCgjY2xpcDFfNDQzN185Njg1OCkiPgo8bGluZSB4MT0iNjEuODUwMSIgeTE9IjE0Ny4wNzIiIHgyPSI2MS44NTAxIiB5Mj0iMTQ2LjI1IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8cGF0aCBkPSJNNTMuOTA5IDE1Mi4wMjVWMTUyLjg5M0M1My45MDkgMTUzLjM1OSA1My44NjczIDE1My43NTIgNTMuNzg0IDE1NC4wNzJDNTMuNzAwNiAxNTQuMzkzIDUzLjU4MDggMTU0LjY1IDUzLjQyNDYgMTU0Ljg0NkM1My4yNjgzIDE1NS4wNDEgNTMuMDc5NSAxNTUuMTgzIDUyLjg1ODIgMTU1LjI3MUM1Mi42Mzk0IDE1NS4zNTcgNTIuMzkyIDE1NS40IDUyLjExNiAxNTUuNEM1MS44OTcyIDE1NS40IDUxLjY5NTQgMTU1LjM3MyA1MS41MTA1IDE1NS4zMThDNTEuMzI1NiAxNTUuMjY0IDUxLjE1OSAxNTUuMTc2IDUxLjAxMDUgMTU1LjA1N0M1MC44NjQ3IDE1NC45MzQgNTAuNzM5NyAxNTQuNzc1IDUwLjYzNTUgMTU0LjU4QzUwLjUzMTQgMTU0LjM4NSA1MC40NTE5IDE1NC4xNDggNTAuMzk3MiAxNTMuODY5QzUwLjM0MjYgMTUzLjU5IDUwLjMxNTIgMTUzLjI2NSA1MC4zMTUyIDE1Mi44OTNWMTUyLjAyNUM1MC4zMTUyIDE1MS41NTkgNTAuMzU2OSAxNTEuMTY5IDUwLjQ0MDIgMTUwLjg1NEM1MC41MjYxIDE1MC41MzggNTAuNjQ3MiAxNTAuMjg2IDUwLjgwMzUgMTUwLjA5NkM1MC45NTk3IDE0OS45MDMgNTEuMTQ3MiAxNDkuNzY1IDUxLjM2NiAxNDkuNjgyQzUxLjU4NzMgMTQ5LjU5OCA1MS44MzQ3IDE0OS41NTcgNTIuMTA4MiAxNDkuNTU3QzUyLjMyOTUgMTQ5LjU1NyA1Mi41MzI3IDE0OS41ODQgNTIuNzE3NiAxNDkuNjM5QzUyLjkwNTEgMTQ5LjY5MSA1My4wNzE3IDE0OS43NzUgNTMuMjE3NiAxNDkuODkzQzUzLjM2MzQgMTUwLjAwNyA1My40ODcxIDE1MC4xNjEgNTMuNTg4NiAxNTAuMzU0QzUzLjY5MjggMTUwLjU0NCA1My43NzIyIDE1MC43NzcgNTMuODI2OSAxNTEuMDUzQzUzLjg4MTYgMTUxLjMyOSA1My45MDkgMTUxLjY1MyA1My45MDkgMTUyLjAyNVpNNTMuMTgyNCAxNTMuMDFWMTUxLjkwNEM1My4xODI0IDE1MS42NDkgNTMuMTY2OCAxNTEuNDI1IDUzLjEzNTUgMTUxLjIzMkM1My4xMDY5IDE1MS4wMzcgNTMuMDYzOSAxNTAuODcgNTMuMDA2NiAxNTAuNzMyQzUyLjk0OTMgMTUwLjU5NCA1Mi44NzY0IDE1MC40ODIgNTIuNzg3OSAxNTAuMzk2QzUyLjcwMTkgMTUwLjMxMSA1Mi42MDE3IDE1MC4yNDggNTIuNDg3MSAxNTAuMjA5QzUyLjM3NTEgMTUwLjE2NyA1Mi4yNDg4IDE1MC4xNDYgNTIuMTA4MiAxNTAuMTQ2QzUxLjkzNjMgMTUwLjE0NiA1MS43ODQgMTUwLjE3OSA1MS42NTExIDE1MC4yNDRDNTEuNTE4MyAxNTAuMzA3IDUxLjQwNjQgMTUwLjQwNyA1MS4zMTUyIDE1MC41NDVDNTEuMjI2NyAxNTAuNjgzIDUxLjE1OSAxNTAuODY0IDUxLjExMjEgMTUxLjA4OEM1MS4wNjUyIDE1MS4zMTIgNTEuMDQxOCAxNTEuNTg0IDUxLjA0MTggMTUxLjkwNFYxNTMuMDFDNTEuMDQxOCAxNTMuMjY1IDUxLjA1NjEgMTUzLjQ5IDUxLjA4NDcgMTUzLjY4NkM1MS4xMTYgMTUzLjg4MSA1MS4xNjE2IDE1NC4wNSA1MS4yMjE1IDE1NC4xOTNDNTEuMjgxNCAxNTQuMzM0IDUxLjM1NDMgMTU0LjQ1IDUxLjQ0MDIgMTU0LjU0MUM1MS41MjYxIDE1NC42MzIgNTEuNjI1MSAxNTQuNyA1MS43MzcxIDE1NC43NDRDNTEuODUxNyAxNTQuNzg2IDUxLjk3OCAxNTQuODA3IDUyLjExNiAxNTQuODA3QzUyLjI5MzEgMTU0LjgwNyA1Mi40NDggMTU0Ljc3MyA1Mi41ODA4IDE1NC43MDVDNTIuNzEzNiAxNTQuNjM3IDUyLjgyNDMgMTU0LjUzMiA1Mi45MTI5IDE1NC4zODlDNTMuMDA0IDE1NC4yNDMgNTMuMDcxNyAxNTQuMDU3IDUzLjExNiAxNTMuODNDNTMuMTYwMyAxNTMuNjAxIDUzLjE4MjQgMTUzLjMyNyA1My4xODI0IDE1My4wMVpNNTguNjQ3OCAxNTQuNzI5VjE1NS4zMjJINTQuOTI1MlYxNTQuODAzTDU2Ljc4ODUgMTUyLjcyOUM1Ny4wMTc2IDE1Mi40NzMgNTcuMTk0NyAxNTIuMjU3IDU3LjMxOTcgMTUyLjA4QzU3LjQ0NzMgMTUxLjkgNTcuNTM1OSAxNTEuNzQgNTcuNTg1MyAxNTEuNkM1Ny42Mzc0IDE1MS40NTYgNTcuNjYzNSAxNTEuMzExIDU3LjY2MzUgMTUxLjE2MkM1Ny42NjM1IDE1MC45NzUgNTcuNjI0NCAxNTAuODA1IDU3LjU0NjMgMTUwLjY1NEM1Ny40NzA4IDE1MC41MDEgNTcuMzU4OCAxNTAuMzc4IDU3LjIxMDMgMTUwLjI4N0M1Ny4wNjE5IDE1MC4xOTYgNTYuODgyMiAxNTAuMTUgNTYuNjcxMyAxNTAuMTVDNTYuNDE4NyAxNTAuMTUgNTYuMjA3NyAxNTAuMiA1Ni4wMzg1IDE1MC4yOTlDNTUuODcxOCAxNTAuMzk1IDU1Ljc0NjggMTUwLjUzMSA1NS42NjM1IDE1MC43MDVDNTUuNTgwMSAxNTAuODggNTUuNTM4NSAxNTEuMDggNTUuNTM4NSAxNTEuMzA3SDU0LjgxNThDNTQuODE1OCAxNTAuOTg2IDU0Ljg4NjEgMTUwLjY5MyA1NS4wMjY3IDE1MC40MjhDNTUuMTY3NCAxNTAuMTYyIDU1LjM3NTcgMTQ5Ljk1MSA1NS42NTE3IDE0OS43OTVDNTUuOTI3OCAxNDkuNjM2IDU2LjI2NzYgMTQ5LjU1NyA1Ni42NzEzIDE0OS41NTdDNTcuMDMwNyAxNDkuNTU3IDU3LjMzNzkgMTQ5LjYyIDU3LjU5MzIgMTQ5Ljc0OEM1Ny44NDg0IDE0OS44NzMgNTguMDQzNyAxNTAuMDUgNTguMTc5MSAxNTAuMjc5QzU4LjMxNzEgMTUwLjUwNiA1OC4zODYxIDE1MC43NzEgNTguMzg2MSAxNTEuMDc2QzU4LjM4NjEgMTUxLjI0MyA1OC4zNTc1IDE1MS40MTIgNTguMzAwMiAxNTEuNTg0QzU4LjI0NTUgMTUxLjc1MyA1OC4xNjg3IDE1MS45MjMgNTguMDY5NyAxNTIuMDkyQzU3Ljk3MzQgMTUyLjI2MSA1Ny44NjAxIDE1Mi40MjggNTcuNzI5OSAxNTIuNTkyQzU3LjYwMjMgMTUyLjc1NiA1Ny40NjU1IDE1Mi45MTcgNTcuMzE5NyAxNTMuMDc2TDU1Ljc5NjMgMTU0LjcyOUg1OC42NDc4Wk02Mi41MTIzIDE0OS42MzVWMTU1LjMyMkg2MS43NTg0VjE0OS42MzVINjIuNTEyM1pNNjQuODk1MSAxNTIuMTkzVjE1Mi44MTFINjIuMzQ4MlYxNTIuMTkzSDY0Ljg5NTFaTTY1LjI4MTggMTQ5LjYzNVYxNTAuMjUySDYyLjM0ODJWMTQ5LjYzNUg2NS4yODE4Wk02Ny44MjE1IDE1NS40QzY3LjUyNzIgMTU1LjQgNjcuMjYwMyAxNTUuMzUxIDY3LjAyMDcgMTU1LjI1MkM2Ni43ODM3IDE1NS4xNSA2Ni41NzkzIDE1NS4wMDggNjYuNDA3NCAxNTQuODI2QzY2LjIzODIgMTU0LjY0NCA2Ni4xMDggMTU0LjQyOCA2Ni4wMTY4IDE1NC4xNzhDNjUuOTI1NyAxNTMuOTI4IDY1Ljg4MDEgMTUzLjY1NCA2NS44ODAxIDE1My4zNTdWMTUzLjE5M0M2NS44ODAxIDE1Mi44NSA2NS45MzA5IDE1Mi41NDQgNjYuMDMyNCAxNTIuMjc1QzY2LjEzNCAxNTIuMDA1IDY2LjI3MiAxNTEuNzc1IDY2LjQ0NjUgMTUxLjU4OEM2Ni42MjEgMTUxLjQgNjYuODE4OSAxNTEuMjU4IDY3LjA0MDIgMTUxLjE2MkM2Ny4yNjE2IDE1MS4wNjYgNjcuNDkwOCAxNTEuMDE4IDY3LjcyNzcgMTUxLjAxOEM2OC4wMjk4IDE1MS4wMTggNjguMjkwMiAxNTEuMDcgNjguNTA5IDE1MS4xNzRDNjguNzMwNCAxNTEuMjc4IDY4LjkxMTMgMTUxLjQyNCA2OS4wNTIgMTUxLjYxMUM2OS4xOTI2IDE1MS43OTYgNjkuMjk2OCAxNTIuMDE1IDY5LjM2NDUgMTUyLjI2OEM2OS40MzIyIDE1Mi41MTggNjkuNDY2IDE1Mi43OTEgNjkuNDY2IDE1My4wODhWMTUzLjQxMkg2Ni4zMDk4VjE1Mi44MjJINjguNzQzNFYxNTIuNzY4QzY4LjczMyAxNTIuNTggNjguNjkzOSAxNTIuMzk4IDY4LjYyNjIgMTUyLjIyMUM2OC41NjExIDE1Mi4wNDQgNjguNDU2OSAxNTEuODk4IDY4LjMxMzcgMTUxLjc4M0M2OC4xNzA1IDE1MS42NjkgNjcuOTc1MSAxNTEuNjExIDY3LjcyNzcgMTUxLjYxMUM2Ny41NjM3IDE1MS42MTEgNjcuNDEyNiAxNTEuNjQ2IDY3LjI3NDYgMTUxLjcxN0M2Ny4xMzY2IDE1MS43ODUgNjcuMDE4MSAxNTEuODg2IDY2LjkxOTIgMTUyLjAyMUM2Ni44MjAyIDE1Mi4xNTcgNjYuNzQzNCAxNTIuMzIyIDY2LjY4ODcgMTUyLjUxOEM2Ni42MzQgMTUyLjcxMyA2Ni42MDY3IDE1Mi45MzggNjYuNjA2NyAxNTMuMTkzVjE1My4zNTdDNjYuNjA2NyAxNTMuNTU4IDY2LjYzNCAxNTMuNzQ3IDY2LjY4ODcgMTUzLjkyNEM2Ni43NDYgMTU0LjA5OCA2Ni44MjggMTU0LjI1MiA2Ni45MzQ4IDE1NC4zODVDNjcuMDQ0MiAxNTQuNTE4IDY3LjE3NTcgMTU0LjYyMiA2Ny4zMjkzIDE1NC42OTdDNjcuNDg1NiAxNTQuNzczIDY3LjY2MjYgMTU0LjgxMSA2Ny44NjA2IDE1NC44MTFDNjguMTE1OCAxNTQuODExIDY4LjMzMTkgMTU0Ljc1OCA2OC41MDkgMTU0LjY1NEM2OC42ODYxIDE1NC41NSA2OC44NDEgMTU0LjQxMSA2OC45NzM4IDE1NC4yMzZMNjkuNDExMyAxNTQuNTg0QzY5LjMyMDIgMTU0LjcyMiA2OS4yMDQzIDE1NC44NTQgNjkuMDYzNyAxNTQuOTc5QzY4LjkyMzEgMTU1LjEwNCA2OC43NDk5IDE1NS4yMDUgNjguNTQ0MiAxNTUuMjgzQzY4LjM0MSAxNTUuMzYxIDY4LjEwMDEgMTU1LjQgNjcuODIxNSAxNTUuNFpNNzAuMzg4NSAxNDkuMzIySDcxLjExNTFWMTU0LjUwMkw3MS4wNTI2IDE1NS4zMjJINzAuMzg4NVYxNDkuMzIyWk03My45NzA1IDE1My4xNzRWMTUzLjI1NkM3My45NzA1IDE1My41NjMgNzMuOTM0MSAxNTMuODQ4IDczLjg2MTIgMTU0LjExMUM3My43ODgyIDE1NC4zNzIgNzMuNjgxNSAxNTQuNTk4IDczLjU0MDggMTU0Ljc5MUM3My40MDAyIDE1NC45ODQgNzMuMjI4MyAxNTUuMTMzIDczLjAyNTIgMTU1LjI0QzcyLjgyMjEgMTU1LjM0NyA3Mi41ODkgMTU1LjQgNzIuMzI2IDE1NS40QzcyLjA1NzggMTU1LjQgNzEuODIyMSAxNTUuMzU1IDcxLjYxOSAxNTUuMjY0QzcxLjQxODUgMTU1LjE3IDcxLjI0OTIgMTU1LjAzNiA3MS4xMTEyIDE1NC44NjFDNzAuOTczMSAxNTQuNjg3IDcwLjg2MjUgMTU0LjQ3NiA3MC43NzkxIDE1NC4yMjlDNzAuNjk4NCAxNTMuOTgxIDcwLjY0MjQgMTUzLjcwMiA3MC42MTEyIDE1My4zOTNWMTUzLjAzM0M3MC42NDI0IDE1Mi43MjEgNzAuNjk4NCAxNTIuNDQxIDcwLjc3OTEgMTUyLjE5M0M3MC44NjI1IDE1MS45NDYgNzAuOTczMSAxNTEuNzM1IDcxLjExMTIgMTUxLjU2MUM3MS4yNDkyIDE1MS4zODMgNzEuNDE4NSAxNTEuMjQ5IDcxLjYxOSAxNTEuMTU4QzcxLjgxOTUgMTUxLjA2NCA3Mi4wNTI2IDE1MS4wMTggNzIuMzE4MiAxNTEuMDE4QzcyLjU4MzggMTUxLjAxOCA3Mi44MTk1IDE1MS4wNyA3My4wMjUyIDE1MS4xNzRDNzMuMjMxIDE1MS4yNzUgNzMuNDAyOCAxNTEuNDIxIDczLjU0MDggMTUxLjYxMUM3My42ODE1IDE1MS44MDEgNzMuNzg4MiAxNTIuMDI5IDczLjg2MTIgMTUyLjI5NUM3My45MzQxIDE1Mi41NTggNzMuOTcwNSAxNTIuODUxIDczLjk3MDUgMTUzLjE3NFpNNzMuMjQ0IDE1My4yNTZWMTUzLjE3NEM3My4yNDQgMTUyLjk2MyA3My4yMjQ0IDE1Mi43NjUgNzMuMTg1NCAxNTIuNThDNzMuMTQ2MyAxNTIuMzkzIDczLjA4MzggMTUyLjIyOSA3Mi45OTc5IDE1Mi4wODhDNzIuOTExOSAxNTEuOTQ1IDcyLjc5ODcgMTUxLjgzMyA3Mi42NTggMTUxLjc1MkM3Mi41MTc0IDE1MS42NjkgNzIuMzQ0MiAxNTEuNjI3IDcyLjEzODUgMTUxLjYyN0M3MS45NTYyIDE1MS42MjcgNzEuNzk3NCAxNTEuNjU4IDcxLjY2MTkgMTUxLjcyMUM3MS41MjkxIDE1MS43ODMgNzEuNDE1OCAxNTEuODY4IDcxLjMyMjEgMTUxLjk3NUM3MS4yMjgzIDE1Mi4wNzkgNzEuMTUxNSAxNTIuMTk5IDcxLjA5MTYgMTUyLjMzNEM3MS4wMzQzIDE1Mi40NjcgNzAuOTkxNCAxNTIuNjA1IDcwLjk2MjcgMTUyLjc0OFYxNTMuNjg5QzcxLjAwNDQgMTUzLjg3MiA3MS4wNzIxIDE1NC4wNDggNzEuMTY1OCAxNTQuMjE3QzcxLjI2MjIgMTU0LjM4MyA3MS4zODk4IDE1NC41MiA3MS41NDg3IDE1NC42MjdDNzEuNzEwMSAxNTQuNzM0IDcxLjkwOTMgMTU0Ljc4NyA3Mi4xNDYzIDE1NC43ODdDNzIuMzQxNiAxNTQuNzg3IDcyLjUwODMgMTU0Ljc0OCA3Mi42NDYzIDE1NC42N0M3Mi43ODY5IDE1NC41ODkgNzIuOTAwMiAxNTQuNDc5IDcyLjk4NjIgMTU0LjMzOEM3My4wNzQ3IDE1NC4xOTcgNzMuMTM5OCAxNTQuMDM1IDczLjE4MTUgMTUzLjg1QzczLjIyMzEgMTUzLjY2NSA3My4yNDQgMTUzLjQ2NyA3My4yNDQgMTUzLjI1NloiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPC9nPgo8ZyBjbGlwLXBhdGg9InVybCgjY2xpcDJfNDQzN185Njg1OCkiPgo8bGluZSB4MT0iOTcuMjUiIHkxPSIxNDcuMDcyIiB4Mj0iOTcuMjUiIHkyPSIxNDYuMjUiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjUiIHN0cm9rZS13aWR0aD0iMC41IiBzdHJva2UtbGluZWNhcD0ic3F1YXJlIi8+CjxwYXRoIGQ9Ik04OS4zMDg5IDE1Mi4wMjVWMTUyLjg5M0M4OS4zMDg5IDE1My4zNTkgODkuMjY3MiAxNTMuNzUyIDg5LjE4MzkgMTU0LjA3MkM4OS4xMDA1IDE1NC4zOTMgODguOTgwNyAxNTQuNjUgODguODI0NSAxNTQuODQ2Qzg4LjY2ODIgMTU1LjA0MSA4OC40Nzk0IDE1NS4xODMgODguMjU4MSAxNTUuMjcxQzg4LjAzOTMgMTU1LjM1NyA4Ny43OTE5IDE1NS40IDg3LjUxNTkgMTU1LjRDODcuMjk3MSAxNTUuNCA4Ny4wOTUzIDE1NS4zNzMgODYuOTEwNCAxNTUuMzE4Qzg2LjcyNTUgMTU1LjI2NCA4Ni41NTg5IDE1NS4xNzYgODYuNDEwNCAxNTUuMDU3Qzg2LjI2NDYgMTU0LjkzNCA4Ni4xMzk2IDE1NC43NzUgODYuMDM1NCAxNTQuNThDODUuOTMxMyAxNTQuMzg1IDg1Ljg1MTggMTU0LjE0OCA4NS43OTcxIDE1My44NjlDODUuNzQyNSAxNTMuNTkgODUuNzE1MSAxNTMuMjY1IDg1LjcxNTEgMTUyLjg5M1YxNTIuMDI1Qzg1LjcxNTEgMTUxLjU1OSA4NS43NTY4IDE1MS4xNjkgODUuODQwMSAxNTAuODU0Qzg1LjkyNjEgMTUwLjUzOCA4Ni4wNDcxIDE1MC4yODYgODYuMjAzNCAxNTAuMDk2Qzg2LjM1OTYgMTQ5LjkwMyA4Ni41NDcxIDE0OS43NjUgODYuNzY1OSAxNDkuNjgyQzg2Ljk4NzIgMTQ5LjU5OCA4Ny4yMzQ2IDE0OS41NTcgODcuNTA4MSAxNDkuNTU3Qzg3LjcyOTQgMTQ5LjU1NyA4Ny45MzI2IDE0OS41ODQgODguMTE3NSAxNDkuNjM5Qzg4LjMwNSAxNDkuNjkxIDg4LjQ3MTYgMTQ5Ljc3NSA4OC42MTc1IDE0OS44OTNDODguNzYzMyAxNTAuMDA3IDg4Ljg4NyAxNTAuMTYxIDg4Ljk4ODYgMTUwLjM1NEM4OS4wOTI3IDE1MC41NDQgODkuMTcyMSAxNTAuNzc3IDg5LjIyNjggMTUxLjA1M0M4OS4yODE1IDE1MS4zMjkgODkuMzA4OSAxNTEuNjUzIDg5LjMwODkgMTUyLjAyNVpNODguNTgyMyAxNTMuMDFWMTUxLjkwNEM4OC41ODIzIDE1MS42NDkgODguNTY2NyAxNTEuNDI1IDg4LjUzNTQgMTUxLjIzMkM4OC41MDY4IDE1MS4wMzcgODguNDYzOCAxNTAuODcgODguNDA2NSAxNTAuNzMyQzg4LjM0OTIgMTUwLjU5NCA4OC4yNzYzIDE1MC40ODIgODguMTg3OCAxNTAuMzk2Qzg4LjEwMTggMTUwLjMxMSA4OC4wMDE2IDE1MC4yNDggODcuODg3IDE1MC4yMDlDODcuNzc1IDE1MC4xNjcgODcuNjQ4NyAxNTAuMTQ2IDg3LjUwODEgMTUwLjE0NkM4Ny4zMzYyIDE1MC4xNDYgODcuMTgzOSAxNTAuMTc5IDg3LjA1MTEgMTUwLjI0NEM4Ni45MTgyIDE1MC4zMDcgODYuODA2MyAxNTAuNDA3IDg2LjcxNTEgMTUwLjU0NUM4Ni42MjY2IDE1MC42ODMgODYuNTU4OSAxNTAuODY0IDg2LjUxMiAxNTEuMDg4Qzg2LjQ2NTEgMTUxLjMxMiA4Ni40NDE3IDE1MS41ODQgODYuNDQxNyAxNTEuOTA0VjE1My4wMUM4Ni40NDE3IDE1My4yNjUgODYuNDU2IDE1My40OSA4Ni40ODQ2IDE1My42ODZDODYuNTE1OSAxNTMuODgxIDg2LjU2MTUgMTU0LjA1IDg2LjYyMTQgMTU0LjE5M0M4Ni42ODEzIDE1NC4zMzQgODYuNzU0MiAxNTQuNDUgODYuODQwMSAxNTQuNTQxQzg2LjkyNjEgMTU0LjYzMiA4Ny4wMjUgMTU0LjcgODcuMTM3IDE1NC43NDRDODcuMjUxNiAxNTQuNzg2IDg3LjM3NzkgMTU0LjgwNyA4Ny41MTU5IDE1NC44MDdDODcuNjkzIDE1NC44MDcgODcuODQ3OSAxNTQuNzczIDg3Ljk4MDcgMTU0LjcwNUM4OC4xMTM2IDE1NC42MzcgODguMjI0MiAxNTQuNTMyIDg4LjMxMjggMTU0LjM4OUM4OC40MDM5IDE1NC4yNDMgODguNDcxNiAxNTQuMDU3IDg4LjUxNTkgMTUzLjgzQzg4LjU2MDIgMTUzLjYwMSA4OC41ODIzIDE1My4zMjcgODguNTgyMyAxNTMuMDFaTTkxLjM3NTkgMTUyLjEyM0g5MS44OTE1QzkyLjE0NDEgMTUyLjEyMyA5Mi4zNTI0IDE1Mi4wODEgOTIuNTE2NSAxNTEuOTk4QzkyLjY4MzIgMTUxLjkxMiA5Mi44MDY5IDE1MS43OTYgOTIuODg3NiAxNTEuNjVDOTIuOTcwOSAxNTEuNTAyIDkzLjAxMjYgMTUxLjMzNSA5My4wMTI2IDE1MS4xNUM5My4wMTI2IDE1MC45MzIgOTIuOTc2MSAxNTAuNzQ4IDkyLjkwMzIgMTUwLjZDOTIuODMwMyAxNTAuNDUxIDkyLjcyMDkgMTUwLjMzOSA5Mi41NzUxIDE1MC4yNjRDOTIuNDI5MyAxNTAuMTg4IDkyLjI0NDQgMTUwLjE1IDkyLjAyMDQgMTUwLjE1QzkxLjgxNzMgMTUwLjE1IDkxLjYzNzYgMTUwLjE5MSA5MS40ODEzIDE1MC4yNzFDOTEuMzI3NyAxNTAuMzUgOTEuMjA2NiAxNTAuNDYyIDkxLjExODEgMTUwLjYwN0M5MS4wMzIxIDE1MC43NTMgOTAuOTg5MSAxNTAuOTI1IDkwLjk4OTEgMTUxLjEyM0g5MC4yNjY1QzkwLjI2NjUgMTUwLjgzNCA5MC4zMzk0IDE1MC41NzEgOTAuNDg1MiAxNTAuMzM0QzkwLjYzMTEgMTUwLjA5NyA5MC44MzU1IDE0OS45MDggOTEuMDk4NSAxNDkuNzY4QzkxLjM2NDEgMTQ5LjYyNyA5MS42NzE0IDE0OS41NTcgOTIuMDIwNCAxNDkuNTU3QzkyLjM2NDEgMTQ5LjU1NyA5Mi42NjQ5IDE0OS42MTggOTIuOTIyNyAxNDkuNzRDOTMuMTgwNiAxNDkuODYgOTMuMzgxMSAxNTAuMDQgOTMuNTI0MyAxNTAuMjc5QzkzLjY2NzUgMTUwLjUxNiA5My43MzkxIDE1MC44MTIgOTMuNzM5MSAxNTEuMTY2QzkzLjczOTEgMTUxLjMwOSA5My43MDUzIDE1MS40NjMgOTMuNjM3NiAxNTEuNjI3QzkzLjU3MjUgMTUxLjc4OCA5My40Njk2IDE1MS45MzkgOTMuMzI5IDE1Mi4wOEM5My4xOTEgMTUyLjIyMSA5My4wMTEzIDE1Mi4zMzcgOTIuNzg5OSAxNTIuNDI4QzkyLjU2ODYgMTUyLjUxNiA5Mi4zMDI5IDE1Mi41NjEgOTEuOTkzMSAxNTIuNTYxSDkxLjM3NTlWMTUyLjEyM1pNOTEuMzc1OSAxNTIuNzE3VjE1Mi4yODNIOTEuOTkzMUM5Mi4zNTUgMTUyLjI4MyA5Mi42NTQ1IDE1Mi4zMjYgOTIuODkxNSAxNTIuNDEyQzkzLjEyODUgMTUyLjQ5OCA5My4zMTQ3IDE1Mi42MTMgOTMuNDUwMSAxNTIuNzU2QzkzLjU4ODEgMTUyLjg5OSA5My42ODQ1IDE1My4wNTcgOTMuNzM5MSAxNTMuMjI5QzkzLjc5NjQgMTUzLjM5OCA5My44MjUxIDE1My41NjcgOTMuODI1MSAxNTMuNzM2QzkzLjgyNTEgMTU0LjAwMiA5My43Nzk1IDE1NC4yMzggOTMuNjg4NCAxNTQuNDQzQzkzLjU5OTggMTU0LjY0OSA5My40NzM1IDE1NC44MjQgOTMuMzA5NSAxNTQuOTY3QzkzLjE0OCAxNTUuMTEgOTIuOTU3OSAxNTUuMjE4IDkyLjczOTEgMTU1LjI5MUM5Mi41MjA0IDE1NS4zNjQgOTIuMjgyMSAxNTUuNCA5Mi4wMjQzIDE1NS40QzkxLjc3NjkgMTU1LjQgOTEuNTQzOCAxNTUuMzY1IDkxLjMyNTEgMTU1LjI5NUM5MS4xMDg5IDE1NS4yMjUgOTAuOTE3NSAxNTUuMTIzIDkwLjc1MDkgMTU0Ljk5QzkwLjU4NDIgMTU0Ljg1NSA5MC40NTQgMTU0LjY4OSA5MC4zNjAyIDE1NC40OTRDOTAuMjY2NSAxNTQuMjk2IDkwLjIxOTYgMTU0LjA3MSA5MC4yMTk2IDE1My44MThIOTAuOTQyM0M5MC45NDIzIDE1NC4wMTYgOTAuOTg1MiAxNTQuMTg5IDkxLjA3MTIgMTU0LjMzOEM5MS4xNTk3IDE1NC40ODYgOTEuMjg0NyAxNTQuNjAyIDkxLjQ0NjIgMTU0LjY4NkM5MS42MTAyIDE1NC43NjYgOTEuODAyOSAxNTQuODA3IDkyLjAyNDMgMTU0LjgwN0M5Mi4yNDU3IDE1NC44MDcgOTIuNDM1OCAxNTQuNzY5IDkyLjU5NDYgMTU0LjY5M0M5Mi43NTYxIDE1NC42MTUgOTIuODc5OCAxNTQuNDk4IDkyLjk2NTcgMTU0LjM0MkM5My4wNTQzIDE1NC4xODYgOTMuMDk4NSAxNTMuOTg5IDkzLjA5ODUgMTUzLjc1MkM5My4wOTg1IDE1My41MTUgOTMuMDQ5IDE1My4zMjEgOTIuOTUwMSAxNTMuMTdDOTIuODUxMSAxNTMuMDE2IDkyLjcxMDUgMTUyLjkwMyA5Mi41MjgyIDE1Mi44M0M5Mi4zNDg1IDE1Mi43NTUgOTIuMTM2MyAxNTIuNzE3IDkxLjg5MTUgMTUyLjcxN0g5MS4zNzU5Wk05Ny45MTIyIDE0OS42MzVWMTU1LjMyMkg5Ny4xNTgzVjE0OS42MzVIOTcuOTEyMlpNMTAwLjI5NSAxNTIuMTkzVjE1Mi44MTFIOTcuNzQ4MVYxNTIuMTkzSDEwMC4yOTVaTTEwMC42ODIgMTQ5LjYzNVYxNTAuMjUySDk3Ljc0ODFWMTQ5LjYzNUgxMDAuNjgyWk0xMDMuMjIxIDE1NS40QzEwMi45MjcgMTU1LjQgMTAyLjY2IDE1NS4zNTEgMTAyLjQyMSAxNTUuMjUyQzEwMi4xODQgMTU1LjE1IDEwMS45NzkgMTU1LjAwOCAxMDEuODA3IDE1NC44MjZDMTAxLjYzOCAxNTQuNjQ0IDEwMS41MDggMTU0LjQyOCAxMDEuNDE3IDE1NC4xNzhDMTAxLjMyNiAxNTMuOTI4IDEwMS4yOCAxNTMuNjU0IDEwMS4yOCAxNTMuMzU3VjE1My4xOTNDMTAxLjI4IDE1Mi44NSAxMDEuMzMxIDE1Mi41NDQgMTAxLjQzMiAxNTIuMjc1QzEwMS41MzQgMTUyLjAwNSAxMDEuNjcyIDE1MS43NzUgMTAxLjg0NiAxNTEuNTg4QzEwMi4wMjEgMTUxLjQgMTAyLjIxOSAxNTEuMjU4IDEwMi40NCAxNTEuMTYyQzEwMi42NjIgMTUxLjA2NiAxMDIuODkxIDE1MS4wMTggMTAzLjEyOCAxNTEuMDE4QzEwMy40MyAxNTEuMDE4IDEwMy42OSAxNTEuMDcgMTAzLjkwOSAxNTEuMTc0QzEwNC4xMyAxNTEuMjc4IDEwNC4zMTEgMTUxLjQyNCAxMDQuNDUyIDE1MS42MTFDMTA0LjU5MiAxNTEuNzk2IDEwNC42OTcgMTUyLjAxNSAxMDQuNzY0IDE1Mi4yNjhDMTA0LjgzMiAxNTIuNTE4IDEwNC44NjYgMTUyLjc5MSAxMDQuODY2IDE1My4wODhWMTUzLjQxMkgxMDEuNzFWMTUyLjgyMkgxMDQuMTQzVjE1Mi43NjhDMTA0LjEzMyAxNTIuNTggMTA0LjA5NCAxNTIuMzk4IDEwNC4wMjYgMTUyLjIyMUMxMDMuOTYxIDE1Mi4wNDQgMTAzLjg1NyAxNTEuODk4IDEwMy43MTQgMTUxLjc4M0MxMDMuNTcgMTUxLjY2OSAxMDMuMzc1IDE1MS42MTEgMTAzLjEyOCAxNTEuNjExQzEwMi45NjQgMTUxLjYxMSAxMDIuODEzIDE1MS42NDYgMTAyLjY3NSAxNTEuNzE3QzEwMi41MzcgMTUxLjc4NSAxMDIuNDE4IDE1MS44ODYgMTAyLjMxOSAxNTIuMDIxQzEwMi4yMiAxNTIuMTU3IDEwMi4xNDMgMTUyLjMyMiAxMDIuMDg5IDE1Mi41MThDMTAyLjAzNCAxNTIuNzEzIDEwMi4wMDcgMTUyLjkzOCAxMDIuMDA3IDE1My4xOTNWMTUzLjM1N0MxMDIuMDA3IDE1My41NTggMTAyLjAzNCAxNTMuNzQ3IDEwMi4wODkgMTUzLjkyNEMxMDIuMTQ2IDE1NC4wOTggMTAyLjIyOCAxNTQuMjUyIDEwMi4zMzUgMTU0LjM4NUMxMDIuNDQ0IDE1NC41MTggMTAyLjU3NiAxNTQuNjIyIDEwMi43MjkgMTU0LjY5N0MxMDIuODg1IDE1NC43NzMgMTAzLjA2MyAxNTQuODExIDEwMy4yNiAxNTQuODExQzEwMy41MTYgMTU0LjgxMSAxMDMuNzMyIDE1NC43NTggMTAzLjkwOSAxNTQuNjU0QzEwNC4wODYgMTU0LjU1IDEwNC4yNDEgMTU0LjQxMSAxMDQuMzc0IDE1NC4yMzZMMTA0LjgxMSAxNTQuNTg0QzEwNC43MiAxNTQuNzIyIDEwNC42MDQgMTU0Ljg1NCAxMDQuNDY0IDE1NC45NzlDMTA0LjMyMyAxNTUuMTA0IDEwNC4xNSAxNTUuMjA1IDEwMy45NDQgMTU1LjI4M0MxMDMuNzQxIDE1NS4zNjEgMTAzLjUgMTU1LjQgMTAzLjIyMSAxNTUuNFpNMTA1Ljc4OCAxNDkuMzIySDEwNi41MTVWMTU0LjUwMkwxMDYuNDUyIDE1NS4zMjJIMTA1Ljc4OFYxNDkuMzIyWk0xMDkuMzcgMTUzLjE3NFYxNTMuMjU2QzEwOS4zNyAxNTMuNTYzIDEwOS4zMzQgMTUzLjg0OCAxMDkuMjYxIDE1NC4xMTFDMTA5LjE4OCAxNTQuMzcyIDEwOS4wODEgMTU0LjU5OCAxMDguOTQxIDE1NC43OTFDMTA4LjggMTU0Ljk4NCAxMDguNjI4IDE1NS4xMzMgMTA4LjQyNSAxNTUuMjRDMTA4LjIyMiAxNTUuMzQ3IDEwNy45ODkgMTU1LjQgMTA3LjcyNiAxNTUuNEMxMDcuNDU4IDE1NS40IDEwNy4yMjIgMTU1LjM1NSAxMDcuMDE5IDE1NS4yNjRDMTA2LjgxOCAxNTUuMTcgMTA2LjY0OSAxNTUuMDM2IDEwNi41MTEgMTU0Ljg2MUMxMDYuMzczIDE1NC42ODcgMTA2LjI2MiAxNTQuNDc2IDEwNi4xNzkgMTU0LjIyOUMxMDYuMDk4IDE1My45ODEgMTA2LjA0MiAxNTMuNzAyIDEwNi4wMTEgMTUzLjM5M1YxNTMuMDMzQzEwNi4wNDIgMTUyLjcyMSAxMDYuMDk4IDE1Mi40NDEgMTA2LjE3OSAxNTIuMTkzQzEwNi4yNjIgMTUxLjk0NiAxMDYuMzczIDE1MS43MzUgMTA2LjUxMSAxNTEuNTYxQzEwNi42NDkgMTUxLjM4MyAxMDYuODE4IDE1MS4yNDkgMTA3LjAxOSAxNTEuMTU4QzEwNy4yMTkgMTUxLjA2NCAxMDcuNDUyIDE1MS4wMTggMTA3LjcxOCAxNTEuMDE4QzEwNy45ODQgMTUxLjAxOCAxMDguMjE5IDE1MS4wNyAxMDguNDI1IDE1MS4xNzRDMTA4LjYzMSAxNTEuMjc1IDEwOC44MDMgMTUxLjQyMSAxMDguOTQxIDE1MS42MTFDMTA5LjA4MSAxNTEuODAxIDEwOS4xODggMTUyLjAyOSAxMDkuMjYxIDE1Mi4yOTVDMTA5LjMzNCAxNTIuNTU4IDEwOS4zNyAxNTIuODUxIDEwOS4zNyAxNTMuMTc0Wk0xMDguNjQ0IDE1My4yNTZWMTUzLjE3NEMxMDguNjQ0IDE1Mi45NjMgMTA4LjYyNCAxNTIuNzY1IDEwOC41ODUgMTUyLjU4QzEwOC41NDYgMTUyLjM5MyAxMDguNDg0IDE1Mi4yMjkgMTA4LjM5OCAxNTIuMDg4QzEwOC4zMTIgMTUxLjk0NSAxMDguMTk5IDE1MS44MzMgMTA4LjA1OCAxNTEuNzUyQzEwNy45MTcgMTUxLjY2OSAxMDcuNzQ0IDE1MS42MjcgMTA3LjUzOCAxNTEuNjI3QzEwNy4zNTYgMTUxLjYyNyAxMDcuMTk3IDE1MS42NTggMTA3LjA2MiAxNTEuNzIxQzEwNi45MjkgMTUxLjc4MyAxMDYuODE2IDE1MS44NjggMTA2LjcyMiAxNTEuOTc1QzEwNi42MjggMTUyLjA3OSAxMDYuNTUxIDE1Mi4xOTkgMTA2LjQ5MiAxNTIuMzM0QzEwNi40MzQgMTUyLjQ2NyAxMDYuMzkxIDE1Mi42MDUgMTA2LjM2MyAxNTIuNzQ4VjE1My42ODlDMTA2LjQwNCAxNTMuODcyIDEwNi40NzIgMTU0LjA0OCAxMDYuNTY2IDE1NC4yMTdDMTA2LjY2MiAxNTQuMzgzIDEwNi43OSAxNTQuNTIgMTA2Ljk0OSAxNTQuNjI3QzEwNy4xMSAxNTQuNzM0IDEwNy4zMDkgMTU0Ljc4NyAxMDcuNTQ2IDE1NC43ODdDMTA3Ljc0MiAxNTQuNzg3IDEwNy45MDggMTU0Ljc0OCAxMDguMDQ2IDE1NC42N0MxMDguMTg3IDE1NC41ODkgMTA4LjMgMTU0LjQ3OSAxMDguMzg2IDE1NC4zMzhDMTA4LjQ3NSAxNTQuMTk3IDEwOC41NCAxNTQuMDM1IDEwOC41ODEgMTUzLjg1QzEwOC42MjMgMTUzLjY2NSAxMDguNjQ0IDE1My40NjcgMTA4LjY0NCAxNTMuMjU2WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8L2c+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwM180NDM3Xzk2ODU4KSI+CjxsaW5lIHgxPSIxMzIuNjUiIHkxPSIxNDcuMDcyIiB4Mj0iMTMyLjY1IiB5Mj0iMTQ2LjI1IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8cGF0aCBkPSJNMTI0LjcwOSAxNTIuMDI1VjE1Mi44OTNDMTI0LjcwOSAxNTMuMzU5IDEyNC42NjggMTUzLjc1MiAxMjQuNTg0IDE1NC4wNzJDMTI0LjUwMSAxNTQuMzkzIDEyNC4zODEgMTU0LjY1IDEyNC4yMjUgMTU0Ljg0NkMxMjQuMDY5IDE1NS4wNDEgMTIzLjg4IDE1NS4xODMgMTIzLjY1OCAxNTUuMjcxQzEyMy40NCAxNTUuMzU3IDEyMy4xOTIgMTU1LjQgMTIyLjkxNiAxNTUuNEMxMjIuNjk4IDE1NS40IDEyMi40OTYgMTU1LjM3MyAxMjIuMzExIDE1NS4zMThDMTIyLjEyNiAxNTUuMjY0IDEyMS45NTkgMTU1LjE3NiAxMjEuODExIDE1NS4wNTdDMTIxLjY2NSAxNTQuOTM0IDEyMS41NCAxNTQuNzc1IDEyMS40MzYgMTU0LjU4QzEyMS4zMzIgMTU0LjM4NSAxMjEuMjUyIDE1NC4xNDggMTIxLjE5OCAxNTMuODY5QzEyMS4xNDMgMTUzLjU5IDEyMS4xMTYgMTUzLjI2NSAxMjEuMTE2IDE1Mi44OTNWMTUyLjAyNUMxMjEuMTE2IDE1MS41NTkgMTIxLjE1NyAxNTEuMTY5IDEyMS4yNDEgMTUwLjg1NEMxMjEuMzI2IDE1MC41MzggMTIxLjQ0OCAxNTAuMjg2IDEyMS42MDQgMTUwLjA5NkMxMjEuNzYgMTQ5LjkwMyAxMjEuOTQ4IDE0OS43NjUgMTIyLjE2NiAxNDkuNjgyQzEyMi4zODggMTQ5LjU5OCAxMjIuNjM1IDE0OS41NTcgMTIyLjkwOCAxNDkuNTU3QzEyMy4xMyAxNDkuNTU3IDEyMy4zMzMgMTQ5LjU4NCAxMjMuNTE4IDE0OS42MzlDMTIzLjcwNSAxNDkuNjkxIDEyMy44NzIgMTQ5Ljc3NSAxMjQuMDE4IDE0OS44OTNDMTI0LjE2NCAxNTAuMDA3IDEyNC4yODcgMTUwLjE2MSAxMjQuMzg5IDE1MC4zNTRDMTI0LjQ5MyAxNTAuNTQ0IDEyNC41NzMgMTUwLjc3NyAxMjQuNjI3IDE1MS4wNTNDMTI0LjY4MiAxNTEuMzI5IDEyNC43MDkgMTUxLjY1MyAxMjQuNzA5IDE1Mi4wMjVaTTEyMy45ODMgMTUzLjAxVjE1MS45MDRDMTIzLjk4MyAxNTEuNjQ5IDEyMy45NjcgMTUxLjQyNSAxMjMuOTM2IDE1MS4yMzJDMTIzLjkwNyAxNTEuMDM3IDEyMy44NjQgMTUwLjg3IDEyMy44MDcgMTUwLjczMkMxMjMuNzUgMTUwLjU5NCAxMjMuNjc3IDE1MC40ODIgMTIzLjU4OCAxNTAuMzk2QzEyMy41MDIgMTUwLjMxMSAxMjMuNDAyIDE1MC4yNDggMTIzLjI4NyAxNTAuMjA5QzEyMy4xNzUgMTUwLjE2NyAxMjMuMDQ5IDE1MC4xNDYgMTIyLjkwOCAxNTAuMTQ2QzEyMi43MzcgMTUwLjE0NiAxMjIuNTg0IDE1MC4xNzkgMTIyLjQ1MSAxNTAuMjQ0QzEyMi4zMTkgMTUwLjMwNyAxMjIuMjA3IDE1MC40MDcgMTIyLjExNiAxNTAuNTQ1QzEyMi4wMjcgMTUwLjY4MyAxMjEuOTU5IDE1MC44NjQgMTIxLjkxMiAxNTEuMDg4QzEyMS44NjYgMTUxLjMxMiAxMjEuODQyIDE1MS41ODQgMTIxLjg0MiAxNTEuOTA0VjE1My4wMUMxMjEuODQyIDE1My4yNjUgMTIxLjg1NiAxNTMuNDkgMTIxLjg4NSAxNTMuNjg2QzEyMS45MTYgMTUzLjg4MSAxMjEuOTYyIDE1NC4wNSAxMjIuMDIyIDE1NC4xOTNDMTIyLjA4MiAxNTQuMzM0IDEyMi4xNTUgMTU0LjQ1IDEyMi4yNDEgMTU0LjU0MUMxMjIuMzI2IDE1NC42MzIgMTIyLjQyNSAxNTQuNyAxMjIuNTM3IDE1NC43NDRDMTIyLjY1MiAxNTQuNzg2IDEyMi43NzggMTU0LjgwNyAxMjIuOTE2IDE1NC44MDdDMTIzLjA5MyAxNTQuODA3IDEyMy4yNDggMTU0Ljc3MyAxMjMuMzgxIDE1NC43MDVDMTIzLjUxNCAxNTQuNjM3IDEyMy42MjUgMTU0LjUzMiAxMjMuNzEzIDE1NC4zODlDMTIzLjgwNCAxNTQuMjQzIDEyMy44NzIgMTU0LjA1NyAxMjMuOTE2IDE1My44M0MxMjMuOTYxIDE1My42MDEgMTIzLjk4MyAxNTMuMzI3IDEyMy45ODMgMTUzLjAxWk0xMjkuNTY1IDE1My40MDhWMTU0LjAwMkgxMjUuNDU2VjE1My41NzZMMTI4LjAwMyAxNDkuNjM1SDEyOC41OTNMMTI3Ljk2IDE1MC43NzVMMTI2LjI3NiAxNTMuNDA4SDEyOS41NjVaTTEyOC43NzIgMTQ5LjYzNVYxNTUuMzIySDEyOC4wNVYxNDkuNjM1SDEyOC43NzJaTTEzMy4zMTMgMTQ5LjYzNVYxNTUuMzIySDEzMi41NTlWMTQ5LjYzNUgxMzMuMzEzWk0xMzUuNjk1IDE1Mi4xOTNWMTUyLjgxMUgxMzMuMTQ5VjE1Mi4xOTNIMTM1LjY5NVpNMTM2LjA4MiAxNDkuNjM1VjE1MC4yNTJIMTMzLjE0OVYxNDkuNjM1SDEzNi4wODJaTTEzOC42MjIgMTU1LjRDMTM4LjMyOCAxNTUuNCAxMzguMDYxIDE1NS4zNTEgMTM3LjgyMSAxNTUuMjUyQzEzNy41ODQgMTU1LjE1IDEzNy4zOCAxNTUuMDA4IDEzNy4yMDggMTU0LjgyNkMxMzcuMDM4IDE1NC42NDQgMTM2LjkwOCAxNTQuNDI4IDEzNi44MTcgMTU0LjE3OEMxMzYuNzI2IDE1My45MjggMTM2LjY4IDE1My42NTQgMTM2LjY4IDE1My4zNTdWMTUzLjE5M0MxMzYuNjggMTUyLjg1IDEzNi43MzEgMTUyLjU0NCAxMzYuODMzIDE1Mi4yNzVDMTM2LjkzNCAxNTIuMDA1IDEzNy4wNzIgMTUxLjc3NSAxMzcuMjQ3IDE1MS41ODhDMTM3LjQyMSAxNTEuNCAxMzcuNjE5IDE1MS4yNTggMTM3Ljg0MSAxNTEuMTYyQzEzOC4wNjIgMTUxLjA2NiAxMzguMjkxIDE1MS4wMTggMTM4LjUyOCAxNTEuMDE4QzEzOC44MyAxNTEuMDE4IDEzOS4wOTEgMTUxLjA3IDEzOS4zMDkgMTUxLjE3NEMxMzkuNTMxIDE1MS4yNzggMTM5LjcxMiAxNTEuNDI0IDEzOS44NTIgMTUxLjYxMUMxMzkuOTkzIDE1MS43OTYgMTQwLjA5NyAxNTIuMDE1IDE0MC4xNjUgMTUyLjI2OEMxNDAuMjMyIDE1Mi41MTggMTQwLjI2NiAxNTIuNzkxIDE0MC4yNjYgMTUzLjA4OFYxNTMuNDEySDEzNy4xMVYxNTIuODIySDEzOS41NDRWMTUyLjc2OEMxMzkuNTMzIDE1Mi41OCAxMzkuNDk0IDE1Mi4zOTggMTM5LjQyNiAxNTIuMjIxQzEzOS4zNjEgMTUyLjA0NCAxMzkuMjU3IDE1MS44OTggMTM5LjExNCAxNTEuNzgzQzEzOC45NzEgMTUxLjY2OSAxMzguNzc1IDE1MS42MTEgMTM4LjUyOCAxNTEuNjExQzEzOC4zNjQgMTUxLjYxMSAxMzguMjEzIDE1MS42NDYgMTM4LjA3NSAxNTEuNzE3QzEzNy45MzcgMTUxLjc4NSAxMzcuODE4IDE1MS44ODYgMTM3LjcxOSAxNTIuMDIxQzEzNy42MiAxNTIuMTU3IDEzNy41NDQgMTUyLjMyMiAxMzcuNDg5IDE1Mi41MThDMTM3LjQzNCAxNTIuNzEzIDEzNy40MDcgMTUyLjkzOCAxMzcuNDA3IDE1My4xOTNWMTUzLjM1N0MxMzcuNDA3IDE1My41NTggMTM3LjQzNCAxNTMuNzQ3IDEzNy40ODkgMTUzLjkyNEMxMzcuNTQ2IDE1NC4wOTggMTM3LjYyOCAxNTQuMjUyIDEzNy43MzUgMTU0LjM4NUMxMzcuODQ0IDE1NC41MTggMTM3Ljk3NiAxNTQuNjIyIDEzOC4xMyAxNTQuNjk3QzEzOC4yODYgMTU0Ljc3MyAxMzguNDYzIDE1NC44MTEgMTM4LjY2MSAxNTQuODExQzEzOC45MTYgMTU0LjgxMSAxMzkuMTMyIDE1NC43NTggMTM5LjMwOSAxNTQuNjU0QzEzOS40ODYgMTU0LjU1IDEzOS42NDEgMTU0LjQxMSAxMzkuNzc0IDE1NC4yMzZMMTQwLjIxMiAxNTQuNTg0QzE0MC4xMiAxNTQuNzIyIDE0MC4wMDUgMTU0Ljg1NCAxMzkuODY0IDE1NC45NzlDMTM5LjcyMyAxNTUuMTA0IDEzOS41NSAxNTUuMjA1IDEzOS4zNDQgMTU1LjI4M0MxMzkuMTQxIDE1NS4zNjEgMTM4LjkgMTU1LjQgMTM4LjYyMiAxNTUuNFpNMTQxLjE4OSAxNDkuMzIySDE0MS45MTVWMTU0LjUwMkwxNDEuODUzIDE1NS4zMjJIMTQxLjE4OVYxNDkuMzIyWk0xNDQuNzcxIDE1My4xNzRWMTUzLjI1NkMxNDQuNzcxIDE1My41NjMgMTQ0LjczNCAxNTMuODQ4IDE0NC42NjEgMTU0LjExMUMxNDQuNTg5IDE1NC4zNzIgMTQ0LjQ4MiAxNTQuNTk4IDE0NC4zNDEgMTU0Ljc5MUMxNDQuMjAxIDE1NC45ODQgMTQ0LjAyOSAxNTUuMTMzIDE0My44MjYgMTU1LjI0QzE0My42MjIgMTU1LjM0NyAxNDMuMzg5IDE1NS40IDE0My4xMjYgMTU1LjRDMTQyLjg1OCAxNTUuNCAxNDIuNjIyIDE1NS4zNTUgMTQyLjQxOSAxNTUuMjY0QzE0Mi4yMTkgMTU1LjE3IDE0Mi4wNDkgMTU1LjAzNiAxNDEuOTExIDE1NC44NjFDMTQxLjc3MyAxNTQuNjg3IDE0MS42NjMgMTU0LjQ3NiAxNDEuNTc5IDE1NC4yMjlDMTQxLjQ5OSAxNTMuOTgxIDE0MS40NDMgMTUzLjcwMiAxNDEuNDExIDE1My4zOTNWMTUzLjAzM0MxNDEuNDQzIDE1Mi43MjEgMTQxLjQ5OSAxNTIuNDQxIDE0MS41NzkgMTUyLjE5M0MxNDEuNjYzIDE1MS45NDYgMTQxLjc3MyAxNTEuNzM1IDE0MS45MTEgMTUxLjU2MUMxNDIuMDQ5IDE1MS4zODMgMTQyLjIxOSAxNTEuMjQ5IDE0Mi40MTkgMTUxLjE1OEMxNDIuNjIgMTUxLjA2NCAxNDIuODUzIDE1MS4wMTggMTQzLjExOCAxNTEuMDE4QzE0My4zODQgMTUxLjAxOCAxNDMuNjIgMTUxLjA3IDE0My44MjYgMTUxLjE3NEMxNDQuMDMxIDE1MS4yNzUgMTQ0LjIwMyAxNTEuNDIxIDE0NC4zNDEgMTUxLjYxMUMxNDQuNDgyIDE1MS44MDEgMTQ0LjU4OSAxNTIuMDI5IDE0NC42NjEgMTUyLjI5NUMxNDQuNzM0IDE1Mi41NTggMTQ0Ljc3MSAxNTIuODUxIDE0NC43NzEgMTUzLjE3NFpNMTQ0LjA0NCAxNTMuMjU2VjE1My4xNzRDMTQ0LjA0NCAxNTIuOTYzIDE0NC4wMjUgMTUyLjc2NSAxNDMuOTg2IDE1Mi41OEMxNDMuOTQ3IDE1Mi4zOTMgMTQzLjg4NCAxNTIuMjI5IDE0My43OTggMTUyLjA4OEMxNDMuNzEyIDE1MS45NDUgMTQzLjU5OSAxNTEuODMzIDE0My40NTggMTUxLjc1MkMxNDMuMzE4IDE1MS42NjkgMTQzLjE0NSAxNTEuNjI3IDE0Mi45MzkgMTUxLjYyN0MxNDIuNzU3IDE1MS42MjcgMTQyLjU5OCAxNTEuNjU4IDE0Mi40NjIgMTUxLjcyMUMxNDIuMzI5IDE1MS43ODMgMTQyLjIxNiAxNTEuODY4IDE0Mi4xMjIgMTUxLjk3NUMxNDIuMDI5IDE1Mi4wNzkgMTQxLjk1MiAxNTIuMTk5IDE0MS44OTIgMTUyLjMzNEMxNDEuODM1IDE1Mi40NjcgMTQxLjc5MiAxNTIuNjA1IDE0MS43NjMgMTUyLjc0OFYxNTMuNjg5QzE0MS44MDUgMTUzLjg3MiAxNDEuODcyIDE1NC4wNDggMTQxLjk2NiAxNTQuMjE3QzE0Mi4wNjIgMTU0LjM4MyAxNDIuMTkgMTU0LjUyIDE0Mi4zNDkgMTU0LjYyN0MxNDIuNTEgMTU0LjczNCAxNDIuNzEgMTU0Ljc4NyAxNDIuOTQ3IDE1NC43ODdDMTQzLjE0MiAxNTQuNzg3IDE0My4zMDkgMTU0Ljc0OCAxNDMuNDQ3IDE1NC42N0MxNDMuNTg3IDE1NC41ODkgMTQzLjcwMSAxNTQuNDc5IDE0My43ODYgMTU0LjMzOEMxNDMuODc1IDE1NC4xOTcgMTQzLjk0IDE1NC4wMzUgMTQzLjk4MiAxNTMuODVDMTQ0LjAyMyAxNTMuNjY1IDE0NC4wNDQgMTUzLjQ2NyAxNDQuMDQ0IDE1My4yNTZaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjU0Ii8+CjwvZz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXA0XzQ0MzdfOTY4NTgpIj4KPGxpbmUgeDE9IjE2OC4wNSIgeTE9IjE0Ny4wNzIiIHgyPSIxNjguMDUiIHkyPSIxNDYuMjUiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjUiIHN0cm9rZS13aWR0aD0iMC41IiBzdHJva2UtbGluZWNhcD0ic3F1YXJlIi8+CjxwYXRoIGQ9Ik0xNjAuMTA5IDE1Mi4wMjVWMTUyLjg5M0MxNjAuMTA5IDE1My4zNTkgMTYwLjA2NyAxNTMuNzUyIDE1OS45ODQgMTU0LjA3MkMxNTkuOTAxIDE1NC4zOTMgMTU5Ljc4MSAxNTQuNjUgMTU5LjYyNSAxNTQuODQ2QzE1OS40NjkgMTU1LjA0MSAxNTkuMjggMTU1LjE4MyAxNTkuMDU4IDE1NS4yNzFDMTU4Ljg0IDE1NS4zNTcgMTU4LjU5MiAxNTUuNCAxNTguMzE2IDE1NS40QzE1OC4wOTcgMTU1LjQgMTU3Ljg5NiAxNTUuMzczIDE1Ny43MTEgMTU1LjMxOEMxNTcuNTI2IDE1NS4yNjQgMTU3LjM1OSAxNTUuMTc2IDE1Ny4yMTEgMTU1LjA1N0MxNTcuMDY1IDE1NC45MzQgMTU2Ljk0IDE1NC43NzUgMTU2LjgzNiAxNTQuNThDMTU2LjczMiAxNTQuMzg1IDE1Ni42NTIgMTU0LjE0OCAxNTYuNTk3IDE1My44NjlDMTU2LjU0MyAxNTMuNTkgMTU2LjUxNSAxNTMuMjY1IDE1Ni41MTUgMTUyLjg5M1YxNTIuMDI1QzE1Ni41MTUgMTUxLjU1OSAxNTYuNTU3IDE1MS4xNjkgMTU2LjY0IDE1MC44NTRDMTU2LjcyNiAxNTAuNTM4IDE1Ni44NDcgMTUwLjI4NiAxNTcuMDA0IDE1MC4wOTZDMTU3LjE2IDE0OS45MDMgMTU3LjM0NyAxNDkuNzY1IDE1Ny41NjYgMTQ5LjY4MkMxNTcuNzg4IDE0OS41OTggMTU4LjAzNSAxNDkuNTU3IDE1OC4zMDggMTQ5LjU1N0MxNTguNTMgMTQ5LjU1NyAxNTguNzMzIDE0OS41ODQgMTU4LjkxOCAxNDkuNjM5QzE1OS4xMDUgMTQ5LjY5MSAxNTkuMjcyIDE0OS43NzUgMTU5LjQxOCAxNDkuODkzQzE1OS41NjQgMTUwLjAwNyAxNTkuNjg3IDE1MC4xNjEgMTU5Ljc4OSAxNTAuMzU0QzE1OS44OTMgMTUwLjU0NCAxNTkuOTcyIDE1MC43NzcgMTYwLjAyNyAxNTEuMDUzQzE2MC4wODIgMTUxLjMyOSAxNjAuMTA5IDE1MS42NTMgMTYwLjEwOSAxNTIuMDI1Wk0xNTkuMzgzIDE1My4wMVYxNTEuOTA0QzE1OS4zODMgMTUxLjY0OSAxNTkuMzY3IDE1MS40MjUgMTU5LjMzNiAxNTEuMjMyQzE1OS4zMDcgMTUxLjAzNyAxNTkuMjY0IDE1MC44NyAxNTkuMjA3IDE1MC43MzJDMTU5LjE1IDE1MC41OTQgMTU5LjA3NyAxNTAuNDgyIDE1OC45ODggMTUwLjM5NkMxNTguOTAyIDE1MC4zMTEgMTU4LjgwMiAxNTAuMjQ4IDE1OC42ODcgMTUwLjIwOUMxNTguNTc1IDE1MC4xNjcgMTU4LjQ0OSAxNTAuMTQ2IDE1OC4zMDggMTUwLjE0NkMxNTguMTM2IDE1MC4xNDYgMTU3Ljk4NCAxNTAuMTc5IDE1Ny44NTEgMTUwLjI0NEMxNTcuNzE5IDE1MC4zMDcgMTU3LjYwNyAxNTAuNDA3IDE1Ny41MTUgMTUwLjU0NUMxNTcuNDI3IDE1MC42ODMgMTU3LjM1OSAxNTAuODY0IDE1Ny4zMTIgMTUxLjA4OEMxNTcuMjY1IDE1MS4zMTIgMTU3LjI0MiAxNTEuNTg0IDE1Ny4yNDIgMTUxLjkwNFYxNTMuMDFDMTU3LjI0MiAxNTMuMjY1IDE1Ny4yNTYgMTUzLjQ5IDE1Ny4yODUgMTUzLjY4NkMxNTcuMzE2IDE1My44ODEgMTU3LjM2MiAxNTQuMDUgMTU3LjQyMiAxNTQuMTkzQzE1Ny40ODIgMTU0LjMzNCAxNTcuNTU0IDE1NC40NSAxNTcuNjQgMTU0LjU0MUMxNTcuNzI2IDE1NC42MzIgMTU3LjgyNSAxNTQuNyAxNTcuOTM3IDE1NC43NDRDMTU4LjA1MiAxNTQuNzg2IDE1OC4xNzggMTU0LjgwNyAxNTguMzE2IDE1NC44MDdDMTU4LjQ5MyAxNTQuODA3IDE1OC42NDggMTU0Ljc3MyAxNTguNzgxIDE1NC43MDVDMTU4LjkxNCAxNTQuNjM3IDE1OS4wMjUgMTU0LjUzMiAxNTkuMTEzIDE1NC4zODlDMTU5LjIwNCAxNTQuMjQzIDE1OS4yNzIgMTU0LjA1NyAxNTkuMzE2IDE1My44M0MxNTkuMzYgMTUzLjYwMSAxNTkuMzgzIDE1My4zMjcgMTU5LjM4MyAxNTMuMDFaTTE2Mi4wMzYgMTUyLjYxNUwxNjEuNDU3IDE1Mi40NjdMMTYxLjc0MyAxNDkuNjM1SDE2NC42NjFWMTUwLjMwM0gxNjIuMzU2TDE2Mi4xODQgMTUxLjg1QzE2Mi4yODggMTUxLjc5IDE2Mi40MiAxNTEuNzM0IDE2Mi41NzkgMTUxLjY4MkMxNjIuNzQgMTUxLjYzIDE2Mi45MjUgMTUxLjYwNCAxNjMuMTMzIDE1MS42MDRDMTYzLjM5NiAxNTEuNjA0IDE2My42MzIgMTUxLjY0OSAxNjMuODQgMTUxLjc0QzE2NC4wNDkgMTUxLjgyOSAxNjQuMjI2IDE1MS45NTYgMTY0LjM3MSAxNTIuMTIzQzE2NC41MiAxNTIuMjkgMTY0LjYzMyAxNTIuNDkgMTY0LjcxMSAxNTIuNzI1QzE2NC43ODkgMTUyLjk1OSAxNjQuODI5IDE1My4yMjEgMTY0LjgyOSAxNTMuNTFDMTY0LjgyOSAxNTMuNzgzIDE2NC43OTEgMTU0LjAzNSAxNjQuNzE1IDE1NC4yNjRDMTY0LjY0MiAxNTQuNDkzIDE2NC41MzIgMTU0LjY5MyAxNjQuMzgzIDE1NC44NjVDMTY0LjIzNSAxNTUuMDM1IDE2NC4wNDcgMTU1LjE2NiAxNjMuODIxIDE1NS4yNkMxNjMuNTk3IDE1NS4zNTQgMTYzLjMzMiAxNTUuNCAxNjMuMDI4IDE1NS40QzE2Mi43OTkgMTU1LjQgMTYyLjU4MSAxNTUuMzY5IDE2Mi4zNzUgMTU1LjMwN0MxNjIuMTcyIDE1NS4yNDIgMTYxLjk5IDE1NS4xNDQgMTYxLjgyOSAxNTUuMDE0QzE2MS42NyAxNTQuODgxIDE2MS41MzkgMTU0LjcxNyAxNjEuNDM4IDE1NC41MjFDMTYxLjMzOSAxNTQuMzI0IDE2MS4yNzYgMTU0LjA5MiAxNjEuMjUgMTUzLjgyNkgxNjEuOTM4QzE2MS45NjkgMTU0LjA0IDE2Mi4wMzIgMTU0LjIxOSAxNjIuMTI1IDE1NC4zNjVDMTYyLjIxOSAxNTQuNTExIDE2Mi4zNDIgMTU0LjYyMiAxNjIuNDkzIDE1NC42OTdDMTYyLjY0NiAxNTQuNzcgMTYyLjgyNSAxNTQuODA3IDE2My4wMjggMTU0LjgwN0MxNjMuMiAxNTQuODA3IDE2My4zNTIgMTU0Ljc3NyAxNjMuNDg1IDE1NC43MTdDMTYzLjYxOCAxNTQuNjU3IDE2My43MyAxNTQuNTcxIDE2My44MjEgMTU0LjQ1OUMxNjMuOTEyIDE1NC4zNDcgMTYzLjk4MSAxNTQuMjEyIDE2NC4wMjggMTU0LjA1M0MxNjQuMDc3IDE1My44OTQgMTY0LjEwMiAxNTMuNzE1IDE2NC4xMDIgMTUzLjUxOEMxNjQuMTAyIDE1My4zMzggMTY0LjA3NyAxNTMuMTcxIDE2NC4wMjggMTUzLjAxOEMxNjMuOTc4IDE1Mi44NjQgMTYzLjkwNCAxNTIuNzMgMTYzLjgwNSAxNTIuNjE1QzE2My43MDkgMTUyLjUwMSAxNjMuNTkgMTUyLjQxMiAxNjMuNDUgMTUyLjM1QzE2My4zMDkgMTUyLjI4NSAxNjMuMTQ4IDE1Mi4yNTIgMTYyLjk2NSAxNTIuMjUyQzE2Mi43MjMgMTUyLjI1MiAxNjIuNTM5IDE1Mi4yODUgMTYyLjQxNCAxNTIuMzVDMTYyLjI5MiAxNTIuNDE1IDE2Mi4xNjYgMTUyLjUwMyAxNjIuMDM2IDE1Mi42MTVaTTE2OC43MTMgMTQ5LjYzNVYxNTUuMzIySDE2Ny45NTlWMTQ5LjYzNUgxNjguNzEzWk0xNzEuMDk1IDE1Mi4xOTNWMTUyLjgxMUgxNjguNTQ4VjE1Mi4xOTNIMTcxLjA5NVpNMTcxLjQ4MiAxNDkuNjM1VjE1MC4yNTJIMTY4LjU0OFYxNDkuNjM1SDE3MS40ODJaTTE3NC4wMjIgMTU1LjRDMTczLjcyNyAxNTUuNCAxNzMuNDYgMTU1LjM1MSAxNzMuMjIxIDE1NS4yNTJDMTcyLjk4NCAxNTUuMTUgMTcyLjc4IDE1NS4wMDggMTcyLjYwOCAxNTQuODI2QzE3Mi40MzggMTU0LjY0NCAxNzIuMzA4IDE1NC40MjggMTcyLjIxNyAxNTQuMTc4QzE3Mi4xMjYgMTUzLjkyOCAxNzIuMDggMTUzLjY1NCAxNzIuMDggMTUzLjM1N1YxNTMuMTkzQzE3Mi4wOCAxNTIuODUgMTcyLjEzMSAxNTIuNTQ0IDE3Mi4yMzMgMTUyLjI3NUMxNzIuMzM0IDE1Mi4wMDUgMTcyLjQ3MiAxNTEuNzc1IDE3Mi42NDcgMTUxLjU4OEMxNzIuODIxIDE1MS40IDE3My4wMTkgMTUxLjI1OCAxNzMuMjQgMTUxLjE2MkMxNzMuNDYyIDE1MS4wNjYgMTczLjY5MSAxNTEuMDE4IDE3My45MjggMTUxLjAxOEMxNzQuMjMgMTUxLjAxOCAxNzQuNDkgMTUxLjA3IDE3NC43MDkgMTUxLjE3NEMxNzQuOTMxIDE1MS4yNzggMTc1LjExMiAxNTEuNDI0IDE3NS4yNTIgMTUxLjYxMUMxNzUuMzkzIDE1MS43OTYgMTc1LjQ5NyAxNTIuMDE1IDE3NS41NjUgMTUyLjI2OEMxNzUuNjMyIDE1Mi41MTggMTc1LjY2NiAxNTIuNzkxIDE3NS42NjYgMTUzLjA4OFYxNTMuNDEySDE3Mi41MVYxNTIuODIySDE3NC45NDRWMTUyLjc2OEMxNzQuOTMzIDE1Mi41OCAxNzQuODk0IDE1Mi4zOTggMTc0LjgyNiAxNTIuMjIxQzE3NC43NjEgMTUyLjA0NCAxNzQuNjU3IDE1MS44OTggMTc0LjUxNCAxNTEuNzgzQzE3NC4zNzEgMTUxLjY2OSAxNzQuMTc1IDE1MS42MTEgMTczLjkyOCAxNTEuNjExQzE3My43NjQgMTUxLjYxMSAxNzMuNjEzIDE1MS42NDYgMTczLjQ3NSAxNTEuNzE3QzE3My4zMzcgMTUxLjc4NSAxNzMuMjE4IDE1MS44ODYgMTczLjExOSAxNTIuMDIxQzE3My4wMiAxNTIuMTU3IDE3Mi45NDQgMTUyLjMyMiAxNzIuODg5IDE1Mi41MThDMTcyLjgzNCAxNTIuNzEzIDE3Mi44MDcgMTUyLjkzOCAxNzIuODA3IDE1My4xOTNWMTUzLjM1N0MxNzIuODA3IDE1My41NTggMTcyLjgzNCAxNTMuNzQ3IDE3Mi44ODkgMTUzLjkyNEMxNzIuOTQ2IDE1NC4wOTggMTczLjAyOCAxNTQuMjUyIDE3My4xMzUgMTU0LjM4NUMxNzMuMjQ0IDE1NC41MTggMTczLjM3NiAxNTQuNjIyIDE3My41MyAxNTQuNjk3QzE3My42ODYgMTU0Ljc3MyAxNzMuODYzIDE1NC44MTEgMTc0LjA2MSAxNTQuODExQzE3NC4zMTYgMTU0LjgxMSAxNzQuNTMyIDE1NC43NTggMTc0LjcwOSAxNTQuNjU0QzE3NC44ODYgMTU0LjU1IDE3NS4wNDEgMTU0LjQxMSAxNzUuMTc0IDE1NC4yMzZMMTc1LjYxMiAxNTQuNTg0QzE3NS41MiAxNTQuNzIyIDE3NS40MDUgMTU0Ljg1NCAxNzUuMjY0IDE1NC45NzlDMTc1LjEyMyAxNTUuMTA0IDE3NC45NSAxNTUuMjA1IDE3NC43NDQgMTU1LjI4M0MxNzQuNTQxIDE1NS4zNjEgMTc0LjMgMTU1LjQgMTc0LjAyMiAxNTUuNFpNMTc2LjU4OSAxNDkuMzIySDE3Ny4zMTVWMTU0LjUwMkwxNzcuMjUzIDE1NS4zMjJIMTc2LjU4OVYxNDkuMzIyWk0xODAuMTcxIDE1My4xNzRWMTUzLjI1NkMxODAuMTcxIDE1My41NjMgMTgwLjEzNCAxNTMuODQ4IDE4MC4wNjEgMTU0LjExMUMxNzkuOTg4IDE1NC4zNzIgMTc5Ljg4MiAxNTQuNTk4IDE3OS43NDEgMTU0Ljc5MUMxNzkuNiAxNTQuOTg0IDE3OS40MjkgMTU1LjEzMyAxNzkuMjI1IDE1NS4yNEMxNzkuMDIyIDE1NS4zNDcgMTc4Ljc4OSAxNTUuNCAxNzguNTI2IDE1NS40QzE3OC4yNTggMTU1LjQgMTc4LjAyMiAxNTUuMzU1IDE3Ny44MTkgMTU1LjI2NEMxNzcuNjE5IDE1NS4xNyAxNzcuNDQ5IDE1NS4wMzYgMTc3LjMxMSAxNTQuODYxQzE3Ny4xNzMgMTU0LjY4NyAxNzcuMDYzIDE1NC40NzYgMTc2Ljk3OSAxNTQuMjI5QzE3Ni44OTkgMTUzLjk4MSAxNzYuODQzIDE1My43MDIgMTc2LjgxMSAxNTMuMzkzVjE1My4wMzNDMTc2Ljg0MyAxNTIuNzIxIDE3Ni44OTkgMTUyLjQ0MSAxNzYuOTc5IDE1Mi4xOTNDMTc3LjA2MyAxNTEuOTQ2IDE3Ny4xNzMgMTUxLjczNSAxNzcuMzExIDE1MS41NjFDMTc3LjQ0OSAxNTEuMzgzIDE3Ny42MTkgMTUxLjI0OSAxNzcuODE5IDE1MS4xNThDMTc4LjAyIDE1MS4wNjQgMTc4LjI1MyAxNTEuMDE4IDE3OC41MTggMTUxLjAxOEMxNzguNzg0IDE1MS4wMTggMTc5LjAyIDE1MS4wNyAxNzkuMjI1IDE1MS4xNzRDMTc5LjQzMSAxNTEuMjc1IDE3OS42MDMgMTUxLjQyMSAxNzkuNzQxIDE1MS42MTFDMTc5Ljg4MiAxNTEuODAxIDE3OS45ODggMTUyLjAyOSAxODAuMDYxIDE1Mi4yOTVDMTgwLjEzNCAxNTIuNTU4IDE4MC4xNzEgMTUyLjg1MSAxODAuMTcxIDE1My4xNzRaTTE3OS40NDQgMTUzLjI1NlYxNTMuMTc0QzE3OS40NDQgMTUyLjk2MyAxNzkuNDI1IDE1Mi43NjUgMTc5LjM4NiAxNTIuNThDMTc5LjM0NyAxNTIuMzkzIDE3OS4yODQgMTUyLjIyOSAxNzkuMTk4IDE1Mi4wODhDMTc5LjExMiAxNTEuOTQ1IDE3OC45OTkgMTUxLjgzMyAxNzguODU4IDE1MS43NTJDMTc4LjcxOCAxNTEuNjY5IDE3OC41NDQgMTUxLjYyNyAxNzguMzM5IDE1MS42MjdDMTc4LjE1NiAxNTEuNjI3IDE3Ny45OTggMTUxLjY1OCAxNzcuODYyIDE1MS43MjFDMTc3LjcyOSAxNTEuNzgzIDE3Ny42MTYgMTUxLjg2OCAxNzcuNTIyIDE1MS45NzVDMTc3LjQyOSAxNTIuMDc5IDE3Ny4zNTIgMTUyLjE5OSAxNzcuMjkyIDE1Mi4zMzRDMTc3LjIzNSAxNTIuNDY3IDE3Ny4xOTIgMTUyLjYwNSAxNzcuMTYzIDE1Mi43NDhWMTUzLjY4OUMxNzcuMjA1IDE1My44NzIgMTc3LjI3MiAxNTQuMDQ4IDE3Ny4zNjYgMTU0LjIxN0MxNzcuNDYyIDE1NC4zODMgMTc3LjU5IDE1NC41MiAxNzcuNzQ5IDE1NC42MjdDMTc3LjkxIDE1NC43MzQgMTc4LjExIDE1NC43ODcgMTc4LjM0NyAxNTQuNzg3QzE3OC41NDIgMTU0Ljc4NyAxNzguNzA4IDE1NC43NDggMTc4Ljg0NyAxNTQuNjdDMTc4Ljk4NyAxNTQuNTg5IDE3OS4xIDE1NC40NzkgMTc5LjE4NiAxNTQuMzM4QzE3OS4yNzUgMTU0LjE5NyAxNzkuMzQgMTU0LjAzNSAxNzkuMzgyIDE1My44NUMxNzkuNDIzIDE1My42NjUgMTc5LjQ0NCAxNTMuNDY3IDE3OS40NDQgMTUzLjI1NloiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPC9nPgo8cGF0aCBkPSJNMTQuNSAxNy41SDI3SDYwLjVDNjEuMDUyMyAxNy41IDYxLjUgMTcuOTQ3NyA2MS41IDE4LjVWMjZDNjEuNSAyNi41NTIzIDYxLjk0NzcgMjcgNjIuNSAyN0g5NkM5Ni41NTIzIDI3IDk3IDI2LjU1MjMgOTcgMjZWMTIuNUM5NyAxMS45NDc3IDk3LjQ0NzcgMTEuNSA5OCAxMS41SDEzMkMxMzIuNTUyIDExLjUgMTMzIDExLjk0NzcgMTMzIDEyLjVWMzIuNUMxMzMgMzMuMDUyMyAxMzMuNDQ4IDMzLjUgMTM0IDMzLjVIMTY3LjVDMTY4LjA1MiAzMy41IDE2OC41IDMzLjA1MjMgMTY4LjUgMzIuNVYyNEMxNjguNSAyMy40NDc3IDE2OC45NDggMjMgMTY5LjUgMjNIMTg2IiBzdHJva2U9IiNGRkMxMDciIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+CjxwYXRoIGQ9Ik0xMSAxMTdIMjdWMTI3LjVINjJWNzkuMTYxMUg5N1YxMDFIMTMyVjg5LjI2NzVIMTY4LjVWMTIxLjVIMTg2IiBzdHJva2U9IiM0Q0FGNTAiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+CjxwYXRoIGQ9Ik0xMiA2MC41SDI4VjY5LjVINjIuNVY0N0w5NyA0Ny4wMDAyVjU3LjAwMDJMMTMzIDU3VjY5LjVIMTY4SDE4NS41IiBzdHJva2U9IiMyMTk2RjMiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+CjwvZz4KPGRlZnM+CjxjbGlwUGF0aCBpZD0iY2xpcDBfNDQzN185Njg1OCI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTYwIiBmaWxsPSJ3aGl0ZSIvPgo8L2NsaXBQYXRoPgo8Y2xpcFBhdGggaWQ9ImNsaXAxXzQ0MzdfOTY4NTgiPgo8cmVjdCB3aWR0aD0iMzUuNCIgaGVpZ2h0PSIxMC4zMjIiIGZpbGw9IndoaXRlIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg0NC4zOTk5IDE0NikiLz4KPC9jbGlwUGF0aD4KPGNsaXBQYXRoIGlkPSJjbGlwMl80NDM3Xzk2ODU4Ij4KPHJlY3Qgd2lkdGg9IjM1LjQiIGhlaWdodD0iMTAuMzIyIiBmaWxsPSJ3aGl0ZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNzkuNzk5OCAxNDYpIi8+CjwvY2xpcFBhdGg+CjxjbGlwUGF0aCBpZD0iY2xpcDNfNDQzN185Njg1OCI+CjxyZWN0IHdpZHRoPSIzNS40IiBoZWlnaHQ9IjEwLjMyMiIgZmlsbD0id2hpdGUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDExNS4yIDE0NikiLz4KPC9jbGlwUGF0aD4KPGNsaXBQYXRoIGlkPSJjbGlwNF80NDM3Xzk2ODU4Ij4KPHJlY3Qgd2lkdGg9IjM1LjQiIGhlaWdodD0iMTAuMzIyIiBmaWxsPSJ3aGl0ZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTUwLjYgMTQ2KSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo=", "description": "Displays changes to the state of the entity over time. For example, online and offline.", "descriptor": { "type": "timeseries", "sizeX": 8, "sizeY": 5, "resources": [], - "templateHtml": "\n", - "templateCss": ".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n", - "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.flotWidget.onDataUpdated();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.$scope.flotWidget.onLatestDataUpdated();\n}\n\nself.onResize = function() {\n self.ctx.$scope.flotWidget.onResize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.flotWidget.onEditModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.$scope.flotWidget.onDestroy();\n}\n\nself.typeParameters = function() {\n return {\n stateData: true,\n hasAdditionalLatestDataKeys: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'temperature', label: 'Temperature', type: 'timeseries', units: '°C', decimals: 0 }];\n }\n };\n}\n\n", + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.timeSeriesChartWidget.onInit();\n};\n\nself.onDataUpdated = function() {\n self.ctx.$scope.timeSeriesChartWidget.onDataUpdated();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.$scope.timeSeriesChartWidget.onLatestDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n stateData: true,\n chartType: 'state',\n previewWidth: '80%',\n embedTitlePanel: true,\n hasAdditionalLatestDataKeys: true,\n dataKeySettingsFunction: TbTimeSeriesChart.dataKeySettings('state'),\n defaultDataKeysFunction: function() {\n return [{ name: 'state', label: 'State', type: 'timeseries', units: '', decimals: 0 }];\n }\n };\n}\n", "settingsSchema": "{}", "dataKeySettingsSchema": "{}", - "settingsDirective": "tb-flot-line-widget-settings", - "dataKeySettingsDirective": "tb-flot-line-key-settings", - "latestDataKeySettingsDirective": "tb-flot-latest-key-settings", + "latestDataKeySettingsSchema": "{}", + "settingsDirective": "tb-time-series-chart-widget-settings", + "dataKeySettingsDirective": "tb-time-series-chart-key-settings", + "latestDataKeySettingsDirective": "", "hasBasicMode": true, - "basicModeDirective": "tb-flot-basic-config", - "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Switch 1\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false,\"axisPosition\":\"left\",\"showSeparateAxis\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"return Math.random() > 0.5 ? 1 : 0;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Switch 2\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false,\"axisPosition\":\"left\"},\"_hash\":0.12775350966079668,\"funcBody\":\"return Math.random() <= 0.5 ? 1 : 0;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"stack\":false,\"fontSize\":10,\"fontColor\":\"#545454\",\"showTooltip\":true,\"tooltipIndividual\":false,\"tooltipCumulative\":false,\"hideZeros\":false,\"tooltipValueFormatter\":\"if (value > 0 && value <= 1) {\\n return 'On';\\n} else if (value === 0) {\\n return 'Off';\\n} else {\\n return '';\\n}\",\"grid\":{\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1,\"color\":\"#545454\",\"backgroundColor\":null,\"tickColor\":\"#DDDDDD\"},\"xaxis\":{\"title\":null,\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"min\":0,\"max\":1.2,\"title\":null,\"showLabels\":true,\"color\":\"#545454\",\"tickSize\":null,\"tickDecimals\":0,\"ticksFormatter\":\"if (value > 0 && value <= 1) {\\n return 'On';\\n} else if (value === 0) {\\n return 'Off';\\n} else {\\n return '';\\n}\"},\"shadowSize\":4,\"smoothLines\":false,\"comparisonEnabled\":false,\"timeForComparison\":\"previousInterval\",\"comparisonCustomIntervalValue\":7200000,\"xaxisSecond\":{\"axisPosition\":\"top\",\"title\":null,\"showLabels\":true},\"showLegend\":true,\"legendConfig\":{\"direction\":\"column\",\"position\":\"right\",\"sortDataKeys\":false,\"showMin\":false,\"showMax\":false,\"showAvg\":false,\"showTotal\":false,\"showLatest\":false},\"customLegendEnabled\":false,\"dataKeysListForLabels\":[]},\"title\":\"State Chart\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null,\"widgetStyle\":{},\"useDashboardTimewindow\":true,\"showLegend\":true,\"actions\":{},\"configMode\":\"basic\",\"showTitleIcon\":false,\"titleIcon\":\"waterfall_chart\",\"iconColor\":\"#1F6BDD\"}" + "basicModeDirective": "tb-time-series-chart-basic-config", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Switch 1\",\"color\":\"#2196f3\",\"settings\":{\"yAxisId\":\"default\",\"showInLegend\":true,\"dataHiddenByDefault\":false,\"type\":\"line\",\"lineSettings\":{\"showLine\":true,\"step\":true,\"stepType\":\"end\",\"smooth\":false,\"lineType\":\"solid\",\"lineWidth\":2,\"showPoints\":false,\"showPointLabel\":false,\"pointLabelPosition\":\"top\",\"pointLabelFont\":{\"family\":\"Roboto\",\"size\":11,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"400\",\"lineHeight\":\"1\"},\"pointLabelColor\":\"rgba(0, 0, 0, 0.76)\",\"enablePointLabelBackground\":false,\"pointLabelBackground\":\"rgba(255,255,255,0.56)\",\"pointShape\":\"circle\",\"pointSize\":12,\"fillAreaSettings\":{\"type\":\"opacity\",\"opacity\":0.4,\"gradient\":{\"start\":100,\"end\":0}}},\"barSettings\":{\"showBorder\":false,\"borderWidth\":2,\"borderRadius\":0,\"showLabel\":false,\"labelPosition\":\"top\",\"labelFont\":{\"family\":\"Roboto\",\"size\":11,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"400\",\"lineHeight\":\"1\"},\"labelColor\":\"rgba(0, 0, 0, 0.76)\",\"enableLabelBackground\":false,\"labelBackground\":\"rgba(255,255,255,0.56)\",\"backgroundSettings\":{\"type\":\"none\",\"opacity\":0.4,\"gradient\":{\"start\":100,\"end\":0}}},\"tooltipValueFormatter\":\"\"},\"_hash\":0.676226248393859,\"funcBody\":\"return Math.random() > 0.5 ? true : false;\",\"decimals\":0,\"aggregationType\":null,\"usePostProcessing\":null,\"postFuncBody\":null,\"units\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Switch 2\",\"color\":\"#FFC107\",\"settings\":{\"yAxisId\":\"default\",\"showInLegend\":true,\"dataHiddenByDefault\":false,\"type\":\"line\",\"lineSettings\":{\"showLine\":true,\"step\":true,\"stepType\":\"end\",\"smooth\":false,\"lineType\":\"solid\",\"lineWidth\":2,\"showPoints\":false,\"showPointLabel\":false,\"pointLabelPosition\":\"top\",\"pointLabelFont\":{\"family\":\"Roboto\",\"size\":11,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"400\",\"lineHeight\":\"1\"},\"pointLabelColor\":\"rgba(0, 0, 0, 0.76)\",\"enablePointLabelBackground\":false,\"pointLabelBackground\":\"rgba(255,255,255,0.56)\",\"pointShape\":\"circle\",\"pointSize\":12,\"fillAreaSettings\":{\"type\":\"none\",\"opacity\":0.4,\"gradient\":{\"start\":100,\"end\":0}}},\"barSettings\":{\"showBorder\":false,\"borderWidth\":2,\"borderRadius\":0,\"showLabel\":false,\"labelPosition\":\"top\",\"labelFont\":{\"family\":\"Roboto\",\"size\":11,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"400\",\"lineHeight\":\"1\"},\"labelColor\":\"rgba(0, 0, 0, 0.76)\",\"enableLabelBackground\":false,\"labelBackground\":\"rgba(255,255,255,0.56)\",\"backgroundSettings\":{\"type\":\"none\",\"opacity\":0.4,\"gradient\":{\"start\":100,\"end\":0}}},\"tooltipValueFormatter\":null},\"_hash\":0.1106990458957191,\"funcBody\":\"return Math.random() <= 0.5 ? true : false;\",\"decimals\":0,\"aggregationType\":null,\"usePostProcessing\":null,\"postFuncBody\":null,\"units\":null}],\"alarmFilterConfig\":{\"statusList\":[\"ACTIVE\"]},\"latestDataKeys\":null}],\"timewindow\":{\"hideInterval\":false,\"hideLastInterval\":false,\"hideQuickInterval\":false,\"hideAggregation\":false,\"hideAggInterval\":false,\"hideTimezone\":false,\"selectedTab\":0,\"realtime\":{\"realtimeType\":0,\"timewindowMs\":60000,\"quickInterval\":\"CURRENT_DAY\",\"interval\":1000},\"aggregation\":{\"type\":\"NONE\",\"limit\":25000},\"timezone\":null},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"yAxes\":{\"default\":{\"units\":null,\"decimals\":0,\"show\":true,\"label\":\"\",\"labelFont\":{\"family\":\"Roboto\",\"size\":12,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"600\",\"lineHeight\":\"1\"},\"labelColor\":\"rgba(0, 0, 0, 0.54)\",\"position\":\"left\",\"showTickLabels\":true,\"tickLabelFont\":{\"family\":\"Roboto\",\"size\":12,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"400\",\"lineHeight\":\"1\"},\"tickLabelColor\":\"rgba(0, 0, 0, 0.54)\",\"ticksFormatter\":\"\",\"showTicks\":true,\"ticksColor\":\"rgba(0, 0, 0, 0.54)\",\"showLine\":true,\"lineColor\":\"rgba(0, 0, 0, 0.54)\",\"showSplitLines\":true,\"splitLinesColor\":\"rgba(0, 0, 0, 0.12)\",\"id\":\"default\",\"order\":0,\"interval\":null,\"splitNumber\":null,\"min\":null,\"max\":null,\"ticksGenerator\":\"\"}},\"thresholds\":[],\"dataZoom\":true,\"stack\":false,\"xAxis\":{\"show\":true,\"label\":\"\",\"labelFont\":{\"family\":\"Roboto\",\"size\":12,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"600\",\"lineHeight\":\"1\"},\"labelColor\":\"rgba(0, 0, 0, 0.54)\",\"position\":\"bottom\",\"showTickLabels\":true,\"tickLabelFont\":{\"family\":\"Roboto\",\"size\":10,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"400\",\"lineHeight\":\"1\"},\"tickLabelColor\":\"rgba(0, 0, 0, 0.54)\",\"ticksFormat\":{},\"showTicks\":true,\"ticksColor\":\"rgba(0, 0, 0, 0.54)\",\"showLine\":true,\"lineColor\":\"rgba(0, 0, 0, 0.54)\",\"showSplitLines\":true,\"splitLinesColor\":\"rgba(0, 0, 0, 0.12)\"},\"noAggregationBarWidthSettings\":{\"strategy\":\"group\",\"groupWidth\":{\"relative\":true,\"relativeWidth\":2,\"absoluteWidth\":1000},\"barWidth\":{\"relative\":true,\"relativeWidth\":2,\"absoluteWidth\":1000}},\"showLegend\":true,\"legendLabelFont\":{\"family\":\"Roboto\",\"size\":12,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"400\",\"lineHeight\":\"16px\"},\"legendLabelColor\":\"rgba(0, 0, 0, 0.76)\",\"legendConfig\":{\"direction\":\"column\",\"position\":\"right\",\"sortDataKeys\":false,\"showMin\":false,\"showMax\":false,\"showAvg\":false,\"showTotal\":false,\"showLatest\":false},\"showTooltip\":true,\"tooltipTrigger\":\"axis\",\"tooltipValueFont\":{\"family\":\"Roboto\",\"size\":12,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"500\",\"lineHeight\":\"16px\"},\"tooltipValueColor\":\"rgba(0, 0, 0, 0.76)\",\"tooltipValueFormatter\":\"\",\"tooltipShowDate\":true,\"tooltipDateFormat\":{\"format\":null,\"lastUpdateAgo\":false,\"custom\":false,\"auto\":true,\"autoDateFormatSettings\":{\"millisecond\":\"MMM dd yyyy HH:mm:ss\"}},\"tooltipDateFont\":{\"family\":\"Roboto\",\"size\":11,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"400\",\"lineHeight\":\"16px\"},\"tooltipDateColor\":\"rgba(0, 0, 0, 0.76)\",\"tooltipDateInterval\":true,\"tooltipBackgroundColor\":\"rgba(255, 255, 255, 0.76)\",\"tooltipBackgroundBlur\":4,\"animation\":{\"animation\":true,\"animationThreshold\":2000,\"animationDuration\":500,\"animationEasing\":\"cubicOut\",\"animationDelay\":0,\"animationDurationUpdate\":300,\"animationEasingUpdate\":\"cubicOut\",\"animationDelayUpdate\":0},\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"padding\":\"12px\",\"states\":[{\"label\":\"Off\",\"value\":0,\"sourceType\":\"constant\",\"sourceValue\":false},{\"label\":\"On\",\"value\":1,\"sourceType\":\"constant\",\"sourceValue\":true}]},\"title\":\"State chart\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":null,\"mobileHeight\":null,\"configMode\":\"basic\",\"actions\":{},\"showTitleIcon\":false,\"titleIcon\":\"thermostat\",\"iconColor\":\"#1F6BDD\",\"useDashboardTimewindow\":false,\"displayTimewindow\":true,\"titleFont\":{\"size\":16,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"500\",\"style\":\"normal\",\"lineHeight\":\"24px\"},\"titleColor\":\"rgba(0, 0, 0, 0.87)\",\"titleTooltip\":\"\",\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"units\":\"\",\"decimals\":null,\"noDataDisplayMessage\":\"\",\"timewindowStyle\":{\"showIcon\":false,\"iconSize\":\"24px\",\"icon\":null,\"iconPosition\":\"left\",\"font\":{\"size\":12,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"400\",\"style\":\"normal\",\"lineHeight\":\"16px\"},\"color\":\"rgba(0, 0, 0, 0.38)\",\"displayTypePrefix\":true},\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"0px\"}" }, - "externalId": null, - "tags": null + "tags": [ + "chart", + "time series", + "time-series", + "state", + "state chart", + "online", + "offline" + ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/state_chart_deprecated.json b/application/src/main/data/json/system/widget_types/state_chart_deprecated.json new file mode 100644 index 0000000000..90fc886d62 --- /dev/null +++ b/application/src/main/data/json/system/widget_types/state_chart_deprecated.json @@ -0,0 +1,25 @@ +{ + "fqn": "charts.state_chart", + "name": "State Chart", + "deprecated": true, + "image": "tb-image:c3RhdGVfY2hhcnRfc3lzdGVtX3dpZGdldF9pbWFnZS5wbmc=:IlN0YXRlIENoYXJ0IiBzeXN0ZW0gd2lkZ2V0IGltYWdl;data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAB9VBMVEUAAAAhlvMilvMymeE+nNVDoetInslNq/VZqOdpuPd3d3d5p5Z5suB6enp8fHyBgYGDg4ODqoqEhISIiIiKioqKuN2MjIyNjY2Ojo6QkJCRkZGSkpKUlJSVlZWWlpaXl5eYmJiZmZmampqcnJydnZ2enp6goKCgr3KhoaGioqKisXSjo6OjvsmkpKSlpaWlwdempqanp6eoqKiosGOo1vqpqampsGOqqqqqsWOrq6usrKysw9Wurq6wsLCysrK0tLS1tbW1wbK2tra3t7e4wau5ubm6urq7u7u8vLy9vb2+vr6/xbTAwMDAxbjCwsLC3ejDw8PExMTFxcXGxsbHx8fIv3jIyMjJycnKysrK5vzMzc7Nzc3Ozs7Pz8/QuDnRzcHS0tLT09PT39HU1NTU6/3VzLLV1dXV3sjW1tbX19fYuTHY2NjZ2dna0Ira2trb29vc3Nzex4De3t7f39/guyvhvCfhvCvh4eHh6Nbi4uLjvCTj4+PkyXbk5OTlvCTm5ubm693o6Ojpxl7q6urr6+vswTTs7Ozt7e3uvhju7u7vvhjv7+/wvxjw8PDx8fHyvxX0yTv09PT19fX29vb39/f4wyL4+Pj5+fn6+vr75J37+/v8whP8/Pz9/f39/v/+/v7/wQf/xRb/3HT/5JH/9tz/++/////APs7XAAAAAWJLR0Smt7AblQAAA1RJREFUeNrt3dlT01AUBvAEd8UNl2oLrdrFolZArUul1hWlVhQXFAUF1xaxIqi4FUQUrDsUClZi4nb+Th96S9NQkjDOOKZ+3wuZw8md++M25OXMlKOccJQnTUYocdRqdw8UAqTfOjG4lvr7uowOqbtAtG6o5OiGFoNDapuJHO8tFK4xOKS9msTVgoUiaQjPclnSl01snZ/y4ly2yBNZbRarbc+3yvdpt/hLawOPJp9ur7O0mRiEmzFkY1M6N/4I8qJpulzR2sBx1sgRjQuyA2pk+STdylw2VqR/FPFfWdcCvjhduiM7kVF2db1Nuspu7JGes+I76SRba7f0Wfnn/6F6It+mfo7m8TvYvh7KTiT3PQIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggPwXkFa7e7AQIP3WiUFzdl7LuBDFvJZxIYp5LeNCFPNaBn7Yc+e1KlheSYcrFCniL7LZqPl8cbp0TjavNcqu9rZJB9gdPdIJVnwjbWG1ndI95UzWM9V5rS9Ti3P4VWy1+5jXAgQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAMK+FeS3Ma2FeC/NamNfCCxEQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBA/hZkTKSkwSGn7VUJOmOLV241NiRSRZ2uYcvt+Io+Y0N83USm+MqGWEnU2JCqXqI1ZE+QhYiIy36tXR5INpOQbB5kftcmK85mtdey2iJekeWqX6/3kc+TLCQTrq6eEuZCgKTsbnNXBsIZOERJMedNQnry73XpW8UAKVhIyBPScVfQE9RqEaP7iMT6g+pdw7vKbxKl3IJqV8K7OUqXPHvG9UMGykk2lz1d4g5yvNXo6QqZiA41aHT5OwQTUWBJSrWrvlMwj1jFU2f1Q8K1dCysCUmVhdenNLvMRKZt3hGNriEnxfxlGqt9aPYRkb9bP6Q1SMFrmltMukKuMT2QpckWjc/WhP2lYB/TgjzdHyCKVM/gGXnsp+qY5hbba+hIVA/ETL0+9SepsoNiTs/igGpXZIhKqVv9QVJARIfXIWqfiM1nS+qBnHdae1V7Qss8ngEijRO5a6sMCAtdqv+HfgPwpNPbU6ipOwAAAABJRU5ErkJggg==", + "description": "Displays changes to the state of the entity over time. For example, online and offline.", + "descriptor": { + "type": "timeseries", + "sizeX": 8, + "sizeY": 5, + "resources": [], + "templateHtml": "\n", + "templateCss": ".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.flotWidget.onDataUpdated();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.$scope.flotWidget.onLatestDataUpdated();\n}\n\nself.onResize = function() {\n self.ctx.$scope.flotWidget.onResize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.flotWidget.onEditModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.$scope.flotWidget.onDestroy();\n}\n\nself.typeParameters = function() {\n return {\n stateData: true,\n hasAdditionalLatestDataKeys: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'temperature', label: 'Temperature', type: 'timeseries', units: '°C', decimals: 0 }];\n }\n };\n}\n\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}", + "settingsDirective": "tb-flot-line-widget-settings", + "dataKeySettingsDirective": "tb-flot-line-key-settings", + "latestDataKeySettingsDirective": "tb-flot-latest-key-settings", + "hasBasicMode": true, + "basicModeDirective": "tb-flot-basic-config", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Switch 1\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false,\"axisPosition\":\"left\",\"showSeparateAxis\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"return Math.random() > 0.5 ? 1 : 0;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Switch 2\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false,\"axisPosition\":\"left\"},\"_hash\":0.12775350966079668,\"funcBody\":\"return Math.random() <= 0.5 ? 1 : 0;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"stack\":false,\"fontSize\":10,\"fontColor\":\"#545454\",\"showTooltip\":true,\"tooltipIndividual\":false,\"tooltipCumulative\":false,\"hideZeros\":false,\"tooltipValueFormatter\":\"if (value > 0 && value <= 1) {\\n return 'On';\\n} else if (value === 0) {\\n return 'Off';\\n} else {\\n return '';\\n}\",\"grid\":{\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1,\"color\":\"#545454\",\"backgroundColor\":null,\"tickColor\":\"#DDDDDD\"},\"xaxis\":{\"title\":null,\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"min\":0,\"max\":1.2,\"title\":null,\"showLabels\":true,\"color\":\"#545454\",\"tickSize\":null,\"tickDecimals\":0,\"ticksFormatter\":\"if (value > 0 && value <= 1) {\\n return 'On';\\n} else if (value === 0) {\\n return 'Off';\\n} else {\\n return '';\\n}\"},\"shadowSize\":4,\"smoothLines\":false,\"comparisonEnabled\":false,\"timeForComparison\":\"previousInterval\",\"comparisonCustomIntervalValue\":7200000,\"xaxisSecond\":{\"axisPosition\":\"top\",\"title\":null,\"showLabels\":true},\"showLegend\":true,\"legendConfig\":{\"direction\":\"column\",\"position\":\"right\",\"sortDataKeys\":false,\"showMin\":false,\"showMax\":false,\"showAvg\":false,\"showTotal\":false,\"showLatest\":false},\"customLegendEnabled\":false,\"dataKeysListForLabels\":[]},\"title\":\"State Chart\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null,\"widgetStyle\":{},\"useDashboardTimewindow\":true,\"showLegend\":true,\"actions\":{},\"configMode\":\"basic\",\"showTitleIcon\":false,\"titleIcon\":\"waterfall_chart\",\"iconColor\":\"#1F6BDD\"}" + }, + "tags": null +} \ No newline at end of file diff --git a/application/src/main/data/upgrade/3.6.3/schema_update.sql b/application/src/main/data/upgrade/3.6.3/schema_update.sql index d7d8887d13..3e0fdc3b4b 100644 --- a/application/src/main/data/upgrade/3.6.3/schema_update.sql +++ b/application/src/main/data/upgrade/3.6.3/schema_update.sql @@ -14,123 +14,14 @@ -- limitations under the License. -- --- create new attribute_kv table schema -DO -$$ - BEGIN - -- in case of running the upgrade script a second time: - IF EXISTS(SELECT 1 FROM information_schema.columns WHERE table_name = 'attribute_kv' and column_name='entity_type') THEN - DROP VIEW IF EXISTS device_info_view; - DROP VIEW IF EXISTS device_info_active_attribute_view; - ALTER INDEX IF EXISTS idx_attribute_kv_by_key_and_last_update_ts RENAME TO idx_attribute_kv_by_key_and_last_update_ts_old; - IF EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'attribute_kv_pkey') THEN - ALTER TABLE attribute_kv RENAME CONSTRAINT attribute_kv_pkey TO attribute_kv_pkey_old; - END IF; - ALTER TABLE attribute_kv RENAME TO attribute_kv_old; - CREATE TABLE IF NOT EXISTS attribute_kv - ( - entity_id uuid, - attribute_type int, - attribute_key int, - bool_v boolean, - str_v varchar(10000000), - long_v bigint, - dbl_v double precision, - json_v json, - last_update_ts bigint, - CONSTRAINT attribute_kv_pkey PRIMARY KEY (entity_id, attribute_type, attribute_key) - ); - END IF; - END; -$$; +-- NOTIFICATIONS UPDATE START --- rename ts_kv_dictionary table to key_dictionary or create table if not exists -DO -$$ - BEGIN - IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = 'ts_kv_dictionary') THEN - ALTER TABLE ts_kv_dictionary RENAME CONSTRAINT ts_key_id_pkey TO key_dictionary_id_pkey; - ALTER TABLE ts_kv_dictionary RENAME TO key_dictionary; - ELSE CREATE TABLE IF NOT EXISTS key_dictionary( - key varchar(255) NOT NULL, - key_id serial UNIQUE, - CONSTRAINT key_dictionary_id_pkey PRIMARY KEY (key) - ); - END IF; - END; -$$; +ALTER TABLE notification ADD COLUMN IF NOT EXISTS delivery_method VARCHAR(50) NOT NULL default 'WEB'; --- insert keys into key_dictionary -DO -$$ - BEGIN - IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = 'attribute_kv_old') THEN - INSERT INTO key_dictionary(key) SELECT DISTINCT attribute_key FROM attribute_kv_old ON CONFLICT DO NOTHING; - END IF; - END; -$$; +DROP INDEX IF EXISTS idx_notification_recipient_id_created_time; +DROP INDEX IF EXISTS idx_notification_recipient_id_unread; --- migrate attributes from attribute_kv_old to attribute_kv -DO -$$ -DECLARE - row_num_old integer; - row_num integer; -BEGIN - IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = 'attribute_kv_old') THEN - INSERT INTO attribute_kv(entity_id, attribute_type, attribute_key, bool_v, str_v, long_v, dbl_v, json_v, last_update_ts) - SELECT a.entity_id, CASE - WHEN a.attribute_type = 'CLIENT_SCOPE' THEN 1 - WHEN a.attribute_type = 'SERVER_SCOPE' THEN 2 - WHEN a.attribute_type = 'SHARED_SCOPE' THEN 3 - ELSE 0 - END, - k.key_id, a.bool_v, a.str_v, a.long_v, a.dbl_v, a.json_v, a.last_update_ts - FROM attribute_kv_old a INNER JOIN key_dictionary k ON (a.attribute_key = k.key); - SELECT COUNT(*) INTO row_num_old FROM attribute_kv_old; - SELECT COUNT(*) INTO row_num FROM attribute_kv; - RAISE NOTICE 'Migrated % of % rows', row_num, row_num_old; +CREATE INDEX IF NOT EXISTS idx_notification_delivery_method_recipient_id_created_time ON notification(delivery_method, recipient_id, created_time DESC); +CREATE INDEX IF NOT EXISTS idx_notification_delivery_method_recipient_id_unread ON notification(delivery_method, recipient_id) WHERE status <> 'READ'; - IF row_num != 0 THEN - DROP TABLE IF EXISTS attribute_kv_old; - ELSE - RAISE EXCEPTION 'Table attribute_kv is empty'; - END IF; - - CREATE INDEX IF NOT EXISTS idx_attribute_kv_by_key_and_last_update_ts ON attribute_kv(entity_id, attribute_key, last_update_ts desc); - END IF; -EXCEPTION - WHEN others THEN - ROLLBACK; - RAISE EXCEPTION 'Error during COPY: %', SQLERRM; -END -$$; - --- OAUTH2 PARAMS ALTER TABLE START - -ALTER TABLE oauth2_params - ADD COLUMN IF NOT EXISTS edge_enabled boolean DEFAULT false; - --- OAUTH2 PARAMS ALTER TABLE END - --- QUEUE STATS UPDATE START - -CREATE TABLE IF NOT EXISTS queue_stats ( - id uuid NOT NULL CONSTRAINT queue_stats_pkey PRIMARY KEY, - created_time bigint NOT NULL, - tenant_id uuid NOT NULL, - queue_name varchar(255) NOT NULL, - service_id varchar(255) NOT NULL, - CONSTRAINT queue_stats_name_unq_key UNIQUE (tenant_id, queue_name, service_id) -); - -INSERT INTO queue_stats - SELECT id, created_time, tenant_id, substring(name FROM 1 FOR position('_' IN name) - 1) AS queue_name, - substring(name FROM position('_' IN name) + 1) AS service_id - FROM asset - WHERE type = 'TbServiceQueue' and name LIKE '%\_%'; - -DELETE FROM asset WHERE type='TbServiceQueue'; -DELETE FROM asset_profile WHERE name ='TbServiceQueue'; - --- QUEUE STATS UPDATE END +-- NOTIFICATIONS UPDATE END diff --git a/application/src/main/data/upgrade/3.6.4/schema_update.sql b/application/src/main/data/upgrade/3.6.4/schema_update.sql new file mode 100644 index 0000000000..d7d8887d13 --- /dev/null +++ b/application/src/main/data/upgrade/3.6.4/schema_update.sql @@ -0,0 +1,136 @@ +-- +-- Copyright © 2016-2024 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +-- create new attribute_kv table schema +DO +$$ + BEGIN + -- in case of running the upgrade script a second time: + IF EXISTS(SELECT 1 FROM information_schema.columns WHERE table_name = 'attribute_kv' and column_name='entity_type') THEN + DROP VIEW IF EXISTS device_info_view; + DROP VIEW IF EXISTS device_info_active_attribute_view; + ALTER INDEX IF EXISTS idx_attribute_kv_by_key_and_last_update_ts RENAME TO idx_attribute_kv_by_key_and_last_update_ts_old; + IF EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'attribute_kv_pkey') THEN + ALTER TABLE attribute_kv RENAME CONSTRAINT attribute_kv_pkey TO attribute_kv_pkey_old; + END IF; + ALTER TABLE attribute_kv RENAME TO attribute_kv_old; + CREATE TABLE IF NOT EXISTS attribute_kv + ( + entity_id uuid, + attribute_type int, + attribute_key int, + bool_v boolean, + str_v varchar(10000000), + long_v bigint, + dbl_v double precision, + json_v json, + last_update_ts bigint, + CONSTRAINT attribute_kv_pkey PRIMARY KEY (entity_id, attribute_type, attribute_key) + ); + END IF; + END; +$$; + +-- rename ts_kv_dictionary table to key_dictionary or create table if not exists +DO +$$ + BEGIN + IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = 'ts_kv_dictionary') THEN + ALTER TABLE ts_kv_dictionary RENAME CONSTRAINT ts_key_id_pkey TO key_dictionary_id_pkey; + ALTER TABLE ts_kv_dictionary RENAME TO key_dictionary; + ELSE CREATE TABLE IF NOT EXISTS key_dictionary( + key varchar(255) NOT NULL, + key_id serial UNIQUE, + CONSTRAINT key_dictionary_id_pkey PRIMARY KEY (key) + ); + END IF; + END; +$$; + +-- insert keys into key_dictionary +DO +$$ + BEGIN + IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = 'attribute_kv_old') THEN + INSERT INTO key_dictionary(key) SELECT DISTINCT attribute_key FROM attribute_kv_old ON CONFLICT DO NOTHING; + END IF; + END; +$$; + +-- migrate attributes from attribute_kv_old to attribute_kv +DO +$$ +DECLARE + row_num_old integer; + row_num integer; +BEGIN + IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = 'attribute_kv_old') THEN + INSERT INTO attribute_kv(entity_id, attribute_type, attribute_key, bool_v, str_v, long_v, dbl_v, json_v, last_update_ts) + SELECT a.entity_id, CASE + WHEN a.attribute_type = 'CLIENT_SCOPE' THEN 1 + WHEN a.attribute_type = 'SERVER_SCOPE' THEN 2 + WHEN a.attribute_type = 'SHARED_SCOPE' THEN 3 + ELSE 0 + END, + k.key_id, a.bool_v, a.str_v, a.long_v, a.dbl_v, a.json_v, a.last_update_ts + FROM attribute_kv_old a INNER JOIN key_dictionary k ON (a.attribute_key = k.key); + SELECT COUNT(*) INTO row_num_old FROM attribute_kv_old; + SELECT COUNT(*) INTO row_num FROM attribute_kv; + RAISE NOTICE 'Migrated % of % rows', row_num, row_num_old; + + IF row_num != 0 THEN + DROP TABLE IF EXISTS attribute_kv_old; + ELSE + RAISE EXCEPTION 'Table attribute_kv is empty'; + END IF; + + CREATE INDEX IF NOT EXISTS idx_attribute_kv_by_key_and_last_update_ts ON attribute_kv(entity_id, attribute_key, last_update_ts desc); + END IF; +EXCEPTION + WHEN others THEN + ROLLBACK; + RAISE EXCEPTION 'Error during COPY: %', SQLERRM; +END +$$; + +-- OAUTH2 PARAMS ALTER TABLE START + +ALTER TABLE oauth2_params + ADD COLUMN IF NOT EXISTS edge_enabled boolean DEFAULT false; + +-- OAUTH2 PARAMS ALTER TABLE END + +-- QUEUE STATS UPDATE START + +CREATE TABLE IF NOT EXISTS queue_stats ( + id uuid NOT NULL CONSTRAINT queue_stats_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid NOT NULL, + queue_name varchar(255) NOT NULL, + service_id varchar(255) NOT NULL, + CONSTRAINT queue_stats_name_unq_key UNIQUE (tenant_id, queue_name, service_id) +); + +INSERT INTO queue_stats + SELECT id, created_time, tenant_id, substring(name FROM 1 FOR position('_' IN name) - 1) AS queue_name, + substring(name FROM position('_' IN name) + 1) AS service_id + FROM asset + WHERE type = 'TbServiceQueue' and name LIKE '%\_%'; + +DELETE FROM asset WHERE type='TbServiceQueue'; +DELETE FROM asset_profile WHERE name ='TbServiceQueue'; + +-- QUEUE STATS 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 640d47362a..f411344e3f 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -19,6 +19,8 @@ import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; @@ -116,9 +118,6 @@ import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import org.thingsboard.server.service.transport.TbCoreToTransportService; -import jakarta.annotation.Nullable; -import jakarta.annotation.PostConstruct; - import java.io.PrintWriter; import java.io.StringWriter; import java.util.concurrent.ConcurrentHashMap; diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index 51205e97c9..87d5c8a63a 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -43,7 +43,6 @@ import org.thingsboard.server.common.data.id.RpcId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKey; import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.SortOrder; @@ -74,8 +73,6 @@ import org.thingsboard.server.gen.transport.TransportProtos.ClaimDeviceMsg; import org.thingsboard.server.gen.transport.TransportProtos.DeviceSessionsCacheEntry; import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; -import org.thingsboard.server.gen.transport.TransportProtos.KeyValueProto; -import org.thingsboard.server.gen.transport.TransportProtos.KeyValueType; import org.thingsboard.server.gen.transport.TransportProtos.SessionCloseNotificationProto; import org.thingsboard.server.gen.transport.TransportProtos.SessionEvent; import org.thingsboard.server.gen.transport.TransportProtos.SessionEventMsg; @@ -254,14 +251,12 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } private boolean isSendNewRpcAvailable() { - switch (rpcSubmitStrategy) { - case SEQUENTIAL_ON_ACK_FROM_DEVICE: - return toDeviceRpcPendingMap.values().stream().filter(md -> !md.isDelivered()).findAny().isEmpty(); - case SEQUENTIAL_ON_RESPONSE_FROM_DEVICE: - return toDeviceRpcPendingMap.values().stream().filter(ToDeviceRpcRequestMetadata::isDelivered).findAny().isEmpty(); - default: - return true; - } + return switch (rpcSubmitStrategy) { + case SEQUENTIAL_ON_ACK_FROM_DEVICE -> toDeviceRpcPendingMap.values().stream().filter(md -> !md.isDelivered()).findAny().isEmpty(); + case SEQUENTIAL_ON_RESPONSE_FROM_DEVICE -> + toDeviceRpcPendingMap.values().stream().filter(ToDeviceRpcRequestMetadata::isDelivered).findAny().isEmpty(); + default -> true; + }; } private void createRpc(ToDeviceRpcRequest request, RpcStatus status) { @@ -465,7 +460,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso handleSessionActivity(sessionInfo, msg.getSubscriptionInfo()); } if (msg.hasClaimDevice()) { - handleClaimDeviceMsg(msg.getClaimDevice()); + handleClaimDeviceMsg(sessionInfo, msg.getClaimDevice()); } if (msg.hasRpcResponseStatusMsg()) { processRpcResponseStatus(sessionInfo, msg.getRpcResponseStatusMsg()); @@ -490,9 +485,22 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso }); } - private void handleClaimDeviceMsg(ClaimDeviceMsg msg) { + private void handleClaimDeviceMsg(SessionInfoProto sessionInfo, ClaimDeviceMsg msg) { + UUID sessionId = getSessionId(sessionInfo); DeviceId deviceId = new DeviceId(new UUID(msg.getDeviceIdMSB(), msg.getDeviceIdLSB())); - systemContext.getClaimDevicesService().registerClaimingInfo(tenantId, deviceId, msg.getSecretKey(), msg.getDurationMs()); + ListenableFuture registrationFuture = systemContext.getClaimDevicesService() + .registerClaimingInfo(tenantId, deviceId, msg.getSecretKey(), msg.getDurationMs()); + Futures.addCallback(registrationFuture, new FutureCallback<>() { + @Override + public void onSuccess(Void result) { + log.debug("[{}][{}] Successfully processed register claiming info request!", sessionId, deviceId); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}][{}] Failed to process register claiming info request due to: ", sessionId, deviceId, t); + } + }, MoreExecutors.directExecutor()); } private void reportSessionOpen() { @@ -927,34 +935,6 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso return systemContext.getEdgeEventService().saveAsync(edgeEvent); } - private KeyValueProto toKeyValueProto(KvEntry kvEntry) { - KeyValueProto.Builder builder = KeyValueProto.newBuilder(); - builder.setKey(kvEntry.getKey()); - switch (kvEntry.getDataType()) { - case BOOLEAN: - builder.setType(KeyValueType.BOOLEAN_V); - builder.setBoolV(kvEntry.getBooleanValue().get()); - break; - case DOUBLE: - builder.setType(KeyValueType.DOUBLE_V); - builder.setDoubleV(kvEntry.getDoubleValue().get()); - break; - case LONG: - builder.setType(KeyValueType.LONG_V); - builder.setLongV(kvEntry.getLongValue().get()); - break; - case STRING: - builder.setType(KeyValueType.STRING_V); - builder.setStringV(kvEntry.getStrValue().get()); - break; - case JSON: - builder.setType(KeyValueType.JSON_V); - builder.setJsonV(kvEntry.getJsonValue().get()); - break; - } - return builder.build(); - } - void restoreSessions() { if (systemContext.isLocalCacheType()) { return; diff --git a/application/src/main/java/org/thingsboard/server/controller/AbstractRpcController.java b/application/src/main/java/org/thingsboard/server/controller/AbstractRpcController.java index 93e8c0be90..3df3d6887a 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AbstractRpcController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AbstractRpcController.java @@ -122,15 +122,9 @@ public abstract class AbstractRpcController extends BaseController { logRpcCall(rpcRequest, rpcError, null); RpcError error = rpcError.get(); switch (error) { - case TIMEOUT: - responseWriter.setResult(new ResponseEntity<>(timeoutStatus)); - break; - case NO_ACTIVE_CONNECTION: - responseWriter.setResult(new ResponseEntity<>(noActiveConnectionStatus)); - break; - default: - responseWriter.setResult(new ResponseEntity<>(timeoutStatus)); - break; + case TIMEOUT -> responseWriter.setResult(new ResponseEntity<>(timeoutStatus)); + case NO_ACTIVE_CONNECTION -> responseWriter.setResult(new ResponseEntity<>(noActiveConnectionStatus)); + default -> responseWriter.setResult(new ResponseEntity<>(timeoutStatus)); } } else { Optional responseData = response.getResponse(); diff --git a/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java b/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java index 595d6feef1..5271de2329 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java @@ -68,7 +68,7 @@ public class ComponentDescriptorController extends BaseController { @RequestMapping(value = "/components/{componentType}", method = RequestMethod.GET) @ResponseBody public List getComponentDescriptorsByType( - @Parameter(description = "Type of the Rule Node", schema = @Schema(allowableValues = "ENRICHMENT,FILTER,TRANSFORMATION,ACTION,EXTERNAL", required = true)) + @Parameter(description = "Type of the Rule Node", schema = @Schema(allowableValues = "ENRICHMENT,FILTER,TRANSFORMATION,ACTION,EXTERNAL", requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("componentType") String strComponentType, @Parameter(description = "Type of the Rule Chain", schema = @Schema(allowableValues = "CORE,EDGE")) @RequestParam(value = "ruleChainType", required = false) String strRuleChainType) throws ThingsboardException { 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 55f4e6d9fa..96f38daf35 100644 --- a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java @@ -16,6 +16,7 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -105,6 +106,8 @@ public class NotificationController extends BaseController { private final NotificationCenter notificationCenter; private final NotificationSettingsService notificationSettingsService; + private static final String DELIVERY_METHOD_ALLOWABLE_VALUES = "WEB,MOBILE_APP"; + @ApiOperation(value = "Get notifications (getNotifications)", notes = "Returns the page of notifications for current user." + NEW_LINE + PAGE_DATA_PARAMETERS + @@ -172,10 +175,23 @@ public class NotificationController extends BaseController { @RequestParam(required = false) String sortOrder, @Parameter(description = "To search for unread notifications only") @RequestParam(defaultValue = "false") boolean unreadOnly, + @Parameter(description = "Delivery method", schema = @Schema(allowableValues = {DELIVERY_METHOD_ALLOWABLE_VALUES})) + @RequestParam(defaultValue = "WEB") NotificationDeliveryMethod deliveryMethod, @AuthenticationPrincipal SecurityUser user) throws ThingsboardException { // no permissions PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); - return notificationService.findNotificationsByRecipientIdAndReadStatus(user.getTenantId(), user.getId(), unreadOnly, pageLink); + return notificationService.findNotificationsByRecipientIdAndReadStatus(user.getTenantId(), deliveryMethod, user.getId(), unreadOnly, pageLink); + } + + @ApiOperation(value = "Get unread notifications count (getUnreadNotificationsCount)", + notes = "Returns unread notifications count for chosen delivery method." + + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @GetMapping("/notifications/unread/count") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + public Integer getUnreadNotificationsCount(@Parameter(description = "Delivery method", schema = @Schema(allowableValues = {DELIVERY_METHOD_ALLOWABLE_VALUES})) + @RequestParam(defaultValue = "MOBILE_APP") NotificationDeliveryMethod deliveryMethod, + @AuthenticationPrincipal SecurityUser user) { + return notificationService.countUnreadNotificationsByRecipientId(user.getTenantId(), deliveryMethod, user.getId()); } @ApiOperation(value = "Mark notification as read (markNotificationAsRead)", @@ -195,9 +211,11 @@ public class NotificationController extends BaseController { AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PutMapping("/notifications/read") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - public void markAllNotificationsAsRead(@AuthenticationPrincipal SecurityUser user) { + public void markAllNotificationsAsRead(@Parameter(description = "Delivery method", schema = @Schema(allowableValues = {DELIVERY_METHOD_ALLOWABLE_VALUES})) + @RequestParam(defaultValue = "WEB") NotificationDeliveryMethod deliveryMethod, + @AuthenticationPrincipal SecurityUser user) { // no permissions - notificationCenter.markAllNotificationsAsRead(user.getTenantId(), user.getId()); + notificationCenter.markAllNotificationsAsRead(user.getTenantId(), deliveryMethod, user.getId()); } @ApiOperation(value = "Delete notification (deleteNotification)", diff --git a/application/src/main/java/org/thingsboard/server/controller/QueueController.java b/application/src/main/java/org/thingsboard/server/controller/QueueController.java index 089ba9bcca..6b90f8f8fd 100644 --- a/application/src/main/java/org/thingsboard/server/controller/QueueController.java +++ b/application/src/main/java/org/thingsboard/server/controller/QueueController.java @@ -67,7 +67,7 @@ public class QueueController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @RequestMapping(value = "/queues", params = {"serviceType", "pageSize", "page"}, method = RequestMethod.GET) @ResponseBody - public PageData getTenantQueuesByServiceType(@Parameter(description = QUEUE_SERVICE_TYPE_DESCRIPTION, schema = @Schema(allowableValues = {"TB-RULE-ENGINE", "TB-CORE", "TB-TRANSPORT", "JS-EXECUTOR"}, required = true)) + public PageData getTenantQueuesByServiceType(@Parameter(description = QUEUE_SERVICE_TYPE_DESCRIPTION, schema = @Schema(allowableValues = {"TB-RULE-ENGINE", "TB-CORE", "TB-TRANSPORT", "JS-EXECUTOR"}, requiredMode = Schema.RequiredMode.REQUIRED)) @RequestParam String serviceType, @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -126,7 +126,7 @@ public class QueueController extends BaseController { @ResponseBody public Queue saveQueue(@Parameter(description = "A JSON value representing the queue.") @RequestBody Queue queue, - @Parameter(description = QUEUE_SERVICE_TYPE_DESCRIPTION, schema = @Schema(allowableValues = {"TB-RULE-ENGINE", "TB-CORE", "TB-TRANSPORT", "JS-EXECUTOR"}, required = true)) + @Parameter(description = QUEUE_SERVICE_TYPE_DESCRIPTION, schema = @Schema(allowableValues = {"TB-RULE-ENGINE", "TB-CORE", "TB-TRANSPORT", "JS-EXECUTOR"}, requiredMode = Schema.RequiredMode.REQUIRED)) @RequestParam String serviceType) throws ThingsboardException { checkParameter("serviceType", serviceType); queue.setTenantId(getCurrentUser().getTenantId()); diff --git a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java index 8d51daab8b..680633e103 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java @@ -47,8 +47,8 @@ import org.thingsboard.server.actors.tenant.DebugTbRateLimits; import org.thingsboard.server.common.data.EventInfo; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.edge.Edge; -import org.thingsboard.server.common.data.event.EventType; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; @@ -80,6 +80,7 @@ import org.thingsboard.server.service.security.permission.Resource; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.TimeUnit; @@ -340,18 +341,8 @@ public class RuleChainController extends BaseController { RuleNodeId ruleNodeId = new RuleNodeId(toUUID(strRuleNodeId)); checkRuleNode(ruleNodeId, Operation.READ); TenantId tenantId = getCurrentUser().getTenantId(); - List events = eventService.findLatestEvents(tenantId, ruleNodeId, EventType.DEBUG_RULE_NODE, 2); - JsonNode result = null; - if (events != null) { - for (EventInfo event : events) { - JsonNode body = event.getBody(); - if (body.has("type") && body.get("type").asText().equals("IN")) { - result = body; - break; - } - } - } - return result; + return Optional.ofNullable(eventService.findLatestDebugRuleNodeInEvent(tenantId, ruleNodeId)) + .map(EventInfo::getBody).orElse(null); } @ApiOperation(value = "Is TBEL script executor enabled", diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java index 1908459200..e646c34e4f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java @@ -268,9 +268,9 @@ public class TbResourceController extends BaseController { "You can specify parameters to filter the results. " + LWM2M_OBJECT_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") @GetMapping(value = "/resource/lwm2m") - public List getLwm2mListObjects(@Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"}, required = true)) + public List getLwm2mListObjects(@Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"}, requiredMode = Schema.RequiredMode.REQUIRED)) @RequestParam String sortOrder, - @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"id", "name"}, required = true)) + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"id", "name"}, requiredMode = Schema.RequiredMode.REQUIRED)) @RequestParam String sortProperty, @Parameter(description = "LwM2M Object ids.", required = true) @RequestParam(required = false) String[] objectIds) throws ThingsboardException { @@ -315,4 +315,4 @@ public class TbResourceController extends BaseController { .body(resource); } -} \ No newline at end of file +} 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 c73ac09cf1..cea449af31 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -25,7 +25,6 @@ import com.google.gson.JsonElement; import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -36,7 +35,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; @@ -49,9 +47,6 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.async.DeferredResult; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardThreadFactory; -import org.thingsboard.server.common.data.kv.AggregationParams; -import org.thingsboard.server.common.data.kv.IntervalType; -import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg; import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.EntityType; @@ -65,6 +60,7 @@ import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.common.data.kv.Aggregation; +import org.thingsboard.server.common.data.kv.AggregationParams; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery; @@ -74,6 +70,7 @@ import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.DataType; import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.IntervalType; import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; @@ -239,7 +236,7 @@ public class TelemetryController extends BaseController { public DeferredResult getAttributesByScope( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, - @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"}, required = true)) @PathVariable("scope") AttributeScope scope, + @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr) throws ThingsboardException { SecurityUser user = getCurrentUser(); return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_ATTRIBUTES, entityType, entityIdStr, @@ -356,7 +353,7 @@ public class TelemetryController extends BaseController { @ResponseBody public DeferredResult saveDeviceAttributes( @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION, required = true) @PathVariable("deviceId") String deviceIdStr, - @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, required = true)) @PathVariable("scope") AttributeScope scope, + @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody JsonNode request) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndUuid(EntityType.DEVICE, deviceIdStr); return saveAttributes(getTenantId(), entityId, scope, request); @@ -379,7 +376,7 @@ public class TelemetryController extends BaseController { public DeferredResult saveEntityAttributesV1( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, - @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"})) @PathVariable("scope")AttributeScope scope, + @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"})) @PathVariable("scope") AttributeScope scope, @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody JsonNode request) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); return saveAttributes(getTenantId(), entityId, scope, request); @@ -402,7 +399,7 @@ public class TelemetryController extends BaseController { public DeferredResult saveEntityAttributesV2( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, - @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, required = true)) @PathVariable("scope")AttributeScope scope, + @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope")AttributeScope scope, @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody JsonNode request) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); return saveAttributes(getTenantId(), entityId, scope, request); @@ -553,7 +550,7 @@ public class TelemetryController extends BaseController { @ResponseBody public DeferredResult deleteDeviceAttributes( @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(DEVICE_ID) String deviceIdStr, - @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"}, required = true)) @PathVariable("scope")AttributeScope scope, + @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope")AttributeScope scope, @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION, required = true)@RequestParam(name = "keys")String keysStr) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndUuid(EntityType.DEVICE, deviceIdStr); return deleteAttributes(entityId, scope, keysStr); @@ -918,4 +915,5 @@ public class TelemetryController extends BaseController { } return entry.getValue(); } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java index 7468e683ab..01487b20a0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -100,7 +100,7 @@ public class TwoFactorAuthConfigController extends BaseController { ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PostMapping("/account/config/generate") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - public TwoFaAccountConfig generateTwoFaAccountConfig(@Parameter(description = "2FA provider type to generate new account config for", schema = @Schema(defaultValue = "TOTP", required = true)) + public TwoFaAccountConfig generateTwoFaAccountConfig(@Parameter(description = "2FA provider type to generate new account config for", schema = @Schema(defaultValue = "TOTP", requiredMode = Schema.RequiredMode.REQUIRED)) @RequestParam TwoFaProviderType providerType) throws Exception { SecurityUser user = getCurrentUser(); return twoFactorAuthService.generateNewAccountConfig(user, providerType); diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index 63f85872d8..2eecd61798 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -128,8 +128,11 @@ public class ThingsboardInstallService { databaseEntitiesUpgradeService.upgradeDatabase("3.6.2"); systemDataLoaderService.updateDefaultNotificationConfigs(true); case "3.6.3": - log.info("Upgrading ThingsBoard from version 3.6.3 to 3.7.0 ..."); + log.info("Upgrading ThingsBoard from version 3.6.3 to 3.6.4 ..."); databaseEntitiesUpgradeService.upgradeDatabase("3.6.3"); + case "3.6.4": + log.info("Upgrading ThingsBoard from version 3.6.4 to 3.7.0 ..."); + databaseEntitiesUpgradeService.upgradeDatabase("3.6.4"); systemDataLoaderService.updateDefaultNotificationConfigs(false); //TODO DON'T FORGET to update switch statement in the CacheCleanupService if you need to clear the cache break; 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 447c993e55..ab6d32fb44 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 @@ -250,7 +250,7 @@ public class EntityActionService { return result; } - private void addTimeseries(ObjectNode entityNode, List timeseries) throws Exception { + private void addTimeseries(ObjectNode entityNode, List timeseries) { if (timeseries != null && !timeseries.isEmpty()) { ArrayNode result = entityNode.putArray("timeseries"); Map> groupedTelemetry = timeseries.stream() 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 974bc06a40..a77b7d0874 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 @@ -20,6 +20,7 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.SettableFuture; +import jakarta.annotation.Nullable; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -28,7 +29,6 @@ import org.springframework.cache.CacheManager; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; -import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; @@ -51,9 +51,7 @@ import org.thingsboard.server.dao.device.claim.ReclaimResult; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.queue.util.TbCoreComponent; -import jakarta.annotation.Nullable; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Optional; @@ -89,16 +87,16 @@ public class ClaimDevicesServiceImpl implements ClaimDevicesService { Device device = deviceService.findDeviceById(tenantId, deviceId); Cache cache = cacheManager.getCache(CLAIM_DEVICES_CACHE); List key = constructCacheKey(device.getId()); + String deviceName = device.getName(); 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()); + return Futures.immediateFailedFuture(new IllegalArgumentException("Device [" + deviceName + "] has been already claimed!")); } else { ListenableFuture> claimingAllowedFuture = attributesService.find(tenantId, device.getId(), - AttributeScope.SERVER_SCOPE, Collections.singletonList(CLAIM_ATTRIBUTE_NAME)); + AttributeScope.SERVER_SCOPE, List.of(CLAIM_ATTRIBUTE_NAME)); return Futures.transform(claimingAllowedFuture, list -> { if (list != null && !list.isEmpty()) { Optional claimingAllowedOptional = list.get(0).getBooleanValue(); @@ -108,8 +106,7 @@ public class ClaimDevicesServiceImpl implements ClaimDevicesService { return null; } } - log.warn("Failed to find claimingAllowed attribute for device or it is already claimed![{}]", device.getName()); - throw new IllegalArgumentException(); + throw new IllegalArgumentException("Failed to find claimingAllowed attribute for device [" + deviceName + "] or it is already claimed!"); }, MoreExecutors.directExecutor()); } } @@ -182,7 +179,7 @@ public class ClaimDevicesServiceImpl implements ClaimDevicesService { } SettableFuture result = SettableFuture.create(); telemetryService.saveAndNotify( - tenantId, savedDevice.getId(), AttributeScope.SERVER_SCOPE, Collections.singletonList( + tenantId, savedDevice.getId(), AttributeScope.SERVER_SCOPE, List.of( new BaseAttributeKvEntry(new BooleanDataEntry(CLAIM_ATTRIBUTE_NAME, true), System.currentTimeMillis()) ), new FutureCallback<>() { @@ -203,7 +200,7 @@ public class ClaimDevicesServiceImpl implements ClaimDevicesService { } private List constructCacheKey(DeviceId deviceId) { - return Collections.singletonList(deviceId); + return List.of(deviceId); } private void persistInCache(String secretKey, long durationMs, Cache cache, List key) { 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 75bb8e10e9..7d0a1aaa92 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 @@ -557,8 +557,7 @@ public final class EdgeGrpcSession implements Closeable { } case ATTRIBUTES_UPDATED, POST_ATTRIBUTES, ATTRIBUTES_DELETED, TIMESERIES_UPDATED -> downlinkMsg = ctx.getTelemetryProcessor().convertTelemetryEventToDownlink(edge, edgeEvent); - default -> - log.warn("[{}][{}] Unsupported action type [{}]", this.tenantId, this.sessionId, edgeEvent.getAction()); + default -> log.warn("[{}][{}] Unsupported action type [{}]", this.tenantId, this.sessionId, edgeEvent.getAction()); } } catch (Exception e) { log.error("[{}][{}] Exception during converting edge event to downlink msg", this.tenantId, this.sessionId, e); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/BaseMsgConstructorFactory.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/BaseMsgConstructorFactory.java index 5253e5ac33..dd76f97d8f 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/BaseMsgConstructorFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/BaseMsgConstructorFactory.java @@ -31,16 +31,10 @@ public abstract class BaseMsgConstructorFactory v1Constructor; + default -> v2Constructor; + }; } + } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/telemetry/BaseTelemetryProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/telemetry/BaseTelemetryProcessor.java index 2a9af53807..ae8c851a85 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/telemetry/BaseTelemetryProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/telemetry/BaseTelemetryProcessor.java @@ -23,10 +23,13 @@ import com.google.common.util.concurrent.SettableFuture; import com.google.gson.Gson; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.ImmutablePair; import org.apache.commons.lang3.tuple.Pair; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; @@ -53,7 +56,6 @@ 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.adaptor.JsonConverter; import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg; import org.thingsboard.server.common.transport.util.JsonUtils; import org.thingsboard.server.dao.model.ModelConstants; @@ -66,8 +68,6 @@ import org.thingsboard.server.queue.TbQueueProducer; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; -import jakarta.annotation.Nullable; -import jakarta.annotation.PostConstruct; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -317,36 +317,22 @@ public abstract class BaseTelemetryProcessor extends BaseEdgeProcessor { JsonNode body) { EntityId entityId; switch (entityType) { - case DEVICE: - entityId = new DeviceId(entityUUID); - break; - case ASSET: - entityId = new AssetId(entityUUID); - break; - case ENTITY_VIEW: - entityId = new EntityViewId(entityUUID); - break; - case DASHBOARD: - entityId = new DashboardId(entityUUID); - break; - case TENANT: - entityId = TenantId.fromUUID(entityUUID); - break; - case CUSTOMER: - entityId = new CustomerId(entityUUID); - break; - case USER: - entityId = new UserId(entityUUID); - break; - case EDGE: - entityId = new EdgeId(entityUUID); - break; - default: + case DEVICE -> entityId = new DeviceId(entityUUID); + case ASSET -> entityId = new AssetId(entityUUID); + case ENTITY_VIEW -> entityId = new EntityViewId(entityUUID); + case DASHBOARD -> entityId = new DashboardId(entityUUID); + case TENANT -> entityId = TenantId.fromUUID(entityUUID); + case CUSTOMER -> entityId = new CustomerId(entityUUID); + case USER -> entityId = new UserId(entityUUID); + case EDGE -> entityId = new EdgeId(entityUUID); + default -> { log.warn("[{}] Unsupported edge event type [{}]", tenantId, entityType); return null; + } } String bodyJackson = JacksonUtil.toString(body); return bodyJackson == null ? null : entityDataMsgConstructor.constructEntityDataMsg(tenantId, entityId, actionType, JsonParser.parseString(bodyJackson)); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java index c699cd6c02..bc7dc62e78 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java @@ -69,80 +69,71 @@ public class EntityStateSourcingListener { @TransactionalEventListener(fallbackExecution = true) public void handleEvent(SaveEntityEvent event) { - log.trace("[{}] SaveEntityEvent called: {}", event.getTenantId(), event); TenantId tenantId = event.getTenantId(); EntityId entityId = event.getEntityId(); EntityType entityType = entityId.getEntityType(); + log.debug("[{}][{}][{}] Handling entity save event: {}", tenantId, entityType, entityId, event); boolean isCreated = event.getCreated() != null && event.getCreated(); ComponentLifecycleEvent lifecycleEvent = isCreated ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED; switch (entityType) { - case ASSET: - case ASSET_PROFILE: - case ENTITY_VIEW: - case NOTIFICATION_RULE: + case ASSET, ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE -> { tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, lifecycleEvent); - break; - case RULE_CHAIN: + } + case RULE_CHAIN -> { RuleChain ruleChain = (RuleChain) event.getEntity(); if (RuleChainType.CORE.equals(ruleChain.getType())) { tbClusterService.broadcastEntityStateChangeEvent(ruleChain.getTenantId(), ruleChain.getId(), lifecycleEvent); } - break; - case TENANT: + } + case TENANT -> { Tenant tenant = (Tenant) event.getEntity(); onTenantUpdate(tenant, lifecycleEvent); - break; - case TENANT_PROFILE: + } + case TENANT_PROFILE -> { TenantProfile tenantProfile = (TenantProfile) event.getEntity(); onTenantProfileUpdate(tenantProfile, lifecycleEvent); - break; - case DEVICE: + } + case DEVICE -> { onDeviceUpdate(event.getEntity(), event.getOldEntity()); - break; - case DEVICE_PROFILE: + } + case DEVICE_PROFILE -> { DeviceProfile deviceProfile = (DeviceProfile) event.getEntity(); onDeviceProfileUpdate(deviceProfile, event.getOldEntity(), isCreated); - break; - case EDGE: + } + case EDGE -> { handleEdgeEvent(tenantId, entityId, event.getEntity(), lifecycleEvent); - break; - case TB_RESOURCE: + } + case TB_RESOURCE -> { TbResource tbResource = (TbResource) event.getEntity(); tbClusterService.onResourceChange(tbResource, null); - break; - case API_USAGE_STATE: + } + case API_USAGE_STATE -> { ApiUsageState apiUsageState = (ApiUsageState) event.getEntity(); tbClusterService.onApiStateChange(apiUsageState, null); - break; - default: - break; + } + default -> {} } } @TransactionalEventListener(fallbackExecution = true) public void handleEvent(DeleteEntityEvent event) { - log.trace("[{}] DeleteEntityEvent called: {}", event.getTenantId(), event); TenantId tenantId = event.getTenantId(); EntityId entityId = event.getEntityId(); EntityType entityType = entityId.getEntityType(); + log.debug("[{}][{}][{}] Handling entity deletion event: {}", tenantId, entityType, entityId, event); switch (entityType) { - case ASSET: - case ASSET_PROFILE: - case ENTITY_VIEW: - case CUSTOMER: - case EDGE: - case NOTIFICATION_RULE: + case ASSET, ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE -> { tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, ComponentLifecycleEvent.DELETED); - break; - case NOTIFICATION_REQUEST: + } + case NOTIFICATION_REQUEST -> { NotificationRequest request = (NotificationRequest) event.getEntity(); if (request.isScheduled()) { tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, ComponentLifecycleEvent.DELETED); } - break; - case RULE_CHAIN: + } + case RULE_CHAIN -> { RuleChain ruleChain = (RuleChain) event.getEntity(); if (RuleChainType.CORE.equals(ruleChain.getType())) { Set referencingRuleChainIds = JacksonUtil.fromString(event.getBody(), new TypeReference<>() {}); @@ -152,29 +143,28 @@ public class EntityStateSourcingListener { } tbClusterService.broadcastEntityStateChangeEvent(tenantId, ruleChain.getId(), ComponentLifecycleEvent.DELETED); } - break; - case TENANT: + } + case TENANT -> { Tenant tenant = (Tenant) event.getEntity(); onTenantDeleted(tenant); - break; - case TENANT_PROFILE: + } + case TENANT_PROFILE -> { TenantProfile tenantProfile = (TenantProfile) event.getEntity(); tbClusterService.onTenantProfileDelete(tenantProfile, null); - break; - case DEVICE: + } + case DEVICE -> { Device device = (Device) event.getEntity(); tbClusterService.onDeviceDeleted(tenantId, device, null); - break; - case DEVICE_PROFILE: + } + case DEVICE_PROFILE -> { DeviceProfile deviceProfile = (DeviceProfile) event.getEntity(); onDeviceProfileDelete(event.getTenantId(), event.getEntityId(), deviceProfile); - break; - case TB_RESOURCE: + } + case TB_RESOURCE -> { TbResourceInfo tbResource = (TbResourceInfo) event.getEntity(); tbClusterService.onResourceDeleted(tbResource, null); - break; - default: - break; + } + default -> {} } } @@ -186,8 +176,7 @@ public class EntityStateSourcingListener { && event.getEntity() instanceof DeviceCredentials) { tbClusterService.pushMsgToCore(new DeviceCredentialsUpdateNotificationMsg(event.getTenantId(), (DeviceId) event.getEntityId(), (DeviceCredentials) event.getEntity()), null); - } else if (ActionType.ASSIGNED_TO_TENANT.equals(event.getActionType()) && event.getEntity() instanceof Device) { - Device device = (Device) event.getEntity(); + } else if (ActionType.ASSIGNED_TO_TENANT.equals(event.getActionType()) && event.getEntity() instanceof Device device) { Tenant tenant = JacksonUtil.fromString(event.getBody(), Tenant.class); if (tenant != null) { tbClusterService.onDeviceAssignedToTenant(tenant.getId(), device); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java index 17d52329b2..8096722940 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java @@ -16,12 +16,10 @@ package org.thingsboard.server.service.entitiy.tenant; import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.dao.tenant.TenantService; @@ -45,25 +43,19 @@ public class DefaultTbTenantService extends AbstractTbEntityService implements T private final TbQueueService tbQueueService; private final TenantProfileService tenantProfileService; private final EntitiesVersionControlService versionControlService; - private final ApplicationEventPublisher eventPublisher; @Override public Tenant save(Tenant tenant) throws Exception { boolean created = tenant.getId() == null; Tenant oldTenant = !created ? tenantService.findTenantById(tenant.getId()) : null; - Tenant savedTenant = checkNotNull(tenantService.saveTenant(tenant, !created)); - if (created) { - installScripts.createDefaultRuleChains(savedTenant.getId()); - installScripts.createDefaultEdgeRuleChains(savedTenant.getId()); - installScripts.createDefaultTenantDashboards(savedTenant.getId(), null); - } + Tenant savedTenant = tenantService.saveTenant(tenant, tenantId -> { + installScripts.createDefaultRuleChains(tenantId); + installScripts.createDefaultEdgeRuleChains(tenantId); + installScripts.createDefaultTenantDashboards(tenantId, null); + }); tenantProfileCache.evict(savedTenant.getId()); - if (created) { - eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(TenantId.SYS_TENANT_ID).entityId(savedTenant.getId()).entity(savedTenant).created(true).build()); - } - TenantProfile oldTenantProfile = oldTenant != null ? tenantProfileService.findTenantProfileById(TenantId.SYS_TENANT_ID, oldTenant.getTenantProfileId()) : null; TenantProfile newTenantProfile = tenantProfileService.findTenantProfileById(TenantId.SYS_TENANT_ID, savedTenant.getTenantProfileId()); tbQueueService.updateQueuesByTenants(Collections.singletonList(savedTenant.getTenantId()), newTenantProfile, oldTenantProfile); diff --git a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java index f5bb67bdce..999079e913 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java +++ b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java @@ -151,17 +151,18 @@ public class InstallScripts { } } - public void createDefaultRuleChains(TenantId tenantId) throws IOException { + public void createDefaultRuleChains(TenantId tenantId) { Path tenantChainsDir = getTenantRuleChainsDir(); loadRuleChainsFromPath(tenantId, tenantChainsDir); } - public void createDefaultEdgeRuleChains(TenantId tenantId) throws IOException { + public void createDefaultEdgeRuleChains(TenantId tenantId) { Path edgeChainsDir = getEdgeRuleChainsDir(); loadRuleChainsFromPath(tenantId, edgeChainsDir); } - private void loadRuleChainsFromPath(TenantId tenantId, Path ruleChainsPath) throws IOException { + @SneakyThrows + private void loadRuleChainsFromPath(TenantId tenantId, Path ruleChainsPath) { findRuleChainsFromPath(ruleChainsPath).forEach(path -> { try { createRuleChainFromFile(tenantId, path, null); @@ -329,17 +330,18 @@ public class InstallScripts { } } - public void loadDashboards(TenantId tenantId, CustomerId customerId) throws Exception { + public void loadDashboards(TenantId tenantId, CustomerId customerId) { Path dashboardsDir = Paths.get(getDataDir(), JSON_DIR, DEMO_DIR, DASHBOARDS_DIR); loadDashboardsFromDir(tenantId, customerId, dashboardsDir); } - public void createDefaultTenantDashboards(TenantId tenantId, CustomerId customerId) throws Exception { + public void createDefaultTenantDashboards(TenantId tenantId, CustomerId customerId) { Path dashboardsDir = Paths.get(getDataDir(), JSON_DIR, TENANT_DIR, DASHBOARDS_DIR); loadDashboardsFromDir(tenantId, customerId, dashboardsDir); } - private void loadDashboardsFromDir(TenantId tenantId, CustomerId customerId, Path dashboardsDir) throws IOException { + @SneakyThrows + private void loadDashboardsFromDir(TenantId tenantId, CustomerId customerId, Path dashboardsDir) { try (DirectoryStream dirStream = Files.newDirectoryStream(dashboardsDir, path -> path.toString().endsWith(JSON_EXT))) { dirStream.forEach( path -> { @@ -414,7 +416,7 @@ public class InstallScripts { } ); } catch (Exception e) { - log.error("Unable to load resources lwm2m object model from file: [{}]", resourceLwm2mPath.toString()); + log.error("Unable to load resources lwm2m object model from file: [{}]", resourceLwm2mPath); throw new RuntimeException("resource lwm2m object model from file", e); } } diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java index d2925870c4..86d922c305 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java @@ -116,7 +116,10 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService updateSchema("3.6.2", 3006002, "3.6.3", 3006003, null); break; case "3.6.3": - updateSchema("3.6.3", 3006003, "3.7.0", 3007000, null); + updateSchema("3.6.3", 3006003, "3.6.4", 3006004, null); + break; + case "3.6.4": + updateSchema("3.6.4", 3006004, "3.7.0", 3007000, null); break; default: throw new RuntimeException("Unable to upgrade SQL database, unsupported fromVersion: " + fromVersion); 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 362d0d971e..b8bd4329b0 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 @@ -81,6 +81,8 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; +import static org.thingsboard.server.common.data.notification.NotificationDeliveryMethod.WEB; + @Service @Slf4j @RequiredArgsConstructor @@ -191,7 +193,7 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple NotificationProcessingContext ctx = NotificationProcessingContext.builder() .tenantId(tenantId) .request(notificationRequest) - .deliveryMethods(Set.of(NotificationDeliveryMethod.WEB)) + .deliveryMethods(Set.of(WEB)) .template(template) .build(); @@ -322,6 +324,7 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple .requestId(request.getId()) .recipientId(recipient.getId()) .type(ctx.getNotificationType()) + .deliveryMethod(WEB) .subject(processedTemplate.getSubject()) .text(processedTemplate.getBody()) .additionalConfig(processedTemplate.getAdditionalConfig()) @@ -347,19 +350,22 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple boolean updated = notificationService.markNotificationAsRead(tenantId, recipientId, notificationId); if (updated) { log.trace("Marked notification {} as read (recipient id: {}, tenant id: {})", notificationId, recipientId, tenantId); - NotificationUpdate update = NotificationUpdate.builder() - .updated(true) - .notificationId(notificationId.getId()) - .newStatus(NotificationStatus.READ) - .build(); - onNotificationUpdate(tenantId, recipientId, update); + Notification notification = notificationService.findNotificationById(tenantId, notificationId); + if (notification.getDeliveryMethod() == WEB) { + NotificationUpdate update = NotificationUpdate.builder() + .updated(true) + .notificationId(notificationId.getId()) + .newStatus(NotificationStatus.READ) + .build(); + onNotificationUpdate(tenantId, recipientId, update); + } } } @Override - public void markAllNotificationsAsRead(TenantId tenantId, UserId recipientId) { - int updatedCount = notificationService.markAllNotificationsAsRead(tenantId, recipientId); - if (updatedCount > 0) { + public void markAllNotificationsAsRead(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId) { + int updatedCount = notificationService.markAllNotificationsAsRead(tenantId, deliveryMethod, recipientId); + if (updatedCount > 0 && deliveryMethod == WEB) { log.trace("Marked all notifications as read (recipient id: {}, tenant id: {})", recipientId, tenantId); NotificationUpdate update = NotificationUpdate.builder() .updated(true) @@ -374,7 +380,7 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple public void deleteNotification(TenantId tenantId, UserId recipientId, NotificationId notificationId) { Notification notification = notificationService.findNotificationById(tenantId, notificationId); boolean deleted = notificationService.deleteNotification(tenantId, recipientId, notificationId); - if (deleted) { + if (deleted && notification.getDeliveryMethod() == WEB) { NotificationUpdate update = NotificationUpdate.builder() .deleted(true) .notification(notification) @@ -451,7 +457,7 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple @Override public NotificationDeliveryMethod getDeliveryMethod() { - return NotificationDeliveryMethod.WEB; + return WEB; } @Override @@ -462,7 +468,7 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple @Autowired public void setChannels(List channels, NotificationCenter webNotificationChannel) { this.channels = channels.stream().collect(Collectors.toMap(NotificationChannel::getDeliveryMethod, c -> c)); - this.channels.put(NotificationDeliveryMethod.WEB, (NotificationChannel) webNotificationChannel); + this.channels.put(WEB, (NotificationChannel) webNotificationChannel); } } diff --git a/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java b/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java index 646556372a..f796e2b440 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.notification.channels; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.base.Strings; import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.MessagingErrorCode; @@ -26,11 +27,15 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.notification.FirebaseService; import org.thingsboard.server.common.data.User; 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; +import org.thingsboard.server.common.data.notification.NotificationStatus; import org.thingsboard.server.common.data.notification.info.NotificationInfo; import org.thingsboard.server.common.data.notification.settings.MobileAppNotificationDeliveryMethodConfig; import org.thingsboard.server.common.data.notification.settings.NotificationSettings; import org.thingsboard.server.common.data.notification.template.MobileAppDeliveryMethodNotificationTemplate; +import org.thingsboard.server.dao.notification.NotificationService; import org.thingsboard.server.dao.notification.NotificationSettingsService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.service.notification.NotificationProcessingContext; @@ -41,6 +46,8 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import static org.thingsboard.server.common.data.notification.NotificationDeliveryMethod.MOBILE_APP; + @Component @RequiredArgsConstructor @Slf4j @@ -48,25 +55,54 @@ public class MobileAppNotificationChannel implements NotificationChannel validTokens = new HashSet<>(mobileSessions.keySet()); String subject = processedTemplate.getSubject(); String body = processedTemplate.getBody(); Map data = getNotificationData(processedTemplate, ctx); + int unreadCount = notificationService.countUnreadNotificationsByRecipientId(ctx.getTenantId(), MOBILE_APP, recipient.getId()); for (String token : mobileSessions.keySet()) { try { - firebaseService.sendMessage(ctx.getTenantId(), credentials, token, subject, body, data); + firebaseService.sendMessage(ctx.getTenantId(), credentials, token, subject, body, data, unreadCount); } catch (FirebaseMessagingException e) { MessagingErrorCode errorCode = e.getMessagingErrorCode(); if (errorCode == MessagingErrorCode.UNREGISTERED || errorCode == MessagingErrorCode.INVALID_ARGUMENT) { @@ -92,12 +128,6 @@ public class MobileAppNotificationChannel implements NotificationChannel { data.put("stateEntityId", stateEntityId.getId().toString()); data.put("stateEntityType", stateEntityId.getEntityType().name()); - if (!"true".equals(data.get("onClick.enabled")) && info.getDashboardId() != null) { - data.put("onClick.enabled", "true"); - data.put("onClick.linkType", "DASHBOARD"); - data.put("onClick.setEntityIdInState", "true"); - data.put("onClick.dashboardId", info.getDashboardId().toString()); - } }); data.put("notificationType", ctx.getNotificationType().name()); switch (ctx.getNotificationType()) { @@ -116,14 +146,14 @@ public class MobileAppNotificationChannel implements NotificationChannel data) throws FirebaseMessagingException { + public void sendMessage(TenantId tenantId, String credentials, String fcmToken, String title, String body, + Map data, Integer badge) throws FirebaseMessagingException { FirebaseContext firebaseContext = contexts.asMap().compute(tenantId.toString(), (key, context) -> { if (context == null) { return new FirebaseContext(key, credentials); @@ -64,6 +65,12 @@ public class DefaultFirebaseService implements FirebaseService { } }); + Aps.Builder apsConfig = Aps.builder() + .setSound("default"); + if (badge != null) { + apsConfig.setBadge(badge); + } + Message message = Message.builder() .setToken(fcmToken) .setNotification(Notification.builder() @@ -74,14 +81,17 @@ public class DefaultFirebaseService implements FirebaseService { .setPriority(AndroidConfig.Priority.HIGH) .build()) .setApnsConfig(ApnsConfig.builder() - .setAps(Aps.builder() - .setContentAvailable(true) - .build()) + .setAps(apsConfig.build()) .build()) .putAllData(data) .build(); - firebaseContext.getMessaging().send(message); - log.trace("[{}] Sent message for FCM token {}", tenantId, fcmToken); + try { + firebaseContext.getMessaging().send(message); + log.trace("[{}] Sent message for FCM token {}", tenantId, fcmToken); + } catch (Throwable t) { + log.debug("[{}] Failed to send message for FCM token {}", tenantId, fcmToken, t); + throw t; + } } public static class FirebaseContext { 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 1542f4095e..ebbd29e589 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 @@ -208,52 +208,22 @@ public class AccessValidator { public void validate(SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { switch (entityId.getEntityType()) { - case DEVICE: - validateDevice(currentUser, operation, entityId, callback); - return; - case DEVICE_PROFILE: - validateDeviceProfile(currentUser, operation, entityId, callback); - return; - case ASSET: - validateAsset(currentUser, operation, entityId, callback); - return; - case ASSET_PROFILE: - validateAssetProfile(currentUser, operation, entityId, callback); - return; - case RULE_CHAIN: - validateRuleChain(currentUser, operation, entityId, callback); - return; - case CUSTOMER: - validateCustomer(currentUser, operation, entityId, callback); - return; - case TENANT: - validateTenant(currentUser, operation, entityId, callback); - return; - case TENANT_PROFILE: - validateTenantProfile(currentUser, operation, entityId, callback); - return; - case USER: - validateUser(currentUser, operation, entityId, callback); - return; - case ENTITY_VIEW: - validateEntityView(currentUser, operation, entityId, callback); - return; - case EDGE: - validateEdge(currentUser, operation, entityId, callback); - return; - case API_USAGE_STATE: - validateApiUsageState(currentUser, operation, entityId, callback); - return; - case TB_RESOURCE: - validateResource(currentUser, operation, entityId, callback); - return; - case OTA_PACKAGE: - validateOtaPackage(currentUser, operation, entityId, callback); - return; - case RPC: - validateRpc(currentUser, operation, entityId, callback); - return; - default: + case DEVICE -> validateDevice(currentUser, operation, entityId, callback); + case DEVICE_PROFILE -> validateDeviceProfile(currentUser, operation, entityId, callback); + case ASSET -> validateAsset(currentUser, operation, entityId, callback); + case ASSET_PROFILE -> validateAssetProfile(currentUser, operation, entityId, callback); + case RULE_CHAIN -> validateRuleChain(currentUser, operation, entityId, callback); + case CUSTOMER -> validateCustomer(currentUser, operation, entityId, callback); + case TENANT -> validateTenant(currentUser, operation, entityId, callback); + case TENANT_PROFILE -> validateTenantProfile(currentUser, operation, entityId, callback); + case USER -> validateUser(currentUser, operation, entityId, callback); + case ENTITY_VIEW -> validateEntityView(currentUser, operation, entityId, callback); + case EDGE -> validateEdge(currentUser, operation, entityId, callback); + case API_USAGE_STATE -> validateApiUsageState(currentUser, operation, entityId, callback); + case TB_RESOURCE -> validateResource(currentUser, operation, entityId, callback); + case OTA_PACKAGE -> validateOtaPackage(currentUser, operation, entityId, callback); + case RPC -> validateRpc(currentUser, operation, entityId, callback); + default -> //TODO: add support of other entities throw new IllegalStateException("Not Implemented!"); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginRequest.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginRequest.java index 5d001ccf6a..dbb930755d 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginRequest.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginRequest.java @@ -32,12 +32,12 @@ public class LoginRequest { this.password = password; } - @Schema(required = true, description = "User email", example = "tenant@thingsboard.org") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "User email", example = "tenant@thingsboard.org") public String getUsername() { return username; } - @Schema(required = true, description = "User password", example = "tenant") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "User password", example = "tenant") public String getPassword() { return password; } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginResponse.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginResponse.java index 239186cf45..503767106a 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginResponse.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginResponse.java @@ -22,11 +22,11 @@ import lombok.Data; @Data public class LoginResponse { - @Schema(required = true, description = "JWT token", + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JWT token", example = "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZW5hbnRAdGhpbmdzYm9hcmQub3JnIi...") private String token; - @Schema(required = true, description = "Refresh token", + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Refresh token", example = "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZW5hbnRAdGhpbmdzYm9hcmQub3JnIi...") private String refreshToken; 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 909300a4b5..3890e5791d 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 @@ -301,18 +301,13 @@ public abstract class AbstractBulkImportService new JsonPrimitive((String) value); + case LONG -> new JsonPrimitive((Long) value); + case DOUBLE -> new JsonPrimitive((Double) value); + case BOOLEAN -> new JsonPrimitive((Boolean) value); + default -> null; + }; } public String stringValue() { diff --git a/application/src/main/java/org/thingsboard/server/service/ws/notification/DefaultNotificationCommandsHandler.java b/application/src/main/java/org/thingsboard/server/service/ws/notification/DefaultNotificationCommandsHandler.java index f868a9d4f3..6fa9111c3d 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/notification/DefaultNotificationCommandsHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/notification/DefaultNotificationCommandsHandler.java @@ -51,6 +51,8 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; +import static org.thingsboard.server.common.data.notification.NotificationDeliveryMethod.WEB; + @Service @TbCoreComponent @RequiredArgsConstructor @@ -104,7 +106,7 @@ public class DefaultNotificationCommandsHandler implements NotificationCommandsH private void fetchUnreadNotifications(NotificationsSubscription subscription) { log.trace("[{}, subId: {}] Fetching unread notifications from DB", subscription.getSessionId(), subscription.getSubscriptionId()); PageData notifications = notificationService.findLatestUnreadNotificationsByRecipientId(subscription.getTenantId(), - (UserId) subscription.getEntityId(), subscription.getLimit()); + WEB, (UserId) subscription.getEntityId(), subscription.getLimit()); subscription.getLatestUnreadNotifications().clear(); notifications.getData().forEach(notification -> { subscription.getLatestUnreadNotifications().put(notification.getUuidId(), notification); @@ -114,7 +116,7 @@ public class DefaultNotificationCommandsHandler implements NotificationCommandsH private void fetchUnreadNotificationsCount(NotificationsCountSubscription subscription) { log.trace("[{}, subId: {}] Fetching unread notifications count from DB", subscription.getSessionId(), subscription.getSubscriptionId()); - int unreadCount = notificationService.countUnreadNotificationsByRecipientId(subscription.getTenantId(), (UserId) subscription.getEntityId()); + int unreadCount = notificationService.countUnreadNotificationsByRecipientId(subscription.getTenantId(), WEB, (UserId) subscription.getEntityId()); subscription.getTotalUnreadCounter().set(unreadCount); } @@ -235,7 +237,7 @@ public class DefaultNotificationCommandsHandler implements NotificationCommandsH @Override public void handleMarkAllAsReadCmd(WebSocketSessionRef sessionRef, MarkAllNotificationsAsReadCmd cmd) { SecurityUser securityCtx = sessionRef.getSecurityCtx(); - notificationCenter.markAllNotificationsAsRead(securityCtx.getTenantId(), securityCtx.getId()); + notificationCenter.markAllNotificationsAsRead(securityCtx.getTenantId(), WEB, securityCtx.getId()); } @Override 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 1dc952c15b..5dd9e4b77f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -41,6 +41,8 @@ import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; @@ -114,6 +116,7 @@ import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.common.msg.session.FeatureType; import org.thingsboard.server.config.ThingsboardSecurityConfiguration; import org.thingsboard.server.dao.Dao; +import org.thingsboard.server.dao.device.ClaimDevicesService; import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService; @@ -151,6 +154,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; +import static org.thingsboard.server.common.data.CacheConstants.CLAIM_DEVICES_CACHE; @Slf4j public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { @@ -232,6 +236,9 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { @Autowired protected DefaultActorService actorService; + @Autowired + protected ClaimDevicesService claimDevicesService; + @SpyBean protected MailService mailService; @@ -1065,6 +1072,16 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { }); } + protected void awaitForClaimingInfoToBeRegistered(DeviceId deviceId) { + CacheManager cacheManager = (CacheManager) ReflectionTestUtils.getField(claimDevicesService, "cacheManager"); + Cache cache = cacheManager.getCache(CLAIM_DEVICES_CACHE); + Awaitility.await("Claiming request from the transport was registered").atMost(5, TimeUnit.SECONDS).until(() -> { + Cache.ValueWrapper value = cache.get(List.of(deviceId)); + log.warn("device {}, claimingRequest registered: {}", deviceId, value); + return value != null; + }); + } + protected static String getMapName(FeatureType featureType) { switch (featureType) { case ATTRIBUTES: diff --git a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java index cd47988658..63747d2c19 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java @@ -663,7 +663,7 @@ public class TenantControllerTest extends AbstractControllerTest { savedDifferentTenant.setTenantProfileId(tenantProfile.getId()); savedDifferentTenant = doPost("/api/tenant", savedDifferentTenant, Tenant.class); TenantId tenantId = differentTenantId; - await().atMost(10, TimeUnit.SECONDS) + await().atMost(30, TimeUnit.SECONDS) .until(() -> { TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, MAIN_QUEUE_NAME, tenantId, tenantId); return !tpi.getTenantId().get().isSysTenantId(); @@ -677,7 +677,7 @@ public class TenantControllerTest extends AbstractControllerTest { tenantProfile.setIsolatedTbRuleEngine(false); tenantProfile.getProfileData().setQueueConfiguration(Collections.emptyList()); tenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); - await().atMost(10, TimeUnit.SECONDS) + await().atMost(30, TimeUnit.SECONDS) .until(() -> partitionService.resolve(ServiceType.TB_RULE_ENGINE, MAIN_QUEUE_NAME, tenantId, tenantId) .getTenantId().get().isSysTenantId()); @@ -689,11 +689,11 @@ public class TenantControllerTest extends AbstractControllerTest { submittedMsgs.add(tbMsg.getId()); Thread.sleep(timeLeft / msgs); } - await().atMost(15, TimeUnit.SECONDS).untilAsserted(() -> { + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { verify(queueAdmin, times(1)).deleteTopic(eq(isolatedTopic)); }); - await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { for (UUID msgId : submittedMsgs) { verify(actorContext).tell(argThat(msg -> { return msg instanceof QueueToRuleEngineMsg && ((QueueToRuleEngineMsg) msg).getMsg().getId().equals(msgId); @@ -718,13 +718,13 @@ public class TenantControllerTest extends AbstractControllerTest { savedDifferentTenant.setTenantProfileId(tenantProfile.getId()); savedDifferentTenant = doPost("/api/tenant", savedDifferentTenant, Tenant.class); TenantId tenantId = differentTenantId; - await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { assertThat(partitionService.getMyPartitions(new QueueKey(ServiceType.TB_RULE_ENGINE, tenantId))).isNotNull(); }); TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, tenantId, tenantId); assertThat(tpi.getTenantId()).hasValue(tenantId); TbMsg tbMsg = publishTbMsg(tenantId, tpi); - await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { verify(actorContext).tell(argThat(msg -> { return msg instanceof QueueToRuleEngineMsg && ((QueueToRuleEngineMsg) msg).getMsg().getId().equals(tbMsg.getId()); })); @@ -732,7 +732,7 @@ public class TenantControllerTest extends AbstractControllerTest { deleteDifferentTenant(); - await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { assertThat(partitionService.getMyPartitions(new QueueKey(ServiceType.TB_RULE_ENGINE, tenantId))).isNull(); assertThatThrownBy(() -> partitionService.resolve(ServiceType.TB_RULE_ENGINE, tenantId, tenantId)) .isInstanceOf(TenantNotFoundException.class); @@ -752,7 +752,7 @@ public class TenantControllerTest extends AbstractControllerTest { } private void verifyUsedQueueAndMessage(String queue, TenantId tenantId, EntityId entityId, String msgType, Runnable action, Consumer tpiAssert) { - await().atMost(15, TimeUnit.SECONDS) + await().atMost(30, TimeUnit.SECONDS) .untilAsserted(() -> { TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, queue, tenantId, entityId); tpiAssert.accept(tpi); diff --git a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java index b945c162df..ae9dbe92c4 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java @@ -145,7 +145,7 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest protected NotificationRequest submitNotificationRequest(List targets, String text, int delayInSec, NotificationDeliveryMethod... deliveryMethods) { if (deliveryMethods.length == 0) { - deliveryMethods = new NotificationDeliveryMethod[]{NotificationDeliveryMethod.WEB}; + deliveryMethods = new NotificationDeliveryMethod[]{NotificationDeliveryMethod.WEB, NotificationDeliveryMethod.MOBILE_APP}; } NotificationTemplate notificationTemplate = createNotificationTemplate(DEFAULT_NOTIFICATION_TYPE, DEFAULT_NOTIFICATION_SUBJECT, text, deliveryMethods); return submitNotificationRequest(targets, notificationTemplate.getId(), delayInSec); @@ -247,8 +247,12 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest } protected List getMyNotifications(boolean unreadOnly, int limit) throws Exception { - return doGetTypedWithPageLink("/api/notifications?unreadOnly={unreadOnly}&", new TypeReference>() {}, - new PageLink(limit, 0), unreadOnly).getData(); + return getMyNotifications(NotificationDeliveryMethod.WEB, unreadOnly, limit); + } + + protected List getMyNotifications(NotificationDeliveryMethod deliveryMethod, boolean unreadOnly, int limit) throws Exception { + return doGetTypedWithPageLink("/api/notifications?unreadOnly={unreadOnly}&deliveryMethod={deliveryMethod}&", new TypeReference>() {}, + new PageLink(limit, 0), unreadOnly, deliveryMethod).getData(); } protected NotificationRule createNotificationRule(NotificationRuleTriggerConfig triggerConfig, String subject, String text, NotificationTargetId... targets) { 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 d3b79029ba..060af9eac1 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 @@ -37,6 +37,7 @@ import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmSeverity; import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.NotificationRequestId; import org.thingsboard.server.common.data.id.NotificationRuleId; @@ -52,6 +53,7 @@ import org.thingsboard.server.common.data.notification.NotificationRequestPrevie import org.thingsboard.server.common.data.notification.NotificationRequestStats; import org.thingsboard.server.common.data.notification.NotificationRequestStatus; import org.thingsboard.server.common.data.notification.NotificationType; +import org.thingsboard.server.common.data.notification.info.AlarmCommentNotificationInfo; import org.thingsboard.server.common.data.notification.info.EntityActionNotificationInfo; import org.thingsboard.server.common.data.notification.rule.trigger.config.AlarmCommentNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.settings.MobileAppNotificationDeliveryMethodConfig; @@ -81,7 +83,6 @@ import org.thingsboard.server.common.data.notification.template.WebDeliveryMetho import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.notification.DefaultNotifications; -import org.thingsboard.server.dao.notification.NotificationDao; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.service.notification.channels.MicrosoftTeamsNotificationChannel; import org.thingsboard.server.service.ws.notification.cmd.UnreadNotificationsUpdate; @@ -116,17 +117,25 @@ public class NotificationApiTest extends AbstractNotificationApiTest { @Autowired private NotificationCenter notificationCenter; @Autowired - private NotificationDao notificationDao; - @Autowired private MicrosoftTeamsNotificationChannel microsoftTeamsNotificationChannel; @MockBean private FirebaseService firebaseService; + private static final String TEST_MOBILE_TOKEN = "tenantFcmToken"; + @Before public void beforeEach() throws Exception { loginCustomerUser(); wsClient = getWsClient(); + + loginSysAdmin(); + MobileAppNotificationDeliveryMethodConfig config = new MobileAppNotificationDeliveryMethodConfig(); + config.setFirebaseServiceAccountCredentials("testCredentials"); + saveNotificationSettings(config); + loginTenantAdmin(); + mobileToken = TEST_MOBILE_TOKEN; + doPost("/api/user/mobile/session", new MobileSessionInfo()).andExpect(status().isOk()); } @Test @@ -242,7 +251,7 @@ public class NotificationApiTest extends AbstractNotificationApiTest { int notificationsCount = 20; wsClient.registerWaitForUpdate(notificationsCount); for (int i = 1; i <= notificationsCount; i++) { - submitNotificationRequest(target.getId(), "Test " + i, NotificationDeliveryMethod.WEB); + submitNotificationRequest(target.getId(), "Test " + i, NotificationDeliveryMethod.WEB, NotificationDeliveryMethod.MOBILE_APP); } wsClient.waitForUpdate(true); assertThat(wsClient.getLastDataUpdate().getTotalUnreadCount()).isEqualTo(notificationsCount); @@ -306,7 +315,7 @@ public class NotificationApiTest extends AbstractNotificationApiTest { NotificationTarget target = createNotificationTarget(savedDifferentTenantUser.getId()); int notificationsCount = 20; for (int i = 0; i < notificationsCount; i++) { - NotificationRequest request = submitNotificationRequest(target.getId(), "Test " + i, NotificationDeliveryMethod.WEB); + NotificationRequest request = submitNotificationRequest(target.getId(), "Test " + i, NotificationDeliveryMethod.WEB, NotificationDeliveryMethod.MOBILE_APP); awaitNotificationRequest(request.getId()); } List requests = notificationRequestService.findNotificationRequestsByTenantIdAndOriginatorType(tenantId, EntityType.USER, new PageLink(100)).getData(); @@ -500,7 +509,7 @@ public class NotificationApiTest extends AbstractNotificationApiTest { @Test public void testNotificationRequestInfo() throws Exception { NotificationDeliveryMethod[] deliveryMethods = new NotificationDeliveryMethod[]{ - NotificationDeliveryMethod.WEB + NotificationDeliveryMethod.WEB, NotificationDeliveryMethod.MOBILE_APP }; NotificationTemplate template = createNotificationTemplate(NotificationType.GENERAL, "Test subject", "Test text", deliveryMethods); NotificationTarget target = createNotificationTarget(tenantAdminUserId); @@ -525,7 +534,6 @@ public class NotificationApiTest extends AbstractNotificationApiTest { await().atMost(2, TimeUnit.SECONDS) .until(() -> findNotificationRequest(notificationRequest.getId()).isSent()); NotificationRequestStats stats = getStats(notificationRequest.getId()); - assertThat(stats.getSent().get(NotificationDeliveryMethod.WEB)).hasValue(1); } @@ -727,17 +735,12 @@ public class NotificationApiTest extends AbstractNotificationApiTest { @Test public void testMobileAppNotifications() throws Exception { - loginSysAdmin(); - MobileAppNotificationDeliveryMethodConfig config = new MobileAppNotificationDeliveryMethodConfig(); - config.setFirebaseServiceAccountCredentials("testCredentials"); - saveNotificationSettings(config); - loginCustomerUser(); mobileToken = "customerFcmToken"; doPost("/api/user/mobile/session", new MobileSessionInfo()).andExpect(status().isOk()); loginTenantAdmin(); - mobileToken = "tenantFcmToken1"; + mobileToken = TEST_MOBILE_TOKEN; doPost("/api/user/mobile/session", new MobileSessionInfo()).andExpect(status().isOk()); mobileToken = "tenantFcmToken2"; doPost("/api/user/mobile/session", new MobileSessionInfo()).andExpect(status().isOk()); @@ -746,7 +749,7 @@ public class NotificationApiTest extends AbstractNotificationApiTest { loginTenantAdmin(); NotificationTarget target = createNotificationTarget(new AllUsersFilter()); - NotificationTemplate template = createNotificationTemplate(NotificationType.GENERAL, "Title", "Message", NotificationDeliveryMethod.MOBILE_APP); + NotificationTemplate template = createNotificationTemplate(NotificationType.GENERAL, "Title", "Message", NotificationDeliveryMethod.MOBILE_APP, NotificationDeliveryMethod.WEB); ((MobileAppDeliveryMethodNotificationTemplate) template.getConfiguration().getDeliveryMethodsTemplates().get(NotificationDeliveryMethod.MOBILE_APP)) .setAdditionalConfig(JacksonUtil.newObjectNode().set("test", JacksonUtil.newObjectNode().put("test", "test"))); saveNotificationTemplate(template); @@ -758,11 +761,19 @@ public class NotificationApiTest extends AbstractNotificationApiTest { .contains("doesn't use the mobile app"); verify(firebaseService).sendMessage(eq(tenantId), eq("testCredentials"), - eq("tenantFcmToken1"), eq("Title"), eq("Message"), argThat(data -> "test".equals(data.get("test.test")))); + eq(TEST_MOBILE_TOKEN), eq("Title"), eq("Message"), argThat(data -> "test".equals(data.get("test.test"))), eq(1)); verify(firebaseService).sendMessage(eq(tenantId), eq("testCredentials"), - eq("tenantFcmToken2"), eq("Title"), eq("Message"), argThat(data -> "test".equals(data.get("test.test")))); + eq("tenantFcmToken2"), eq("Title"), eq("Message"), argThat(data -> "test".equals(data.get("test.test"))), eq(1)); verify(firebaseService).sendMessage(eq(tenantId), eq("testCredentials"), - eq("customerFcmToken"), eq("Title"), eq("Message"), argThat(data -> "test".equals(data.get("test.test")))); + eq("customerFcmToken"), eq("Title"), eq("Message"), argThat(data -> "test".equals(data.get("test.test"))), eq(1)); + assertThat(getMyNotifications(NotificationDeliveryMethod.MOBILE_APP, true, 10)).singleElement().satisfies(notification -> { + assertThat(notification.getDeliveryMethod()).isEqualTo(NotificationDeliveryMethod.MOBILE_APP); + assertThat(notification.getText()).isEqualTo("Message"); + assertThat(notification.getSubject()).isEqualTo("Title"); + }); + assertThat(getMyNotifications(true, 10)).singleElement().satisfies(notification -> { + assertThat(notification.getDeliveryMethod()).isEqualTo(NotificationDeliveryMethod.WEB); + }); verifyNoMoreInteractions(firebaseService); clearInvocations(firebaseService); @@ -770,26 +781,24 @@ public class NotificationApiTest extends AbstractNotificationApiTest { request = submitNotificationRequest(List.of(target.getId()), template.getId(), 0); awaitNotificationRequest(request.getId()); verify(firebaseService).sendMessage(eq(tenantId), eq("testCredentials"), - eq("tenantFcmToken1"), eq("Title"), eq("Message"), anyMap()); + eq(TEST_MOBILE_TOKEN), eq("Title"), eq("Message"), anyMap(), eq(2)); verify(firebaseService).sendMessage(eq(tenantId), eq("testCredentials"), - eq("customerFcmToken"), eq("Title"), eq("Message"), anyMap()); + eq("customerFcmToken"), eq("Title"), eq("Message"), anyMap(), eq(2)); verifyNoMoreInteractions(firebaseService); + + Integer unreadCount = doGet("/api/notifications/unread/count", Integer.class); + assertThat(unreadCount).isEqualTo(2); } @Test public void testMobileAppNotifications_ruleBased() throws Exception { - loginSysAdmin(); - MobileAppNotificationDeliveryMethodConfig config = new MobileAppNotificationDeliveryMethodConfig(); - config.setFirebaseServiceAccountCredentials("testCredentials"); - saveNotificationSettings(config); - loginTenantAdmin(); - mobileToken = "tenantFcmToken"; + mobileToken = TEST_MOBILE_TOKEN; doPost("/api/user/mobile/session", new MobileSessionInfo()).andExpect(status().isOk()); createNotificationRule(AlarmCommentNotificationRuleTriggerConfig.builder().onlyUserComments(true).build(), DefaultNotifications.alarmComment.getSubject(), DefaultNotifications.alarmComment.getText(), - List.of(createNotificationTarget(tenantAdminUserId).getId()), NotificationDeliveryMethod.MOBILE_APP); + List.of(createNotificationTarget(tenantAdminUserId).getId()), NotificationDeliveryMethod.MOBILE_APP, NotificationDeliveryMethod.WEB); Device device = createDevice("test", "test"); UUID alarmDashboardId = UUID.randomUUID(); @@ -802,18 +811,21 @@ public class NotificationApiTest extends AbstractNotificationApiTest { .put("dashboardId", alarmDashboardId.toString())) .build(); alarm = doPost("/api/alarm", alarm, Alarm.class); + AlarmId alarmId = alarm.getId(); AlarmComment comment = new AlarmComment(); comment.setComment(JacksonUtil.newObjectNode() .put("text", "text")); - doPost("/api/alarm/" + alarm.getId() + "/comment", comment, AlarmComment.class); + doPost("/api/alarm/" + alarmId + "/comment", comment, AlarmComment.class); + String expectedSubject = "Comment on 'test' alarm"; + String expectedBody = TENANT_ADMIN_EMAIL + " added comment: text"; ArgumentCaptor> msgCaptor = ArgumentCaptor.forClass(Map.class); await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { verify(firebaseService).sendMessage(eq(tenantId), eq("testCredentials"), - eq("tenantFcmToken"), eq("Comment on 'test' alarm"), - eq(TENANT_ADMIN_EMAIL + " added comment: text"), - msgCaptor.capture()); + eq(TEST_MOBILE_TOKEN), eq(expectedSubject), + eq(expectedBody), + msgCaptor.capture(), eq(1)); }); Map firebaseMessageData = msgCaptor.getValue(); assertThat(firebaseMessageData.keySet()).doesNotContainNull().doesNotContain(""); @@ -823,6 +835,14 @@ public class NotificationApiTest extends AbstractNotificationApiTest { assertThat(firebaseMessageData.get("onClick.enabled")).isEqualTo("true"); assertThat(firebaseMessageData.get("onClick.linkType")).isEqualTo("DASHBOARD"); assertThat(firebaseMessageData.get("onClick.dashboardId")).isEqualTo(alarmDashboardId.toString()); + + assertThat(getMyNotifications(NotificationDeliveryMethod.MOBILE_APP, true, 10)).singleElement().satisfies(notification -> { + assertThat(notification.getDeliveryMethod()).isEqualTo(NotificationDeliveryMethod.MOBILE_APP); + assertThat(notification.getSubject()).isEqualTo(expectedSubject); + assertThat(notification.getText()).isEqualTo(expectedBody); + assertThat(notification.getInfo()).asInstanceOf(type(AlarmCommentNotificationInfo.class)) + .matches(info -> info.getAlarmId().equals(alarmId.getId()) && info.getDashboardId().getId().equals(alarmDashboardId)); + }); } @Test diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimDeviceTest.java index 924792fca1..26bef40c48 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimDeviceTest.java @@ -23,10 +23,7 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.thingsboard.server.common.data.ClaimRequest; -import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.msg.session.FeatureType; import org.thingsboard.server.dao.device.claim.ClaimResponse; import org.thingsboard.server.dao.device.claim.ClaimResult; @@ -45,37 +42,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @DaoSqlTest public class CoapClaimDeviceTest extends AbstractCoapIntegrationTest { - protected static final String CUSTOMER_USER_PASSWORD = "customerUser123!"; - - protected User customerAdmin; - protected Customer savedCustomer; - @Before public void beforeTest() throws Exception { CoapTestConfigProperties configProperties = CoapTestConfigProperties.builder() .deviceName("Test Claim device") .build(); processBeforeTest(configProperties); - createCustomerAndUser(); - } - - protected void createCustomerAndUser() throws Exception { - Customer customer = new Customer(); - customer.setTenantId(tenantId); - customer.setTitle("Test Claiming Customer"); - savedCustomer = doPost("/api/customer", customer, Customer.class); - assertNotNull(savedCustomer); - assertEquals(tenantId, savedCustomer.getTenantId()); - - User user = new User(); - user.setAuthority(Authority.CUSTOMER_USER); - user.setTenantId(tenantId); - user.setCustomerId(savedCustomer.getId()); - user.setEmail("customer@thingsboard.org"); - - customerAdmin = createUser(user, CUSTOMER_USER_PASSWORD); - assertNotNull(customerAdmin); - assertEquals(customerAdmin.getCustomerId(), savedCustomer.getId()); } @After @@ -110,8 +82,9 @@ public class CoapClaimDeviceTest extends AbstractCoapIntegrationTest { protected void validateClaimResponse(boolean emptyPayload, CoapTestClient client, byte[] payloadBytes, byte[] failurePayloadBytes) throws Exception { postClaimRequest(client, failurePayloadBytes); + awaitForClaimingInfoToBeRegistered(savedDevice.getId()); - loginUser(customerAdmin.getName(), CUSTOMER_USER_PASSWORD); + loginCustomerUser(); ClaimRequest claimRequest; if (!emptyPayload) { claimRequest = new ClaimRequest("value"); @@ -128,6 +101,7 @@ public class CoapClaimDeviceTest extends AbstractCoapIntegrationTest { assertEquals(claimResponse, ClaimResponse.FAILURE); postClaimRequest(client, payloadBytes); + awaitForClaimingInfoToBeRegistered(savedDevice.getId()); ClaimResult claimResult = doExecuteWithRetriesAndInterval( () -> doPostClaimAsync("/api/customer/device/" + savedDevice.getName() + "/claim", claimRequest, ClaimResult.class, status().isOk()), @@ -138,7 +112,7 @@ public class CoapClaimDeviceTest extends AbstractCoapIntegrationTest { Device claimedDevice = claimResult.getDevice(); assertNotNull(claimedDevice); assertNotNull(claimedDevice.getCustomerId()); - assertEquals(customerAdmin.getCustomerId(), claimedDevice.getCustomerId()); + assertEquals(customerId, claimedDevice.getCustomerId()); claimResponse = doPostClaimAsync("/api/customer/device/" + savedDevice.getName() + "/claim", claimRequest, ClaimResponse.class, status().isBadRequest()); assertEquals(claimResponse, ClaimResponse.CLAIMED); diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimJsonDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimJsonDeviceTest.java index 89ef5e57fe..8e1d663565 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimJsonDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimJsonDeviceTest.java @@ -36,7 +36,6 @@ public class CoapClaimJsonDeviceTest extends CoapClaimDeviceTest { .transportPayloadType(TransportPayloadType.JSON) .build(); processBeforeTest(configProperties); - createCustomerAndUser(); } @After diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimProtoDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimProtoDeviceTest.java index 7dbebac847..eb66e63190 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimProtoDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimProtoDeviceTest.java @@ -39,7 +39,6 @@ public class CoapClaimProtoDeviceTest extends CoapClaimDeviceTest { .transportPayloadType(TransportPayloadType.PROTOBUF) .build(); processBeforeTest(configProperties); - createCustomerAndUser(); } @After diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java index 510854d759..c0aeaf37ef 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java @@ -189,9 +189,9 @@ public abstract class AbstractMqttIntegrationTest extends AbstractTransportInteg subscribeAndWait(client, attrSubTopic, deviceId, featureType, MqttQoS.AT_MOST_ONCE); } - protected void subscribeAndWait(MqttTestClient client, String attrSubTopic, DeviceId deviceId, FeatureType featureType, MqttQoS mqttQoS) throws MqttException { + protected void subscribeAndWait(MqttTestClient client, String subTopic, DeviceId deviceId, FeatureType featureType, MqttQoS mqttQoS) throws MqttException { int subscriptionCount = getDeviceActorSubscriptionCount(deviceId, featureType); - client.subscribeAndWait(attrSubTopic, mqttQoS); + client.subscribeAndWait(subTopic, mqttQoS); // TODO: This test awaits for the device actor to receive the subscription. Ideally it should not happen. See details below: // The transport layer acknowledge subscription request once the message about subscription is in the queue. // Test sends data immediately after acknowledgement. @@ -200,8 +200,8 @@ public abstract class AbstractMqttIntegrationTest extends AbstractTransportInteg awaitForDeviceActorToReceiveSubscription(deviceId, featureType, subscriptionCount + 1); } - protected void subscribeAndCheckSubscription(MqttTestClient client, String attrSubTopic, DeviceId deviceId, FeatureType featureType) throws MqttException { - client.subscribeAndWait(attrSubTopic, MqttQoS.AT_MOST_ONCE); + protected void subscribeAndCheckSubscription(MqttTestClient client, String subTopic, DeviceId deviceId, FeatureType featureType) throws MqttException { + client.subscribeAndWait(subTopic, MqttQoS.AT_MOST_ONCE); // TODO: This test awaits for the device actor to receive the subscription. Ideally it should not happen. See details below: // The transport layer acknowledge subscription request once the message about subscription is in the queue. // Test sends data immediately after acknowledgement. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimBackwardCompatibilityDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimBackwardCompatibilityDeviceTest.java index feab658a9b..96e45d51c0 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimBackwardCompatibilityDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimBackwardCompatibilityDeviceTest.java @@ -36,7 +36,6 @@ public class MqttClaimBackwardCompatibilityDeviceTest extends MqttClaimDeviceTes .useJsonPayloadFormatForDefaultDownlinkTopics(true) .build(); processBeforeTest(configProperties); - createCustomerAndUser(); } @Test diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimDeviceTest.java index b253136b6e..fbc12e48ba 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimDeviceTest.java @@ -19,10 +19,7 @@ import lombok.extern.slf4j.Slf4j; import org.junit.Before; import org.junit.Test; import org.thingsboard.server.common.data.ClaimRequest; -import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.device.claim.ClaimResponse; import org.thingsboard.server.dao.device.claim.ClaimResult; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -41,11 +38,6 @@ import static org.thingsboard.server.common.data.device.profile.MqttTopics.GATEW @DaoSqlTest public class MqttClaimDeviceTest extends AbstractMqttIntegrationTest { - protected static final String CUSTOMER_USER_PASSWORD = "customerUser123!"; - - protected User customerAdmin; - protected Customer savedCustomer; - @Before public void beforeTest() throws Exception { MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder() @@ -53,26 +45,6 @@ public class MqttClaimDeviceTest extends AbstractMqttIntegrationTest { .gatewayName("Test Claim gateway") .build(); processBeforeTest(configProperties); - createCustomerAndUser(); - } - - protected void createCustomerAndUser() throws Exception { - Customer customer = new Customer(); - customer.setTenantId(tenantId); - customer.setTitle("Test Claiming Customer"); - savedCustomer = doPost("/api/customer", customer, Customer.class); - assertNotNull(savedCustomer); - assertEquals(tenantId, savedCustomer.getTenantId()); - - User user = new User(); - user.setAuthority(Authority.CUSTOMER_USER); - user.setTenantId(tenantId); - user.setCustomerId(savedCustomer.getId()); - user.setEmail("customer@thingsboard.org"); - - customerAdmin = createUser(user, CUSTOMER_USER_PASSWORD); - assertNotNull(customerAdmin); - assertEquals(customerAdmin.getCustomerId(), savedCustomer.getId()); } @Test @@ -113,8 +85,9 @@ public class MqttClaimDeviceTest extends AbstractMqttIntegrationTest { protected void validateClaimResponse(boolean emptyPayload, MqttTestClient client, byte[] payloadBytes, byte[] failurePayloadBytes) throws Exception { client.publishAndWait(DEVICE_CLAIM_TOPIC, failurePayloadBytes); + awaitForClaimingInfoToBeRegistered(savedDevice.getId()); - loginUser(customerAdmin.getName(), CUSTOMER_USER_PASSWORD); + loginCustomerUser(); ClaimRequest claimRequest; if (!emptyPayload) { claimRequest = new ClaimRequest("value"); @@ -132,6 +105,7 @@ public class MqttClaimDeviceTest extends AbstractMqttIntegrationTest { client.publishAndWait(DEVICE_CLAIM_TOPIC, payloadBytes); client.disconnect(); + awaitForClaimingInfoToBeRegistered(savedDevice.getId()); ClaimResult claimResult = doExecuteWithRetriesAndInterval( () -> doPostClaimAsync("/api/customer/device/" + savedDevice.getName() + "/claim", claimRequest, ClaimResult.class, status().isOk()), @@ -142,7 +116,7 @@ public class MqttClaimDeviceTest extends AbstractMqttIntegrationTest { Device claimedDevice = claimResult.getDevice(); assertNotNull(claimedDevice); assertNotNull(claimedDevice.getCustomerId()); - assertEquals(customerAdmin.getCustomerId(), claimedDevice.getCustomerId()); + assertEquals(customerId, claimedDevice.getCustomerId()); claimResponse = doPostClaimAsync("/api/customer/device/" + savedDevice.getName() + "/claim", claimRequest, ClaimResponse.class, status().isBadRequest()); assertEquals(claimResponse, ClaimResponse.CLAIMED); @@ -158,8 +132,9 @@ public class MqttClaimDeviceTest extends AbstractMqttIntegrationTest { ); assertNotNull(savedDevice); + awaitForClaimingInfoToBeRegistered(savedDevice.getId()); - loginUser(customerAdmin.getName(), CUSTOMER_USER_PASSWORD); + loginCustomerUser(); ClaimRequest claimRequest; if (!emptyPayload) { claimRequest = new ClaimRequest("value"); @@ -172,6 +147,7 @@ public class MqttClaimDeviceTest extends AbstractMqttIntegrationTest { client.publishAndWait(GATEWAY_CLAIM_TOPIC, payloadBytes); client.disconnect(); + awaitForClaimingInfoToBeRegistered(savedDevice.getId()); ClaimResult claimResult = doExecuteWithRetriesAndInterval( () -> doPostClaimAsync("/api/customer/device/" + deviceName + "/claim", claimRequest, ClaimResult.class, status().isOk()), @@ -183,7 +159,7 @@ public class MqttClaimDeviceTest extends AbstractMqttIntegrationTest { Device claimedDevice = claimResult.getDevice(); assertNotNull(claimedDevice); assertNotNull(claimedDevice.getCustomerId()); - assertEquals(customerAdmin.getCustomerId(), claimedDevice.getCustomerId()); + assertEquals(customerId, claimedDevice.getCustomerId()); claimResponse = doPostClaimAsync("/api/customer/device/" + deviceName + "/claim", claimRequest, ClaimResponse.class, status().isBadRequest()); assertEquals(claimResponse, ClaimResponse.CLAIMED); diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimJsonDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimJsonDeviceTest.java index a591933cf7..c567ef25d1 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimJsonDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimJsonDeviceTest.java @@ -34,7 +34,6 @@ public class MqttClaimJsonDeviceTest extends MqttClaimDeviceTest { .transportPayloadType(TransportPayloadType.JSON) .build(); processBeforeTest(configProperties); - createCustomerAndUser(); } @Test diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimProtoDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimProtoDeviceTest.java index d4276eb211..a8bc81cee5 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimProtoDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimProtoDeviceTest.java @@ -36,7 +36,6 @@ public class MqttClaimProtoDeviceTest extends MqttClaimDeviceTest { .transportPayloadType(TransportPayloadType.PROTOBUF) .build(); processBeforeTest(configProperties); - createCustomerAndUser(); } @Test diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/AbstractMqttV5ClaimTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/AbstractMqttV5ClaimTest.java index 7213ecafb5..00a190f6d0 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/AbstractMqttV5ClaimTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/AbstractMqttV5ClaimTest.java @@ -17,10 +17,7 @@ package org.thingsboard.server.transport.mqtt.mqttv5.claim; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.ClaimRequest; -import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.device.claim.ClaimResponse; import org.thingsboard.server.dao.device.claim.ClaimResult; import org.thingsboard.server.transport.mqtt.mqttv5.AbstractMqttV5Test; @@ -33,10 +30,6 @@ import static org.thingsboard.server.common.data.device.profile.MqttTopics.DEVIC @Slf4j public abstract class AbstractMqttV5ClaimTest extends AbstractMqttV5Test { - protected static final String CUSTOMER_USER_PASSWORD = "customerUser123!"; - - protected User customerAdmin; - protected Customer savedCustomer; protected void processTestClaimingDevice() throws Exception { MqttV5TestClient client = new MqttV5TestClient(); @@ -50,8 +43,9 @@ public abstract class AbstractMqttV5ClaimTest extends AbstractMqttV5Test { protected void validateClaimResponse(MqttV5TestClient client, byte[] payloadBytes, byte[] failurePayloadBytes) throws Exception { client.publishAndWait(DEVICE_CLAIM_TOPIC, failurePayloadBytes); + awaitForClaimingInfoToBeRegistered(savedDevice.getId()); - loginUser(customerAdmin.getName(), CUSTOMER_USER_PASSWORD); + loginCustomerUser(); ClaimRequest claimRequest = new ClaimRequest("value"); ClaimResponse claimResponse = doExecuteWithRetriesAndInterval( @@ -64,6 +58,7 @@ public abstract class AbstractMqttV5ClaimTest extends AbstractMqttV5Test { client.publishAndWait(DEVICE_CLAIM_TOPIC, payloadBytes); client.disconnect(); + awaitForClaimingInfoToBeRegistered(savedDevice.getId()); ClaimResult claimResult = doExecuteWithRetriesAndInterval( () -> doPostClaimAsync("/api/customer/device/" + savedDevice.getName() + "/claim", claimRequest, ClaimResult.class, status().isOk()), @@ -74,28 +69,10 @@ public abstract class AbstractMqttV5ClaimTest extends AbstractMqttV5Test { Device claimedDevice = claimResult.getDevice(); assertNotNull(claimedDevice); assertNotNull(claimedDevice.getCustomerId()); - assertEquals(customerAdmin.getCustomerId(), claimedDevice.getCustomerId()); + assertEquals(customerId, claimedDevice.getCustomerId()); claimResponse = doPostClaimAsync("/api/customer/device/" + savedDevice.getName() + "/claim", claimRequest, ClaimResponse.class, status().isBadRequest()); assertEquals(claimResponse, ClaimResponse.CLAIMED); } - protected void createCustomerAndUser() throws Exception { - Customer customer = new Customer(); - customer.setTenantId(tenantId); - customer.setTitle("Test Claiming Customer"); - savedCustomer = doPost("/api/customer", customer, Customer.class); - assertNotNull(savedCustomer); - assertEquals(tenantId, savedCustomer.getTenantId()); - - User user = new User(); - user.setAuthority(Authority.CUSTOMER_USER); - user.setTenantId(tenantId); - user.setCustomerId(savedCustomer.getId()); - user.setEmail("customer@thingsboard.org"); - - customerAdmin = createUser(user, CUSTOMER_USER_PASSWORD); - assertNotNull(customerAdmin); - assertEquals(customerAdmin.getCustomerId(), savedCustomer.getId()); - } } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/MqttV5ClaimTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/MqttV5ClaimTest.java index dcabe07de1..75e884f9fd 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/MqttV5ClaimTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/MqttV5ClaimTest.java @@ -29,7 +29,6 @@ public class MqttV5ClaimTest extends AbstractMqttV5ClaimTest { .deviceName("Test Claim device") .build(); processBeforeTest(configProperties); - createCustomerAndUser(); } @Test diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/event/EventService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/event/EventService.java index 9b0d5a280d..ef6ebdcdd7 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/event/EventService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/event/EventService.java @@ -35,6 +35,8 @@ public interface EventService { List findLatestEvents(TenantId tenantId, EntityId entityId, EventType eventType, int limit); + EventInfo findLatestDebugRuleNodeInEvent(TenantId tenantId, EntityId entityId); + PageData findEventsByFilter(TenantId tenantId, EntityId entityId, EventFilter eventFilter, TimePageLink pageLink); void removeEvents(TenantId tenantId, EntityId entityId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/notification/NotificationService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/notification/NotificationService.java index 18c9d4a7fb..91f6f7a379 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/notification/NotificationService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/notification/NotificationService.java @@ -19,6 +19,7 @@ import org.thingsboard.server.common.data.id.NotificationId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.notification.Notification; +import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -30,13 +31,13 @@ public interface NotificationService { boolean markNotificationAsRead(TenantId tenantId, UserId recipientId, NotificationId notificationId); - int markAllNotificationsAsRead(TenantId tenantId, UserId recipientId); + int markAllNotificationsAsRead(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId); - PageData findNotificationsByRecipientIdAndReadStatus(TenantId tenantId, UserId recipientId, boolean unreadOnly, PageLink pageLink); + PageData findNotificationsByRecipientIdAndReadStatus(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, boolean unreadOnly, PageLink pageLink); - PageData findLatestUnreadNotificationsByRecipientId(TenantId tenantId, UserId recipientId, int limit); + PageData findLatestUnreadNotificationsByRecipientId(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, int limit); - int countUnreadNotificationsByRecipientId(TenantId tenantId, UserId recipientId); + int countUnreadNotificationsByRecipientId(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId); boolean deleteNotification(TenantId tenantId, UserId recipientId, NotificationId notificationId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantService.java index 11a71eaff5..bef82f921f 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantService.java @@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.entity.EntityDaoService; import java.util.List; +import java.util.function.Consumer; public interface TenantService extends EntityDaoService { @@ -36,7 +37,7 @@ public interface TenantService extends EntityDaoService { Tenant saveTenant(Tenant tenant); - Tenant saveTenant(Tenant tenant, boolean publishSaveEvent); + Tenant saveTenant(Tenant tenant, Consumer defaultEntitiesCreator); boolean tenantExists(TenantId tenantId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java b/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java index 9f0100dbb1..398cd17aa1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java @@ -36,7 +36,7 @@ public class Customer extends ContactBased implements HasTenantId, E @NoXss @Length(fieldName = "title") - @Schema(required = true, description = "Title of the customer", example = "Company A") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Title of the customer", example = "Company A") private String title; @Schema(description = "JSON object with Tenant Id") private TenantId tenantId; @@ -132,7 +132,7 @@ public class Customer extends ContactBased implements HasTenantId, E return super.getPhone(); } - @Schema(required = true, description = "Email", example = "example@company.com") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Email", example = "example@company.com") @Override public String getEmail() { return super.getEmail(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DashboardInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/DashboardInfo.java index be0f7ce109..238ef8744c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DashboardInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DashboardInfo.java @@ -85,7 +85,7 @@ public class DashboardInfo extends BaseData implements HasName, Has this.tenantId = tenantId; } - @Schema(required = true, description = "Title of the dashboard.") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Title of the dashboard.") public String getTitle() { return title; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Device.java b/common/data/src/main/java/org/thingsboard/server/common/data/Device.java index 13df99ca50..e7c37d3ed1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/Device.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/Device.java @@ -136,7 +136,7 @@ public class Device extends BaseDataWithAdditionalInfo implements HasL this.customerId = customerId; } - @Schema(required = true, description = "Unique Device Name in scope of Tenant", example = "A4B72CCDFF33") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Unique Device Name in scope of Tenant", example = "A4B72CCDFF33") @Override public String getName() { return name; @@ -164,7 +164,7 @@ public class Device extends BaseDataWithAdditionalInfo implements HasL this.label = label; } - @Schema(required = true, description = "JSON object with Device Profile Id.") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with Device Profile Id.") public DeviceProfileId getDeviceProfileId() { return deviceProfileId; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java index 4a4e785dac..344045134e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -40,17 +40,17 @@ public class EntityView extends BaseDataWithAdditionalInfo private static final long serialVersionUID = 5582010124562018986L; - @Schema(required = true, description = "JSON object with the referenced Entity Id (Device or Asset).") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with the referenced Entity Id (Device or Asset).") private EntityId entityId; private TenantId tenantId; private CustomerId customerId; @NoXss @Length(fieldName = "name") - @Schema(required = true, description = "Entity View name", example = "A4B72CCDFF33") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Entity View name", example = "A4B72CCDFF33") private String name; @NoXss @Length(fieldName = "type") - @Schema(required = true, description = "Device Profile Name", example = "Temperature Sensor") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Device Profile Name", example = "Temperature Sensor") private String type; @Schema(description = "Set of telemetry and attribute keys to expose via Entity View.") private TelemetryEntityView keys; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SaveDeviceWithCredentialsRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/SaveDeviceWithCredentialsRequest.java index bc40710aed..24991bb71d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SaveDeviceWithCredentialsRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SaveDeviceWithCredentialsRequest.java @@ -25,10 +25,10 @@ import jakarta.validation.constraints.NotNull;; @Data public class SaveDeviceWithCredentialsRequest { - @Schema(description = "The JSON with device entity.", required = true) + @Schema(description = "The JSON with device entity.", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull private final Device device; - @Schema(description = "The JSON with credentials entity.", required = true) + @Schema(description = "The JSON with credentials entity.", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull private final DeviceCredentials credentials; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java b/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java index 1f649f69e6..7cb7e15395 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java @@ -33,7 +33,7 @@ public class Tenant extends ContactBased implements HasTenantId, HasTi @Length(fieldName = "title") @NoXss - @Schema(required = true, description = "Title of the tenant", example = "Company A") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Title of the tenant", example = "Company A") private String title; @NoXss @Length(fieldName = "region") @@ -152,7 +152,7 @@ public class Tenant extends ContactBased implements HasTenantId, HasTi return super.getPhone(); } - @Schema(required = true, description = "Email", example = "example@company.com") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Email", example = "example@company.com") @Override public String getEmail() { return super.getEmail(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/User.java b/common/data/src/main/java/org/thingsboard/server/common/data/User.java index 2027de62be..678b7cdac6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/User.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/User.java @@ -103,7 +103,7 @@ public class User extends BaseDataWithAdditionalInfo implements HasName, this.customerId = customerId; } - @Schema(required = true, description = "Email of the user", example = "user@example.com") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Email of the user", example = "user@example.com") public String getEmail() { return email; } @@ -119,7 +119,7 @@ public class User extends BaseDataWithAdditionalInfo implements HasName, return email; } - @Schema(required = true, description = "Authority", example = "SYS_ADMIN, TENANT_ADMIN or CUSTOMER_USER") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Authority", example = "SYS_ADMIN, TENANT_ADMIN or CUSTOMER_USER") public Authority getAuthority() { return authority; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java index 04d2b9231c..19e0bc29b1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java @@ -59,16 +59,16 @@ public class Alarm extends BaseData implements HasName, HasTenantId, Ha private CustomerId customerId; @NoXss - @Schema(required = true, description = "representing type of the Alarm", example = "High Temperature Alarm") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "representing type of the Alarm", example = "High Temperature Alarm") @Length(fieldName = "type") private String type; - @Schema(required = true, description = "JSON object with alarm originator id") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with alarm originator id") private EntityId originator; - @Schema(required = true, description = "Alarm severity", example = "CRITICAL") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Alarm severity", example = "CRITICAL") private AlarmSeverity severity; - @Schema(required = true, description = "Acknowledged", example = "true") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Acknowledged", example = "true") private boolean acknowledged; - @Schema(required = true, description = "Cleared", example = "false") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Cleared", example = "false") private boolean cleared; @Schema(description = "Alarm assignee user id") private UserId assigneeId; @@ -128,7 +128,7 @@ public class Alarm extends BaseData implements HasName, HasTenantId, Ha @Override @JsonProperty(access = JsonProperty.Access.READ_ONLY) - @Schema(required = true, description = "representing type of the Alarm", example = "High Temperature Alarm") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "representing type of the Alarm", example = "High Temperature Alarm") public String getName() { return type; } @@ -150,7 +150,7 @@ public class Alarm extends BaseData implements HasName, HasTenantId, Ha } @JsonProperty(access = JsonProperty.Access.READ_ONLY) - @Schema(required = true, description = "status of the Alarm", example = "ACTIVE_UNACK", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "status of the Alarm", example = "ACTIVE_UNACK", accessMode = Schema.AccessMode.READ_ONLY) public AlarmStatus getStatus() { return toStatus(cleared, acknowledged); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java index 54dd8ba71b..c39c603a9e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java @@ -40,14 +40,14 @@ public class AlarmCreateOrUpdateActiveRequest implements AlarmModificationReques @Schema(description = "JSON object with Customer Id", accessMode = Schema.AccessMode.READ_ONLY) private CustomerId customerId; @NotNull - @Schema(required = true, description = "representing type of the Alarm", example = "High Temperature Alarm") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "representing type of the Alarm", example = "High Temperature Alarm") @Length(fieldName = "type") private String type; @NotNull - @Schema(required = true, description = "JSON object with alarm originator id") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with alarm originator id") private EntityId originator; @NotNull - @Schema(required = true, description = "Alarm severity", example = "CRITICAL") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Alarm severity", example = "CRITICAL") private AlarmSeverity severity; @Schema(description = "Timestamp of the alarm start time, in milliseconds", example = "1634058704565") private long startTs; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmUpdateRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmUpdateRequest.java index 544d200a27..8eb418d6dc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmUpdateRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmUpdateRequest.java @@ -41,7 +41,7 @@ public class AlarmUpdateRequest implements AlarmModificationRequest { "Omit this field to create new alarm.") private AlarmId alarmId; @NotNull - @Schema(required = true, description = "Alarm severity", example = "CRITICAL") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Alarm severity", example = "CRITICAL") private AlarmSeverity severity; @Schema(description = "Timestamp of the alarm start time, in milliseconds", example = "1634058704565") private long startTs; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java b/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java index 753a83fbaf..16518bc618 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java @@ -120,7 +120,7 @@ public class Asset extends BaseDataWithAdditionalInfo implements HasLab this.customerId = customerId; } - @Schema(required = true, description = "Unique Asset Name in scope of Tenant", example = "Empire State Building") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Unique Asset Name in scope of Tenant", example = "Empire State Building") @Override public String getName() { return name; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java b/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java index d281589734..67d32d6e89 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java @@ -120,13 +120,13 @@ public class Edge extends BaseDataWithAdditionalInfo implements HasLabel return this.rootRuleChainId; } - @Schema(required = true, description = "Unique Edge Name in scope of Tenant", example = "Silo_A_Edge") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Unique Edge Name in scope of Tenant", example = "Silo_A_Edge") @Override public String getName() { return this.name; } - @Schema(required = true, description = "Edge type", example = "Silos") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Edge type", example = "Silos") public String getType() { return this.type; } @@ -136,12 +136,12 @@ public class Edge extends BaseDataWithAdditionalInfo implements HasLabel return this.label; } - @Schema(required = true, description = "Edge routing key ('username') to authorize on cloud") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Edge routing key ('username') to authorize on cloud") public String getRoutingKey() { return this.routingKey; } - @Schema(required = true, description = "Edge secret ('password') to authorize on cloud") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Edge secret ('password') to authorize on cloud") public String getSecret() { return this.secret; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java index dc7f632037..6d2a110cf7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java @@ -33,7 +33,7 @@ import io.swagger.v3.oas.annotations.media.Schema; }) public interface EventFilter { - @Schema(required = true, description = "String value representing the event type", example = "STATS") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "String value representing the event type", example = "STATS") EventType getEventType(); boolean isNotEmpty(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AlarmId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AlarmId.java index aae2e3726a..02b4d602ce 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AlarmId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AlarmId.java @@ -36,7 +36,7 @@ public class AlarmId extends UUIDBased implements EntityId { return new AlarmId(UUID.fromString(alarmId)); } - @Schema(required = true, description = "string", example = "ALARM", allowableValues = "ALARM") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ALARM", allowableValues = "ALARM") @Override public EntityType getEntityType() { return EntityType.ALARM; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiUsageStateId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiUsageStateId.java index 228b4160a0..ec942731f3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiUsageStateId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiUsageStateId.java @@ -34,7 +34,7 @@ public class ApiUsageStateId extends UUIDBased implements EntityId { return new ApiUsageStateId(UUID.fromString(userId)); } - @Schema(required = true, description = "string", example = "API_USAGE_STATE", allowableValues = "API_USAGE_STATE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "API_USAGE_STATE", allowableValues = "API_USAGE_STATE") @Override public EntityType getEntityType() { return EntityType.API_USAGE_STATE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetId.java index 2c70920aeb..a45668e9bd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetId.java @@ -36,7 +36,7 @@ public class AssetId extends UUIDBased implements EntityId { return new AssetId(UUID.fromString(assetId)); } - @Schema(required = true, description = "string", example = "ASSET", allowableValues = "ASSET") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ASSET", allowableValues = "ASSET") @Override public EntityType getEntityType() { return EntityType.ASSET; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetProfileId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetProfileId.java index 0efd6dcf71..d2377c19e6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetProfileId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetProfileId.java @@ -35,7 +35,7 @@ public class AssetProfileId extends UUIDBased implements EntityId { return new AssetProfileId(UUID.fromString(assetProfileId)); } - @Schema(required = true, description = "string", example = "ASSET_PROFILE", allowableValues = "ASSET_PROFILE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ASSET_PROFILE", allowableValues = "ASSET_PROFILE") @Override public EntityType getEntityType() { return EntityType.ASSET_PROFILE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/CustomerId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/CustomerId.java index 72f380a4bd..d8ef7fd09e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/CustomerId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/CustomerId.java @@ -32,7 +32,7 @@ public final class CustomerId extends UUIDBased implements EntityId { super(id); } - @Schema(required = true, description = "string", example = "CUSTOMER", allowableValues = "CUSTOMER") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "CUSTOMER", allowableValues = "CUSTOMER") @Override public EntityType getEntityType() { return EntityType.CUSTOMER; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/DashboardId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/DashboardId.java index 7220919b14..a7c21821ff 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/DashboardId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/DashboardId.java @@ -34,7 +34,7 @@ public class DashboardId extends UUIDBased implements EntityId { return new DashboardId(UUID.fromString(dashboardId)); } - @Schema(required = true, description = "string", example = "DASHBOARD", allowableValues = "DASHBOARD") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "DASHBOARD", allowableValues = "DASHBOARD") @Override public EntityType getEntityType() { return EntityType.DASHBOARD; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceId.java index d59de6cb07..8fe48b9836 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceId.java @@ -37,7 +37,7 @@ public class DeviceId extends UUIDBased implements EntityId { } @Override - @Schema(required = true, description = "string", example = "DEVICE", allowableValues = "DEVICE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "DEVICE", allowableValues = "DEVICE") public EntityType getEntityType() { return EntityType.DEVICE; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceProfileId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceProfileId.java index 11679436f5..7050fa7ff3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceProfileId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceProfileId.java @@ -35,7 +35,7 @@ public class DeviceProfileId extends UUIDBased implements EntityId { return new DeviceProfileId(UUID.fromString(deviceProfileId)); } - @Schema(required = true, description = "string", example = "DEVICE_PROFILE", allowableValues = "DEVICE_PROFILE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "DEVICE_PROFILE", allowableValues = "DEVICE_PROFILE") @Override public EntityType getEntityType() { return EntityType.DEVICE_PROFILE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java index 1d6c1dad49..5c29c210df 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java @@ -41,7 +41,7 @@ public class EdgeId extends UUIDBased implements EntityId { return new EdgeId(UUID.fromString(edgeId)); } - @Schema(required = true, description = "string", example = "EDGE", allowableValues = "EDGE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "EDGE", allowableValues = "EDGE") @Override public EntityType getEntityType() { return EntityType.EDGE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java index a44f35e5d3..7c5f7be1e8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java @@ -38,7 +38,7 @@ public class EntityViewId extends UUIDBased implements EntityId { return new EntityViewId(UUID.fromString(entityViewID)); } - @Schema(required = true, description = "string", example = "ENTITY_VIEW", allowableValues = "ENTITY_VIEW") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ENTITY_VIEW", allowableValues = "ENTITY_VIEW") @Override public EntityType getEntityType() { return EntityType.ENTITY_VIEW; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationId.java index be8aa8e9cd..8fc12993b2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationId.java @@ -29,7 +29,7 @@ public class NotificationId extends UUIDBased implements EntityId { super(id); } - @Schema(required = true, description = "string", example = "NOTIFICATION", allowableValues = "NOTIFICATION") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "NOTIFICATION", allowableValues = "NOTIFICATION") @Override public EntityType getEntityType() { return EntityType.NOTIFICATION; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRequestId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRequestId.java index afcb27f0c9..30c7336b79 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRequestId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRequestId.java @@ -29,7 +29,7 @@ public class NotificationRequestId extends UUIDBased implements EntityId { super(id); } - @Schema(required = true, description = "string", example = "NOTIFICATION_REQUEST", allowableValues = "NOTIFICATION_REQUEST") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "NOTIFICATION_REQUEST", allowableValues = "NOTIFICATION_REQUEST") @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_REQUEST; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRuleId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRuleId.java index dc5e6c8671..c17e42a759 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRuleId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRuleId.java @@ -29,7 +29,7 @@ public class NotificationRuleId extends UUIDBased implements EntityId { super(id); } - @Schema(required = true, description = "string", example = "NOTIFICATION_RULE", allowableValues = "NOTIFICATION_RULE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "NOTIFICATION_RULE", allowableValues = "NOTIFICATION_RULE") @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_RULE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTargetId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTargetId.java index 3966dbbbd7..2984bc7644 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTargetId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTargetId.java @@ -29,7 +29,7 @@ public class NotificationTargetId extends UUIDBased implements EntityId { super(id); } - @Schema(required = true, description = "string", example = "NOTIFICATION_TARGET", allowableValues = "NOTIFICATION_TARGET") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "NOTIFICATION_TARGET", allowableValues = "NOTIFICATION_TARGET") @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_TARGET; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTemplateId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTemplateId.java index 65f312b7e0..9fb99a578e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTemplateId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTemplateId.java @@ -29,7 +29,7 @@ public class NotificationTemplateId extends UUIDBased implements EntityId { super(id); } - @Schema(required = true, description = "string", example = "NOTIFICATION_TEMPLATE", allowableValues = "NOTIFICATION_TEMPLATE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "NOTIFICATION_TEMPLATE", allowableValues = "NOTIFICATION_TEMPLATE") @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_TEMPLATE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java index 3225a72f6f..bc042883ce 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java @@ -35,7 +35,7 @@ public class OtaPackageId extends UUIDBased implements EntityId { return new OtaPackageId(UUID.fromString(firmwareId)); } - @Schema(required = true, description = "string", example = "OTA_PACKAGE", allowableValues = "OTA_PACKAGE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "OTA_PACKAGE", allowableValues = "OTA_PACKAGE") @Override public EntityType getEntityType() { return EntityType.OTA_PACKAGE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueId.java index 7f415fc451..48987a0539 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueId.java @@ -35,9 +35,9 @@ public class QueueId extends UUIDBased implements EntityId { return new QueueId(UUID.fromString(queueId)); } - @Schema(required = true, description = "string", example = "QUEUE", allowableValues = "QUEUE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "QUEUE", allowableValues = "QUEUE") @Override public EntityType getEntityType() { return EntityType.QUEUE; } -} \ No newline at end of file +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueStatsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueStatsId.java index ae5a4843ed..dd36f09313 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueStatsId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueStatsId.java @@ -35,9 +35,9 @@ public class QueueStatsId extends UUIDBased implements EntityId { return new QueueStatsId(UUID.fromString(queueId)); } - @Schema(required = true, description = "string", example = "QUEUE_STATS", allowableValues = "QUEUE_STATS") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "QUEUE_STATS", allowableValues = "QUEUE_STATS") @Override public EntityType getEntityType() { return EntityType.QUEUE_STATS; } -} \ No newline at end of file +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/RpcId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/RpcId.java index b544971d98..c6bf1aff87 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/RpcId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/RpcId.java @@ -31,7 +31,7 @@ public final class RpcId extends UUIDBased implements EntityId { super(id); } - @Schema(required = true, description = "string", example = "RPC", allowableValues = "RPC") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "RPC", allowableValues = "RPC") @Override public EntityType getEntityType() { return EntityType.RPC; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleChainId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleChainId.java index 379d1f608e..1557b053c2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleChainId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleChainId.java @@ -29,7 +29,7 @@ public class RuleChainId extends UUIDBased implements EntityId { super(id); } - @Schema(required = true, description = "string", example = "RULE_CHAIN", allowableValues = "RULE_CHAIN") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "RULE_CHAIN", allowableValues = "RULE_CHAIN") @Override public EntityType getEntityType() { return EntityType.RULE_CHAIN; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleNodeId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleNodeId.java index b0c1d1c8e5..945c99a89a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleNodeId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleNodeId.java @@ -29,7 +29,7 @@ public class RuleNodeId extends UUIDBased implements EntityId { super(id); } - @Schema(required = true, description = "string", example = "RULE_NODE", allowableValues = "RULE_NODE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "RULE_NODE", allowableValues = "RULE_NODE") @Override public EntityType getEntityType() { return EntityType.RULE_NODE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/TbResourceId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/TbResourceId.java index 28d22d04f5..a2ef70b75b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/TbResourceId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/TbResourceId.java @@ -31,7 +31,7 @@ public class TbResourceId extends UUIDBased implements EntityId { super(id); } - @Schema(required = true, description = "string", example = "TB_RESOURCE", allowableValues = "TB_RESOURCE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "TB_RESOURCE", allowableValues = "TB_RESOURCE") @Override public EntityType getEntityType() { return EntityType.TB_RESOURCE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java index aead099818..e160eb59d0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java @@ -50,7 +50,7 @@ public final class TenantId extends UUIDBased implements EntityId { return this.equals(SYS_TENANT_ID); } - @Schema(required = true, description = "string", example = "TENANT", allowableValues = "TENANT") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "TENANT", allowableValues = "TENANT") @Override public EntityType getEntityType() { return EntityType.TENANT; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantProfileId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantProfileId.java index ea2aaa1ac1..7b40eb01ed 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantProfileId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantProfileId.java @@ -35,7 +35,7 @@ public class TenantProfileId extends UUIDBased implements EntityId { return new TenantProfileId(UUID.fromString(tenantProfileId)); } - @Schema(required = true, description = "string", example = "TENANT_PROFILE", allowableValues = "TENANT_PROFILE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "TENANT_PROFILE", allowableValues = "TENANT_PROFILE") @Override public EntityType getEntityType() { return EntityType.TENANT_PROFILE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/UUIDBased.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/UUIDBased.java index b9fd9d30ee..e0a1abf9ff 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/UUIDBased.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/UUIDBased.java @@ -36,7 +36,7 @@ public abstract class UUIDBased implements HasUUID, Serializable { this.id = id; } - @Schema(required = true, description = "string", example = "784f394c-42b6-435a-983c-b7beff2784f9") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "784f394c-42b6-435a-983c-b7beff2784f9") public UUID getId() { return id; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetTypeId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetTypeId.java index dfa1934499..76a631d4fb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetTypeId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetTypeId.java @@ -31,7 +31,7 @@ public final class WidgetTypeId extends UUIDBased implements EntityId { super(id); } - @Schema(required = true, description = "string", example = "WIDGET_TYPE", allowableValues = "WIDGET_TYPE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "WIDGET_TYPE", allowableValues = "WIDGET_TYPE") @Override public EntityType getEntityType() { return EntityType.WIDGET_TYPE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetsBundleId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetsBundleId.java index eaa679bc6a..fd9d3e9db7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetsBundleId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetsBundleId.java @@ -31,7 +31,7 @@ public final class WidgetsBundleId extends UUIDBased implements EntityId { super(id); } - @Schema(required = true, description = "string", example = "WIDGETS_BUNDLE", allowableValues = "WIDGETS_BUNDLE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "WIDGETS_BUNDLE", allowableValues = "WIDGETS_BUNDLE") @Override public EntityType getEntityType() { return EntityType.WIDGETS_BUNDLE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/Notification.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/Notification.java index 0f7095c755..6335cb671d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/Notification.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/Notification.java @@ -36,13 +36,12 @@ public class Notification extends BaseData { private NotificationRequestId requestId; private UserId recipientId; - private NotificationType type; + private NotificationDeliveryMethod deliveryMethod; private String subject; private String text; private JsonNode additionalConfig; private NotificationInfo info; - private NotificationStatus status; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2BasicMapperConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2BasicMapperConfig.java index 3d419caea8..926e063f8d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2BasicMapperConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2BasicMapperConfig.java @@ -38,7 +38,7 @@ public class OAuth2BasicMapperConfig { @Length(fieldName = "lastNameAttributeKey", max = 31) @Schema(description = "Last name attribute key") private final String lastNameAttributeKey; - @Schema(description = "Tenant naming strategy. For DOMAIN type, domain for tenant name will be taken from the email (substring before '@')", required = true) + @Schema(description = "Tenant naming strategy. For DOMAIN type, domain for tenant name will be taken from the email (substring before '@')", requiredMode = Schema.RequiredMode.REQUIRED) private final TenantNameStrategyType tenantNameStrategy; @Length(fieldName = "tenantNamePattern") @Schema(description = "Tenant name pattern for CUSTOM naming strategy. " + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2ClientRegistrationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2ClientRegistrationTemplate.java index 0f9f580264..aa15466d0e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2ClientRegistrationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2ClientRegistrationTemplate.java @@ -36,7 +36,7 @@ import java.util.List; public class OAuth2ClientRegistrationTemplate extends BaseDataWithAdditionalInfo implements HasName { @Length(fieldName = "providerId") - @Schema(description = "OAuth2 provider identifier (e.g. its name)", required = true) + @Schema(description = "OAuth2 provider identifier (e.g. its name)", requiredMode = Schema.RequiredMode.REQUIRED) private String providerId; @Valid @Schema(description = "Default config for mapping OAuth2 log in response to platform entities") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2DomainInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2DomainInfo.java index 9ddc3c8492..dc70213e95 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2DomainInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2DomainInfo.java @@ -31,8 +31,8 @@ import lombok.ToString; @Builder @Schema public class OAuth2DomainInfo { - @Schema(description = "Domain scheme. Mixed scheme means than both HTTP and HTTPS are going to be used", required = true) + @Schema(description = "Domain scheme. Mixed scheme means than both HTTP and HTTPS are going to be used", requiredMode = Schema.RequiredMode.REQUIRED) private SchemeType scheme; - @Schema(description = "Domain name. Cannot be empty", required = true) + @Schema(description = "Domain name. Cannot be empty", requiredMode = Schema.RequiredMode.REQUIRED) private String name; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Info.java b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Info.java index 034070f57c..0a2ace050c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Info.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Info.java @@ -37,6 +37,6 @@ public class OAuth2Info { private boolean enabled; @Schema(description = "Whether OAuth2 settings are enabled on Edge or not") private boolean edgeEnabled; - @Schema(description = "List of configured OAuth2 clients. Cannot contain null values", required = true) + @Schema(description = "List of configured OAuth2 clients. Cannot contain null values", requiredMode = Schema.RequiredMode.REQUIRED) private List oauth2ParamsInfos; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2MapperConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2MapperConfig.java index ad39cdad04..be13a032e2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2MapperConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2MapperConfig.java @@ -32,7 +32,7 @@ public class OAuth2MapperConfig { private boolean allowUserCreation; @Schema(description = "Whether user credentials should be activated when user is created after successful authentication") private boolean activateUser; - @Schema(description = "Type of OAuth2 mapper. Depending on this param, different mapper config fields must be specified", required = true) + @Schema(description = "Type of OAuth2 mapper. Depending on this param, different mapper config fields must be specified", requiredMode = Schema.RequiredMode.REQUIRED) private MapperType type; @Valid @Schema(description = "Mapper config for BASIC and GITHUB mapper types") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2MobileInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2MobileInfo.java index 8aaffb80aa..053762bdf0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2MobileInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2MobileInfo.java @@ -31,8 +31,8 @@ import lombok.ToString; @Builder @Schema public class OAuth2MobileInfo { - @Schema(description = "Application package name. Cannot be empty", required = true) + @Schema(description = "Application package name. Cannot be empty", requiredMode = Schema.RequiredMode.REQUIRED) private String pkgName; - @Schema(description = "Application secret. The length must be at least 16 characters", required = true) + @Schema(description = "Application secret. The length must be at least 16 characters", requiredMode = Schema.RequiredMode.REQUIRED) private String appSecret; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2ParamsInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2ParamsInfo.java index 76dca282cf..c1b6124b8f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2ParamsInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2ParamsInfo.java @@ -36,11 +36,11 @@ public class OAuth2ParamsInfo { @Schema(description = "List of configured domains where OAuth2 platform will redirect a user after successful " + "authentication. Cannot be empty. There have to be only one domain with specific name with scheme type 'MIXED'. " + - "Configured domains with the same name must have different scheme types", required = true) + "Configured domains with the same name must have different scheme types", requiredMode = Schema.RequiredMode.REQUIRED) private List domainInfos; - @Schema(description = "Mobile applications settings. Application package name must be unique within the list", required = true) + @Schema(description = "Mobile applications settings. Application package name must be unique within the list", requiredMode = Schema.RequiredMode.REQUIRED) private List mobileInfos; - @Schema(description = "List of OAuth2 provider settings. Cannot be empty", required = true) + @Schema(description = "List of OAuth2 provider settings. Cannot be empty", requiredMode = Schema.RequiredMode.REQUIRED) private List clientRegistrations; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2RegistrationInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2RegistrationInfo.java index 1773583ffc..36bae12b23 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2RegistrationInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2RegistrationInfo.java @@ -34,17 +34,17 @@ import java.util.List; @Builder @Schema public class OAuth2RegistrationInfo { - @Schema(description = "Config for mapping OAuth2 log in response to platform entities", required = true) + @Schema(description = "Config for mapping OAuth2 log in response to platform entities", requiredMode = Schema.RequiredMode.REQUIRED) private OAuth2MapperConfig mapperConfig; - @Schema(description = "OAuth2 client ID. Cannot be empty", required = true) + @Schema(description = "OAuth2 client ID. Cannot be empty", requiredMode = Schema.RequiredMode.REQUIRED) private String clientId; - @Schema(description = "OAuth2 client secret. Cannot be empty", required = true) + @Schema(description = "OAuth2 client secret. Cannot be empty", requiredMode = Schema.RequiredMode.REQUIRED) private String clientSecret; - @Schema(description = "Authorization URI of the OAuth2 provider. Cannot be empty", required = true) + @Schema(description = "Authorization URI of the OAuth2 provider. Cannot be empty", requiredMode = Schema.RequiredMode.REQUIRED) private String authorizationUri; - @Schema(description = "Access token URI of the OAuth2 provider. Cannot be empty", required = true) + @Schema(description = "Access token URI of the OAuth2 provider. Cannot be empty", requiredMode = Schema.RequiredMode.REQUIRED) private String accessTokenUri; - @Schema(description = "OAuth scopes that will be requested from OAuth2 platform. Cannot be empty", required = true) + @Schema(description = "OAuth scopes that will be requested from OAuth2 platform. Cannot be empty", requiredMode = Schema.RequiredMode.REQUIRED) private List scope; @Schema(description = "User info URI of the OAuth2 provider") private String userInfoUri; @@ -52,14 +52,14 @@ public class OAuth2RegistrationInfo { private String userNameAttributeName; @Schema(description = "JSON Web Key URI of the OAuth2 provider") private String jwkSetUri; - @Schema(description = "Client authentication method to use: 'BASIC' or 'POST'. Cannot be empty", required = true) + @Schema(description = "Client authentication method to use: 'BASIC' or 'POST'. Cannot be empty", requiredMode = Schema.RequiredMode.REQUIRED) private String clientAuthenticationMethod; - @Schema(description = "OAuth2 provider label. Cannot be empty", required = true) + @Schema(description = "OAuth2 provider label. Cannot be empty", requiredMode = Schema.RequiredMode.REQUIRED) private String loginButtonLabel; @Schema(description = "Log in button icon for OAuth2 provider") private String loginButtonIcon; @Schema(description = "List of platforms for which usage of the OAuth2 client is allowed (empty for all allowed)") private List platforms; - @Schema(description = "Additional info of OAuth2 client (e.g. providerName)", required = true) + @Schema(description = "Additional info of OAuth2 client (e.g. providerName)", requiredMode = Schema.RequiredMode.REQUIRED) private JsonNode additionalInfo; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java index a6f504577b..309385d5e4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java @@ -31,11 +31,11 @@ import java.util.List; @NoArgsConstructor public class AttributesEntityView implements Serializable { - @Schema(required = true, description = "List of client-side attribute keys to expose", example = "currentConfiguration") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of client-side attribute keys to expose", example = "currentConfiguration") private List cs = new ArrayList<>(); - @Schema(required = true, description = "List of server-side attribute keys to expose", example = "model") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of server-side attribute keys to expose", example = "model") private List ss = new ArrayList<>(); - @Schema(required = true, description = "List of shared attribute keys to expose", example = "targetConfiguration") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of shared attribute keys to expose", example = "targetConfiguration") private List sh = new ArrayList<>(); public AttributesEntityView(List cs, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java index 609d185f9c..a2484b0707 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java @@ -31,9 +31,9 @@ import java.util.List; @NoArgsConstructor public class TelemetryEntityView implements Serializable { - @Schema(required = true, description = "List of time-series data keys to expose", example = "temperature, humidity") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of time-series data keys to expose", example = "temperature, humidity") private List timeseries; - @Schema(required = true, description = "JSON object with attributes to expose") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with attributes to expose") private AttributesEntityView attributes; public TelemetryEntityView(List timeseries, AttributesEntityView attributes) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/DefaultRuleChainCreateRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/DefaultRuleChainCreateRequest.java index d984fd244d..cda3072101 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/DefaultRuleChainCreateRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/DefaultRuleChainCreateRequest.java @@ -28,7 +28,7 @@ public class DefaultRuleChainCreateRequest implements Serializable { private static final long serialVersionUID = 5600333716030561537L; - @Schema(required = true, description = "Name of the new rule chain", example = "Root Rule Chain") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Name of the new rule chain", example = "Root Rule Chain") private String name; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/NodeConnectionInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/NodeConnectionInfo.java index 82be10c121..fe80293d16 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/NodeConnectionInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/NodeConnectionInfo.java @@ -24,10 +24,10 @@ import lombok.Data; @Schema @Data public class NodeConnectionInfo { - @Schema(required = true, description = "Index of rule node in the 'nodes' array of the RuleChainMetaData. Indicates the 'from' part of the connection.") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Index of rule node in the 'nodes' array of the RuleChainMetaData. Indicates the 'from' part of the connection.") private int fromIndex; - @Schema(required = true, description = "Index of rule node in the 'nodes' array of the RuleChainMetaData. Indicates the 'to' part of the connection.") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Index of rule node in the 'nodes' array of the RuleChainMetaData. Indicates the 'to' part of the connection.") private int toIndex; - @Schema(required = true, description = "Type of the relation. Typically indicated the result of processing by the 'from' rule node. For example, 'Success' or 'Failure'") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Type of the relation. Typically indicated the result of processing by the 'from' rule node. For example, 'Success' or 'Failure'") private String type; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java index be901a9176..c293e6f570 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java @@ -39,11 +39,11 @@ public class RuleChain extends BaseDataWithAdditionalInfo implement private static final long serialVersionUID = -5656679015121935465L; - @Schema(required = true, description = "JSON object with Tenant Id.", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with Tenant Id.", accessMode = Schema.AccessMode.READ_ONLY) private TenantId tenantId; @NoXss @Length(fieldName = "name") - @Schema(required = true, description = "Rule Chain name", example = "Humidity data processing") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Rule Chain name", example = "Humidity data processing") private String name; @Schema(description = "Rule Chain type. 'EDGE' rule chains are processing messages on the edge devices only.", example = "A4B72CCDFF33") private RuleChainType type; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainConnectionInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainConnectionInfo.java index 654f296c63..029cfb9976 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainConnectionInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainConnectionInfo.java @@ -26,12 +26,12 @@ import org.thingsboard.server.common.data.id.RuleChainId; @Schema @Data public class RuleChainConnectionInfo { - @Schema(required = true, description = "Index of rule node in the 'nodes' array of the RuleChainMetaData. Indicates the 'from' part of the connection.") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Index of rule node in the 'nodes' array of the RuleChainMetaData. Indicates the 'from' part of the connection.") private int fromIndex; - @Schema(required = true, description = "JSON object with the Rule Chain Id.") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with the Rule Chain Id.") private RuleChainId targetRuleChainId; - @Schema(required = true, description = "JSON object with the additional information about the connection.") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with the additional information about the connection.") private JsonNode additionalInfo; - @Schema(required = true, description = "Type of the relation. Typically indicated the result of processing by the 'from' rule node. For example, 'Success' or 'Failure'") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Type of the relation. Typically indicated the result of processing by the 'from' rule node. For example, 'Success' or 'Failure'") private String type; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainData.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainData.java index e9899d6019..e75d830e84 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainData.java @@ -24,8 +24,8 @@ import java.util.List; @Data public class RuleChainData { - @Schema(required = true, description = "List of the Rule Chain objects.", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of the Rule Chain objects.", accessMode = Schema.AccessMode.READ_ONLY) List ruleChains; - @Schema(required = true, description = "List of the Rule Chain metadata objects.", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of the Rule Chain metadata objects.", accessMode = Schema.AccessMode.READ_ONLY) List metadata; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java index 9a7bc70a7f..eb18e28c8c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java @@ -29,19 +29,19 @@ import java.util.List; @Data public class RuleChainMetaData { - @Schema(required = true, description = "JSON object with Rule Chain Id.", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with Rule Chain Id.", accessMode = Schema.AccessMode.READ_ONLY) private RuleChainId ruleChainId; - @Schema(required = true, description = "Index of the first rule node in the 'nodes' list") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Index of the first rule node in the 'nodes' list") private Integer firstNodeIndex; - @Schema(required = true, description = "List of rule node JSON objects") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of rule node JSON objects") private List nodes; - @Schema(required = true, description = "List of JSON objects that represent connections between rule nodes") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of JSON objects that represent connections between rule nodes") private List connections; - @Schema(required = true, description = "List of JSON objects that represent connections between rule nodes and other rule chains.") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of JSON objects that represent connections between rule nodes and other rule chains.") private List ruleChainConnections; public void addConnectionInfo(int fromIndex, int toIndex, String type) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainOutputLabelsUsage.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainOutputLabelsUsage.java index d0c7186501..b9fb1fc6d8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainOutputLabelsUsage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainOutputLabelsUsage.java @@ -28,16 +28,16 @@ import java.util.Set; @Slf4j public class RuleChainOutputLabelsUsage { - @Schema(required = true, description = "Rule Chain Id", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Rule Chain Id", accessMode = Schema.AccessMode.READ_ONLY) private RuleChainId ruleChainId; - @Schema(required = true, description = "Rule Node Id", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Rule Node Id", accessMode = Schema.AccessMode.READ_ONLY) private RuleNodeId ruleNodeId; - @Schema(required = true, description = "Rule Chain Name", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Rule Chain Name", accessMode = Schema.AccessMode.READ_ONLY) private String ruleChainName; - @Schema(required = true, description = "Rule Node Name", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Rule Node Name", accessMode = Schema.AccessMode.READ_ONLY) private String ruleNodeName; - @Schema(required = true, description = "Output labels", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Output labels", accessMode = Schema.AccessMode.READ_ONLY) private Set labels; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java index f59b86af63..24d8cc1175 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java @@ -47,7 +47,7 @@ public class DeviceCredentials extends BaseData implements this.credentialsValue = deviceCredentials.getCredentialsValue(); } - @Schema(required = true, accessMode = Schema.AccessMode.READ_ONLY, description = "The Id is automatically generated during device creation. " + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "The Id is automatically generated during device creation. " + "Use 'getDeviceCredentialsByDeviceId' to obtain the id based on device id. " + "Use 'updateDeviceCredentials' to update device credentials. ", example = "784f394c-42b6-435a-983c-b7beff2784f9") @Override @@ -61,7 +61,7 @@ public class DeviceCredentials extends BaseData implements return super.getCreatedTime(); } - @Schema(required = true, description = "JSON object with the device Id.") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with the device Id.") public DeviceId getDeviceId() { return deviceId; } @@ -80,7 +80,7 @@ public class DeviceCredentials extends BaseData implements this.credentialsType = credentialsType; } - @Schema(required = true, description = "Unique Credentials Id per platform instance. " + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Unique Credentials Id per platform instance. " + "Used to lookup credentials from the database. " + "By default, new access token for your device. " + "Depends on the type of the credentials." diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java index 8ed16a1c1c..5fc0c14d61 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java @@ -20,27 +20,27 @@ import lombok.Data; @Data public class SmppSmsProviderConfiguration implements SmsProviderConfiguration { - @Schema(description = "SMPP version", allowableValues = "3.3, 3.4", required = true) + @Schema(description = "SMPP version", allowableValues = "3.3, 3.4", requiredMode = Schema.RequiredMode.REQUIRED) private String protocolVersion; - @Schema(description = "SMPP host", required = true) + @Schema(description = "SMPP host", requiredMode = Schema.RequiredMode.REQUIRED) private String host; - @Schema(description = "SMPP port", required = true) + @Schema(description = "SMPP port", requiredMode = Schema.RequiredMode.REQUIRED) private Integer port; - @Schema(description = "System ID", required = true) + @Schema(description = "System ID", requiredMode = Schema.RequiredMode.REQUIRED) private String systemId; - @Schema(description = "Password", required = true) + @Schema(description = "Password", requiredMode = Schema.RequiredMode.REQUIRED) private String password; - @Schema(description = "System type", required = false) + @Schema(description = "System type", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String systemType; - @Schema(description = "TX - Transmitter, RX - Receiver, TRX - Transciever. By default TX is used", required = false) + @Schema(description = "TX - Transmitter, RX - Receiver, TRX - Transciever. By default TX is used", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private SmppBindType bindType; - @Schema(description = "Service type", required = false) + @Schema(description = "Service type", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String serviceType; - @Schema(description = "Source address", required = false) + @Schema(description = "Source address", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String sourceAddress; @Schema(description = "Source TON (Type of Number). Needed is source address is set. 5 by default.\n" + "0 - Unknown\n" + @@ -49,7 +49,7 @@ public class SmppSmsProviderConfiguration implements SmsProviderConfiguration { "3 - Network Specific\n" + "4 - Subscriber Number\n" + "5 - Alphanumeric\n" + - "6 - Abbreviated", required = false) + "6 - Abbreviated", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private Byte sourceTon; @Schema(description = "Source NPI (Numbering Plan Identification). Needed is source address is set. 0 by default.\n" + "0 - Unknown\n" + @@ -61,7 +61,7 @@ public class SmppSmsProviderConfiguration implements SmsProviderConfiguration { "9 - Private numbering plan\n" + "10 - ERMES numbering plan (ETSI DE/PS 3 01-3)\n" + "13 - Internet (IP)\n" + - "18 - WAP Client Id (to be defined by WAP Forum)", required = false) + "18 - WAP Client Id (to be defined by WAP Forum)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private Byte sourceNpi; @Schema(description = "Destination TON (Type of Number). 5 by default.\n" + @@ -71,7 +71,7 @@ public class SmppSmsProviderConfiguration implements SmsProviderConfiguration { "3 - Network Specific\n" + "4 - Subscriber Number\n" + "5 - Alphanumeric\n" + - "6 - Abbreviated", required = false) + "6 - Abbreviated", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private Byte destinationTon; @Schema(description = "Destination NPI (Numbering Plan Identification). 0 by default.\n" + "0 - Unknown\n" + @@ -83,10 +83,10 @@ public class SmppSmsProviderConfiguration implements SmsProviderConfiguration { "9 - Private numbering plan\n" + "10 - ERMES numbering plan (ETSI DE/PS 3 01-3)\n" + "13 - Internet (IP)\n" + - "18 - WAP Client Id (to be defined by WAP Forum)", required = false) + "18 - WAP Client Id (to be defined by WAP Forum)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private Byte destinationNpi; - @Schema(description = "Address range", required = false) + @Schema(description = "Address range", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String addressRange; @Schema(allowableValues = "0-10,13-14", @@ -102,7 +102,7 @@ public class SmppSmsProviderConfiguration implements SmsProviderConfiguration { "9 - Pictogram Encoding\n" + "10 - Music Codes (ISO-2022-JP)\n" + "13 - Extended Kanji JIS (X 0212-1990)\n" + - "14 - Korean Graphic Character Set (KS C 5601/KS X 1001)", required = false) + "14 - Korean Graphic Character Set (KS C 5601/KS X 1001)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private Byte codingScheme; @Override 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 3c4a1e6be9..23bfe55f1b 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 @@ -111,7 +111,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { .setConnectRequestMsg(ConnectRequestMsg.newBuilder() .setEdgeRoutingKey(edgeKey) .setEdgeSecret(edgeSecret) - .setEdgeVersion(EdgeVersion.V_3_6_2) + .setEdgeVersion(EdgeVersion.V_3_7_0) .setMaxInboundMessageSize(maxInboundMessageSize) .build()) .build()); @@ -248,4 +248,5 @@ public class EdgeGrpcClient implements EdgeRpcClient { uplinkMsgLock.unlock(); } } + } diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index 58959e4d19..3096c7b08a 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -37,7 +37,8 @@ enum EdgeVersion { V_3_6_0 = 3; V_3_6_1 = 4; V_3_6_2 = 5; - V_3_7_0 = 6; + V_3_6_4 = 6; + V_3_7_0 = 7; } /** diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java index 38f84bb4a1..e89ce5634c 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java @@ -258,31 +258,31 @@ public class ProtoUtils { .setLastUpdateTs(attributeKvEntry.getLastUpdateTs()) .setKey(attributeKvEntry.getKey()); switch (attributeKvEntry.getDataType()) { - case BOOLEAN: + case BOOLEAN -> { attributeKvEntry.getBooleanValue().ifPresent(attributeValueBuilder::setBoolV); attributeValueBuilder.setHasV(attributeKvEntry.getBooleanValue().isPresent()); attributeValueBuilder.setType(TransportProtos.KeyValueType.BOOLEAN_V); - break; - case STRING: + } + case STRING -> { attributeKvEntry.getStrValue().ifPresent(attributeValueBuilder::setStringV); attributeValueBuilder.setHasV(attributeKvEntry.getStrValue().isPresent()); attributeValueBuilder.setType(TransportProtos.KeyValueType.STRING_V); - break; - case DOUBLE: + } + case DOUBLE -> { attributeKvEntry.getDoubleValue().ifPresent(attributeValueBuilder::setDoubleV); attributeValueBuilder.setHasV(attributeKvEntry.getDoubleValue().isPresent()); attributeValueBuilder.setType(TransportProtos.KeyValueType.DOUBLE_V); - break; - case LONG: + } + case LONG -> { attributeKvEntry.getLongValue().ifPresent(attributeValueBuilder::setLongV); attributeValueBuilder.setHasV(attributeKvEntry.getLongValue().isPresent()); attributeValueBuilder.setType(TransportProtos.KeyValueType.LONG_V); - break; - case JSON: + } + case JSON -> { attributeKvEntry.getJsonValue().ifPresent(attributeValueBuilder::setJsonV); attributeValueBuilder.setHasV(attributeKvEntry.getJsonValue().isPresent()); attributeValueBuilder.setType(TransportProtos.KeyValueType.JSON_V); - break; + } } builder.addValues(attributeValueBuilder.build()); } diff --git a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java index 4190c6bbc9..6817ec71f0 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java @@ -30,6 +30,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.google.common.collect.Lists; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.kv.DataType; import org.thingsboard.server.common.data.kv.KvEntry; @@ -50,6 +51,7 @@ import java.util.regex.Pattern; /** * Created by Valerii Sosliuk on 5/12/2017. */ +@Slf4j public class JacksonUtil { public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @@ -158,6 +160,20 @@ public class JacksonUtil { } } + public static String toPlainText(String data) { + if (data == null) { + return null; + } + if (data.startsWith("\"") && data.endsWith("\"") && data.length() >= 2) { + final String dataBefore = data; + try { + data = JacksonUtil.fromString(data, String.class); + } catch (Exception ignored) {} + log.trace("Trimming double quotes. Before trim: [{}], after trim: [{}]", dataBefore, data); + } + return data; + } + public static T treeToValue(JsonNode node, Class clazz) { try { return OBJECT_MAPPER.treeToValue(node, clazz); @@ -239,6 +255,10 @@ public class JacksonUtil { return node; } + public static ObjectNode asObject(JsonNode node) { + return node != null && node.isObject() ? ((ObjectNode) node) : newObjectNode(); + } + public static void replaceUuidsRecursively(JsonNode node, Set skippedRootFields, Pattern includedFieldsPattern, UnaryOperator replacer, boolean root) { if (node == null) { return; diff --git a/common/util/src/test/java/org/thingsboard/common/util/JacksonUtilTest.java b/common/util/src/test/java/org/thingsboard/common/util/JacksonUtilTest.java index 7e37d34509..dcddc6e07a 100644 --- a/common/util/src/test/java/org/thingsboard/common/util/JacksonUtilTest.java +++ b/common/util/src/test/java/org/thingsboard/common/util/JacksonUtilTest.java @@ -19,6 +19,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Assert; import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.id.AssetId; @@ -55,4 +58,15 @@ public class JacksonUtilTest { Assert.assertEquals(asset.getName(), result.getName()); Assert.assertEquals(asset.getType(), result.getType()); } + + @ParameterizedTest + @ValueSource(strings = { "", "false", "\"", "\"\"", "\"This is a string with double quotes\"", "Path: /home/developer/test.txt", + "First line\nSecond line\n\nFourth line", "Before\rAfter", "Tab\tSeparated\tValues", "Test\bbackspace", "[]", + "[1, 2, 3]", "{\"key\": \"value\"}", "{\n\"temperature\": 25.5,\n\"humidity\": 50.2\n\"}", "Expression: (a + b) * c", + "世界", "Україна", "\u1F1FA\u1F1E6", "🇺🇦"}) + public void toPlainTextTest(String original) { + String serialized = JacksonUtil.toString(original); + Assertions.assertNotNull(serialized); + Assertions.assertEquals(original, JacksonUtil.toPlainText(serialized)); + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java index d5b7955d85..466a591394 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java @@ -32,7 +32,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; @@ -366,7 +366,7 @@ public class EdgeServiceImpl extends AbstractCachedEntityService, List>() { + edges = Futures.transform(edges, new Function<>() { @Nullable @Override public List apply(@Nullable List edgeList) { @@ -426,7 +426,7 @@ public class EdgeServiceImpl extends AbstractCachedEntityService tenantEdgesRemover = - new PaginatedRemover() { + new PaginatedRemover<>() { @Override protected PageData findEntities(TenantId tenantId, TenantId id, PageLink pageLink) { @@ -439,7 +439,7 @@ public class EdgeServiceImpl extends AbstractCachedEntityService customerEdgeUnassigner = new PaginatedRemover() { + private PaginatedRemover customerEdgeUnassigner = new PaginatedRemover<>() { @Override protected PageData findEntities(TenantId tenantId, CustomerId id, PageLink pageLink) { @@ -525,7 +525,7 @@ public class EdgeServiceImpl extends AbstractCachedEntityService edgeRuleChains = findEdgeRuleChains(tenantId, edgeId); - List edgeRuleChainIds = edgeRuleChains.stream().map(IdBased::getId).collect(Collectors.toList()); + List edgeRuleChainIds = edgeRuleChains.stream().map(IdBased::getId).toList(); ObjectNode result = JacksonUtil.newObjectNode(); for (RuleChain edgeRuleChain : edgeRuleChains) { List ruleNodes = @@ -534,8 +534,7 @@ public class EdgeServiceImpl extends AbstractCachedEntityService connectedRuleChains = ruleNodes.stream() .filter(rn -> rn.getType().equals(tbRuleChainInputNodeClassName)) - .map(rn -> new RuleChainId(UUID.fromString(rn.getConfiguration().get("ruleChainId").asText()))) - .collect(Collectors.toList()); + .map(rn -> new RuleChainId(UUID.fromString(rn.getConfiguration().get("ruleChainId").asText()))).toList(); List missingRuleChains = new ArrayList<>(); for (RuleChainId connectedRuleChain : connectedRuleChains) { if (!edgeRuleChainIds.contains(connectedRuleChain)) { @@ -561,7 +560,7 @@ public class EdgeServiceImpl extends AbstractCachedEntityService Futures.immediateFuture(kvEntryOpt.flatMap(KvEntry::getBooleanValue).orElse(false)), MoreExecutors.directExecutor()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java index 5edb59f5c8..3cdcf5e874 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java @@ -40,11 +40,16 @@ import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityFilterType; import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.EntityListFilter; +import org.thingsboard.server.common.data.query.KeyFilter; import org.thingsboard.server.common.data.query.RelationsQueryFilter; import org.thingsboard.server.dao.exception.IncorrectParameterException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -63,6 +68,10 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe public static final String INCORRECT_CUSTOMER_ID = "Incorrect customerId "; public static final CustomerId NULL_CUSTOMER_ID = new CustomerId(NULL_UUID); + private static final int MAX_ENTITY_IDS_SIZE = 1024; + private static final Set EXCLUDED_TYPES_FROM_OPTIMIZATION = Set.of( + EntityFilterType.ENTITY_LIST, EntityFilterType.SINGLE_ENTITY, EntityFilterType.RELATIONS_QUERY); + @Autowired private EntityQueryDao entityQueryDao; @@ -86,9 +95,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe validateId(customerId, id -> INCORRECT_CUSTOMER_ID + id); validateEntityDataQuery(query); - if (EntityFilterType.RELATIONS_QUERY.equals(query.getEntityFilter().getType()) - || EntityFilterType.SINGLE_ENTITY.equals(query.getEntityFilter().getType()) - || StringUtils.isNotEmpty(query.getPageLink().getTextSearch())) { + if (!isValidForOptimization(query)) { return this.entityQueryDao.findEntityDataByQuery(tenantId, customerId, query); } @@ -97,41 +104,10 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe if (entityDataByQuery == null || entityDataByQuery.getData().isEmpty()) { return entityDataByQuery; } - // 2 step - find entity data by entity ids from the 1st step - PageData result = findEntityDataByEntityIds(tenantId, customerId, query, entityDataByQuery.getData()); - return new PageData<>(result.getData(), entityDataByQuery.getTotalPages(), entityDataByQuery.getTotalElements(), entityDataByQuery.hasNext()); - } - - private PageData findEntityIdsByFilterAndSorterColumns(TenantId tenantId, CustomerId customerId, EntityDataQuery query) { - List entityFields = null; - List latestValues = null; - if (query.getPageLink().getSortOrder() != null) { - if (query.getEntityFields() != null) { - entityFields = query.getEntityFields().stream() - .filter(entityKey -> entityKey.getKey().equals(query.getPageLink().getSortOrder().getKey().getKey())) - .collect(Collectors.toList()); - } - if (query.getLatestValues() != null) { - latestValues = query.getLatestValues().stream() - .filter(entityKey -> entityKey.getKey().equals(query.getPageLink().getSortOrder().getKey().getKey())) - .collect(Collectors.toList()); - } - } - EntityDataQuery entityQuery = new EntityDataQuery(query.getEntityFilter(), query.getPageLink(), entityFields, latestValues, query.getKeyFilters()); - return this.entityQueryDao.findEntityDataByQuery(tenantId, customerId, entityQuery); - } - - private PageData findEntityDataByEntityIds(TenantId tenantId, CustomerId customerId, EntityDataQuery query, List data) { - List entityIds = data.stream().map(d -> d.getEntityId().getId().toString()).toList(); - EntityType entityType = data.isEmpty() ? null : data.get(0).getEntityId().getEntityType(); - - EntityListFilter filter = new EntityListFilter(); - filter.setEntityType(entityType); - filter.setEntityList(entityIds); - EntityDataPageLink pageLink = new EntityDataPageLink(query.getPageLink().getPageSize(), 0, null, query.getPageLink().getSortOrder()); - EntityDataQuery entityQuery = new EntityDataQuery(filter, pageLink, query.getEntityFields(), query.getLatestValues(), null); - return this.entityQueryDao.findEntityDataByQuery(tenantId, customerId, entityQuery); + // 2 step - find entity data by entity ids from the 1st step + List result = fetchEntityDataByIdsFromInitialQuery(tenantId, customerId, query, entityDataByQuery.getData()); + return new PageData<>(result, entityDataByQuery.getTotalPages(), entityDataByQuery.getTotalElements(), entityDataByQuery.hasNext()); } @Override @@ -228,4 +204,75 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe } } + private boolean isValidForOptimization(EntityDataQuery query) { + if (StringUtils.isNotEmpty(query.getPageLink().getTextSearch())) { + return false; + } + + if (EXCLUDED_TYPES_FROM_OPTIMIZATION.contains(query.getEntityFilter().getType())) { + return false; + } + + if ((query.getEntityFields() == null || query.getEntityFields().isEmpty()) && + (query.getLatestValues() == null || query.getLatestValues().isEmpty())) { + return false; + } + + Set filteringKeys = new HashSet<>(Optional.ofNullable(query.getKeyFilters()).orElse(Collections.emptyList()).stream().map(KeyFilter::getKey).toList()); + Set entityFields = new HashSet<>(Optional.ofNullable(query.getEntityFields()).orElse(Collections.emptyList())); + Set latestValues = new HashSet<>(Optional.ofNullable(query.getLatestValues()).orElse(Collections.emptyList())); + + return !(filteringKeys.containsAll(entityFields) && filteringKeys.containsAll(latestValues)); + } + + private PageData findEntityIdsByFilterAndSorterColumns(TenantId tenantId, CustomerId customerId, EntityDataQuery query) { + List entityFields = null; + List latestValues = null; + if (query.getPageLink().getSortOrder() != null) { + if (query.getEntityFields() != null) { + entityFields = query.getEntityFields().stream() + .filter(entityKey -> entityKey.getKey().equals(query.getPageLink().getSortOrder().getKey().getKey())) + .collect(Collectors.toList()); + } + if (query.getLatestValues() != null) { + latestValues = query.getLatestValues().stream() + .filter(entityKey -> entityKey.getKey().equals(query.getPageLink().getSortOrder().getKey().getKey())) + .collect(Collectors.toList()); + } + } + EntityDataQuery entityQuery = new EntityDataQuery(query.getEntityFilter(), query.getPageLink(), entityFields, latestValues, query.getKeyFilters()); + return this.entityQueryDao.findEntityDataByQuery(tenantId, customerId, entityQuery); + } + + private List fetchEntityDataByIdsFromInitialQuery(TenantId tenantId, CustomerId customerId, EntityDataQuery query, List initialQueryResult) { + List result = new ArrayList<>(); + + List entityIds = initialQueryResult.stream().map(d -> d.getEntityId().getId().toString()).collect(Collectors.toList()); + EntityType entityType = initialQueryResult.get(0).getEntityId().getEntityType(); + + if (entityIds.size() > MAX_ENTITY_IDS_SIZE) { + List> chunks = new ArrayList<>(); + for (int i = 0; i < entityIds.size(); i += MAX_ENTITY_IDS_SIZE) { + chunks.add(entityIds.subList(i, Math.min(entityIds.size(), i + MAX_ENTITY_IDS_SIZE))); + } + for (List chunk : chunks) { + result.addAll(findEntityDataByEntityIds(tenantId, customerId, query, chunk, entityType, chunk.size())); + } + } else { + result.addAll(findEntityDataByEntityIds(tenantId, customerId, query, entityIds, entityType, query.getPageLink().getPageSize())); + } + return result; + } + + private List findEntityDataByEntityIds(TenantId tenantId, CustomerId customerId, EntityDataQuery query, + List entityIds, EntityType entityType, int pageSize) { + EntityListFilter filter = new EntityListFilter(); + filter.setEntityType(entityType); + filter.setEntityList(entityIds); + + EntityDataPageLink pageLink = new EntityDataPageLink(pageSize, 0, null, query.getPageLink().getSortOrder()); + EntityDataQuery entityQuery = new EntityDataQuery(filter, pageLink, query.getEntityFields(), query.getLatestValues(), null); + return this.entityQueryDao.findEntityDataByQuery(tenantId, customerId, entityQuery).getData(); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/event/BaseEventService.java b/dao/src/main/java/org/thingsboard/server/dao/event/BaseEventService.java index 88834eecbc..5314dcd405 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/event/BaseEventService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/event/BaseEventService.java @@ -37,6 +37,7 @@ import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.dao.service.DataValidator; import java.util.List; +import java.util.Optional; import java.util.function.BiConsumer; import java.util.function.Function; import java.util.stream.Collectors; @@ -106,6 +107,11 @@ public class BaseEventService implements EventService { return convert(entityId.getEntityType(), eventDao.findLatestEvents(tenantId.getId(), entityId.getId(), eventType, limit)); } + @Override + public EventInfo findLatestDebugRuleNodeInEvent(TenantId tenantId, EntityId entityId) { + return convert(entityId.getEntityType(), eventDao.findLatestDebugRuleNodeInEvent(tenantId.getId(), entityId.getId())); + } + @Override public PageData findEventsByFilter(TenantId tenantId, EntityId entityId, EventFilter eventFilter, TimePageLink pageLink) { return convert(entityId.getEntityType(), eventDao.findEventByFilter(tenantId.getId(), entityId.getId(), eventFilter, pageLink)); @@ -140,4 +146,8 @@ public class BaseEventService implements EventService { return list == null ? null : list.stream().map(e -> e.toInfo(entityType)).collect(Collectors.toList()); } + private EventInfo convert(EntityType entityType, Event event) { + return Optional.ofNullable(event).map(e -> e.toInfo(entityType)).orElse(null); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/event/EventDao.java b/dao/src/main/java/org/thingsboard/server/dao/event/EventDao.java index 2db5a7012d..6b3febf502 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/event/EventDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/event/EventDao.java @@ -62,6 +62,15 @@ public interface EventDao { */ List findLatestEvents(UUID tenantId, UUID entityId, EventType eventType, int limit); + /** + * Find latest debug IN event by tenantId, entityId. + * + * @param tenantId the tenantId + * @param entityId the entityId + * @return the latest debug IN event + */ + Event findLatestDebugRuleNodeInEvent(UUID tenantId, UUID entityId); + /** * Executes stored procedure to cleanup old events. Uses separate ttl for debug and other events. * @param regularEventExpTs the expiration time of the regular events diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index e1771c8a97..ae15c2d029 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -629,6 +629,7 @@ public class ModelConstants { public static final String NOTIFICATION_REQUEST_ID_PROPERTY = "request_id"; public static final String NOTIFICATION_RECIPIENT_ID_PROPERTY = "recipient_id"; public static final String NOTIFICATION_TYPE_PROPERTY = "type"; + public static final String NOTIFICATION_DELIVERY_METHOD_PROPERTY = "delivery_method"; public static final String NOTIFICATION_SUBJECT_PROPERTY = "subject"; public static final String NOTIFICATION_TEXT_PROPERTY = "body"; public static final String NOTIFICATION_ADDITIONAL_CONFIG_PROPERTY = "additional_config"; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/NotificationEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/NotificationEntity.java index 169af3b4c1..7d84f03e46 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/NotificationEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/NotificationEntity.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.id.NotificationId; import org.thingsboard.server.common.data.id.NotificationRequestId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.notification.Notification; +import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationStatus; import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.common.data.notification.info.NotificationInfo; @@ -54,6 +55,10 @@ public class NotificationEntity extends BaseSqlEntity { @Column(name = ModelConstants.NOTIFICATION_TYPE_PROPERTY, nullable = false) private NotificationType type; + @Enumerated(EnumType.STRING) + @Column(name = ModelConstants.NOTIFICATION_DELIVERY_METHOD_PROPERTY, nullable = false) + private NotificationDeliveryMethod deliveryMethod; + @Column(name = ModelConstants.NOTIFICATION_SUBJECT_PROPERTY) private String subject; @@ -80,6 +85,7 @@ public class NotificationEntity extends BaseSqlEntity { setRequestId(getUuid(notification.getRequestId())); setRecipientId(getUuid(notification.getRecipientId())); setType(notification.getType()); + setDeliveryMethod(notification.getDeliveryMethod()); setSubject(notification.getSubject()); setText(notification.getText()); setAdditionalConfig(notification.getAdditionalConfig()); @@ -95,6 +101,7 @@ public class NotificationEntity extends BaseSqlEntity { notification.setRequestId(getEntityId(requestId, NotificationRequestId::new)); notification.setRecipientId(getEntityId(recipientId, UserId::new)); notification.setType(type); + notification.setDeliveryMethod(deliveryMethod); notification.setSubject(subject); notification.setText(text); notification.setAdditionalConfig(additionalConfig); diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationService.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationService.java index b62d9a83ad..38a8cb4178 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationService.java @@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.id.NotificationId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.notification.Notification; +import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationStatus; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -57,29 +58,29 @@ public class DefaultNotificationService implements NotificationService, EntityDa } @Override - public int markAllNotificationsAsRead(TenantId tenantId, UserId recipientId) { - return notificationDao.updateStatusByRecipientId(tenantId, recipientId, NotificationStatus.READ); + public int markAllNotificationsAsRead(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId) { + return notificationDao.updateStatusByDeliveryMethodAndRecipientId(tenantId, deliveryMethod, recipientId, NotificationStatus.READ); } @Override - public PageData findNotificationsByRecipientIdAndReadStatus(TenantId tenantId, UserId recipientId, boolean unreadOnly, PageLink pageLink) { + public PageData findNotificationsByRecipientIdAndReadStatus(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, boolean unreadOnly, PageLink pageLink) { if (unreadOnly) { - return notificationDao.findUnreadByRecipientIdAndPageLink(tenantId, recipientId, pageLink); + return notificationDao.findUnreadByDeliveryMethodAndRecipientIdAndPageLink(tenantId, deliveryMethod, recipientId, pageLink); } else { - return notificationDao.findByRecipientIdAndPageLink(tenantId, recipientId, pageLink); + return notificationDao.findByDeliveryMethodAndRecipientIdAndPageLink(tenantId, deliveryMethod, recipientId, pageLink); } } @Override - public PageData findLatestUnreadNotificationsByRecipientId(TenantId tenantId, UserId recipientId, int limit) { + public PageData findLatestUnreadNotificationsByRecipientId(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, int limit) { SortOrder sortOrder = new SortOrder(EntityKeyMapping.CREATED_TIME, SortOrder.Direction.DESC); PageLink pageLink = new PageLink(limit, 0, null, sortOrder); - return findNotificationsByRecipientIdAndReadStatus(tenantId, recipientId, true, pageLink); + return findNotificationsByRecipientIdAndReadStatus(tenantId, deliveryMethod, recipientId, true, pageLink); } @Override - public int countUnreadNotificationsByRecipientId(TenantId tenantId, UserId recipientId) { - return notificationDao.countUnreadByRecipientId(tenantId, recipientId); + public int countUnreadNotificationsByRecipientId(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId) { + return notificationDao.countUnreadByDeliveryMethodAndRecipientId(tenantId, deliveryMethod, recipientId); } @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 012d220174..10438c484a 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 @@ -20,7 +20,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; -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; @@ -157,7 +156,7 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS return new UserNotificationSettings(prefs); } - @Transactional(propagation = Propagation.NOT_SUPPORTED) // so that parent transaction is not aborted on method failure + @Transactional @Override public void createDefaultNotificationConfigs(TenantId tenantId) { NotificationTarget allUsers = createTarget(tenantId, "All users", new AllUsersFilter(), diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationDao.java b/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationDao.java index 02bfe11fde..2233840058 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationDao.java @@ -20,6 +20,7 @@ import org.thingsboard.server.common.data.id.NotificationRequestId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.notification.Notification; +import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationStatus; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -27,19 +28,17 @@ import org.thingsboard.server.dao.Dao; public interface NotificationDao extends Dao { - PageData findUnreadByRecipientIdAndPageLink(TenantId tenantId, UserId recipientId, PageLink pageLink); + PageData findUnreadByDeliveryMethodAndRecipientIdAndPageLink(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, PageLink pageLink); - PageData findByRecipientIdAndPageLink(TenantId tenantId, UserId recipientId, PageLink pageLink); + PageData findByDeliveryMethodAndRecipientIdAndPageLink(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, PageLink pageLink); boolean updateStatusByIdAndRecipientId(TenantId tenantId, UserId recipientId, NotificationId notificationId, NotificationStatus status); - int countUnreadByRecipientId(TenantId tenantId, UserId recipientId); - - PageData findByRequestId(TenantId tenantId, NotificationRequestId notificationRequestId, PageLink pageLink); + int countUnreadByDeliveryMethodAndRecipientId(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId); boolean deleteByIdAndRecipientId(TenantId tenantId, UserId recipientId, NotificationId notificationId); - int updateStatusByRecipientId(TenantId tenantId, UserId recipientId, NotificationStatus status); + int updateStatusByDeliveryMethodAndRecipientId(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, NotificationStatus status); void deleteByRequestId(TenantId tenantId, NotificationRequestId requestId); 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 dff1b7ab5d..3b7e9ab664 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 @@ -119,7 +119,7 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC public RuleChain saveRuleChain(RuleChain ruleChain, boolean publishSaveEvent) { ruleChainValidator.validate(ruleChain, RuleChain::getTenantId); try { - RuleChain savedRuleChain = ruleChainDao.save(ruleChain.getTenantId(), ruleChain); + RuleChain savedRuleChain = ruleChainDao.saveAndFlush(ruleChain.getTenantId(), ruleChain); if (ruleChain.getId() == null) { entityCountService.publishCountEntityEvictEvent(ruleChain.getTenantId(), EntityType.RULE_CHAIN); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java index 67bde161b6..afd6608774 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java @@ -392,6 +392,11 @@ public class JpaBaseEventDao implements EventDao { return DaoUtil.convertDataList(getEventRepository(eventType).findLatestEvents(tenantId, entityId, limit)); } + @Override + public Event findLatestDebugRuleNodeInEvent(UUID tenantId, UUID entityId) { + return DaoUtil.getData(ruleNodeDebugEventRepository.findLatestDebugRuleNodeInEvent(tenantId, entityId)); + } + @Override public void cleanupEvents(long regularEventExpTs, long debugEventExpTs, boolean cleanupDb) { if (regularEventExpTs > 0) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/RuleNodeDebugEventRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/RuleNodeDebugEventRepository.java index 7c13c923c8..05473d92f7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/event/RuleNodeDebugEventRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/RuleNodeDebugEventRepository.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.event.RuleNodeDebugEvent; import org.thingsboard.server.dao.model.sql.RuleNodeDebugEventEntity; import java.util.List; +import java.util.Optional; import java.util.UUID; @@ -35,6 +36,9 @@ public interface RuleNodeDebugEventRepository extends EventRepository findLatestEvents(@Param("tenantId") UUID tenantId, @Param("entityId") UUID entityId, @Param("limit") int limit); + @Query(nativeQuery = true, value = "SELECT * FROM rule_node_debug_event e WHERE e.tenant_id = :tenantId AND e.entity_id = :entityId AND e.e_type = 'IN' ORDER BY e.ts DESC LIMIT 1") + Optional findLatestDebugRuleNodeInEvent(@Param("tenantId") UUID tenantId, @Param("entityId") UUID entityId); + @Override @Query("SELECT e FROM RuleNodeDebugEventEntity e WHERE " + "e.tenantId = :tenantId " + diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationDao.java index f095aad1ae..64d663b397 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationDao.java @@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.id.NotificationRequestId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.notification.Notification; +import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationStatus; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -51,14 +52,14 @@ public class JpaNotificationDao extends JpaPartitionedAbstractDao findUnreadByRecipientIdAndPageLink(TenantId tenantId, UserId recipientId, PageLink pageLink) { - return DaoUtil.toPageData(notificationRepository.findByRecipientIdAndStatusNot(recipientId.getId(), NotificationStatus.READ, - pageLink.getTextSearch(), DaoUtil.toPageable(pageLink))); + public PageData findUnreadByDeliveryMethodAndRecipientIdAndPageLink(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, PageLink pageLink) { + return DaoUtil.toPageData(notificationRepository.findByDeliveryMethodAndRecipientIdAndStatusNot(deliveryMethod, + recipientId.getId(), NotificationStatus.READ, pageLink.getTextSearch(), DaoUtil.toPageable(pageLink))); } @Override - public PageData findByRecipientIdAndPageLink(TenantId tenantId, UserId recipientId, PageLink pageLink) { - return DaoUtil.toPageData(notificationRepository.findByRecipientId(recipientId.getId(), + public PageData findByDeliveryMethodAndRecipientIdAndPageLink(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, PageLink pageLink) { + return DaoUtil.toPageData(notificationRepository.findByDeliveryMethodAndRecipientId(deliveryMethod, recipientId.getId(), pageLink.getTextSearch(), DaoUtil.toPageable(pageLink))); } @@ -71,13 +72,8 @@ public class JpaNotificationDao extends JpaPartitionedAbstractDao findByRequestId(TenantId tenantId, NotificationRequestId notificationRequestId, PageLink pageLink) { - return DaoUtil.toPageData(notificationRepository.findByRequestId(notificationRequestId.getId(), DaoUtil.toPageable(pageLink))); + public int countUnreadByDeliveryMethodAndRecipientId(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId) { + return notificationRepository.countByDeliveryMethodAndRecipientIdAndStatusNot(deliveryMethod, recipientId.getId(), NotificationStatus.READ); } @Override @@ -86,8 +82,8 @@ public class JpaNotificationDao extends JpaPartitionedAbstractDao { - @Query("SELECT n FROM NotificationEntity n WHERE n.recipientId = :recipientId AND n.status <> :status " + + @Query("SELECT n FROM NotificationEntity n WHERE n.deliveryMethod = :deliveryMethod " + + "AND n.recipientId = :recipientId AND n.status <> :status " + "AND (:searchText is NULL OR ilike(n.subject, concat('%', :searchText, '%')) = true " + "OR ilike(n.text, concat('%', :searchText, '%')) = true)") - Page findByRecipientIdAndStatusNot(@Param("recipientId") UUID recipientId, - @Param("status") NotificationStatus status, - @Param("searchText") String searchText, - Pageable pageable); + Page findByDeliveryMethodAndRecipientIdAndStatusNot(@Param("deliveryMethod") NotificationDeliveryMethod deliveryMethod, + @Param("recipientId") UUID recipientId, + @Param("status") NotificationStatus status, + @Param("searchText") String searchText, + Pageable pageable); - @Query("SELECT n FROM NotificationEntity n WHERE n.recipientId = :recipientId " + + @Query("SELECT n FROM NotificationEntity n WHERE n.deliveryMethod = :deliveryMethod AND n.recipientId = :recipientId " + "AND (:searchText is NULL OR ilike(n.subject, concat('%', :searchText, '%')) = true " + "OR ilike(n.text, concat('%', :searchText, '%')) = true)") - Page findByRecipientId(@Param("recipientId") UUID recipientId, - @Param("searchText") String searchText, - Pageable pageable); + Page findByDeliveryMethodAndRecipientId(@Param("deliveryMethod") NotificationDeliveryMethod deliveryMethod, + @Param("recipientId") UUID recipientId, + @Param("searchText") String searchText, + Pageable pageable); @Modifying @Transactional @@ -54,9 +58,7 @@ public interface NotificationRepository extends JpaRepository findByRequestId(UUID requestId, Pageable pageable); + int countByDeliveryMethodAndRecipientIdAndStatusNot(NotificationDeliveryMethod deliveryMethod, UUID recipientId, NotificationStatus status); @Transactional @Modifying @@ -76,8 +78,9 @@ public interface NotificationRepository extends JpaRepository :status") - int updateStatusByRecipientId(@Param("recipientId") UUID recipientId, - @Param("status") NotificationStatus status); + "WHERE n.deliveryMethod = :deliveryMethod AND n.recipientId = :recipientId AND n.status <> :status") + int updateStatusByDeliveryMethodAndRecipientIdAndStatusNot(@Param("deliveryMethod") NotificationDeliveryMethod deliveryMethod, + @Param("recipientId") UUID recipientId, + @Param("status") NotificationStatus status); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index 9e673b4f8b..da96dee67b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -48,6 +48,7 @@ import org.thingsboard.server.dao.user.UserService; import java.util.List; import java.util.Optional; +import java.util.function.Consumer; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -116,12 +117,12 @@ public class TenantServiceImpl extends AbstractCachedEntityService defaultEntitiesCreator) { log.trace("Executing saveTenant [{}]", tenant); tenant.setRegion(DEFAULT_TENANT_REGION); if (tenant.getTenantProfileId() == null) { @@ -130,20 +131,20 @@ public class TenantServiceImpl extends AbstractCachedEntityService 'READ'; +CREATE INDEX IF NOT EXISTS idx_notification_delivery_method_recipient_id_unread ON notification(delivery_method, recipient_id) WHERE status <> 'READ'; CREATE INDEX IF NOT EXISTS idx_resource_etag ON resource(tenant_id, etag); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 2236c7e6f8..1199f45181 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -25,7 +25,7 @@ CREATE OR REPLACE PROCEDURE insert_tb_schema_settings() $$ BEGIN IF (SELECT COUNT(*) FROM tb_schema_settings) = 0 THEN - INSERT INTO tb_schema_settings (schema_version) VALUES (3006000); + INSERT INTO tb_schema_settings (schema_version) VALUES (3006004); END IF; END; $$; @@ -864,6 +864,7 @@ CREATE TABLE IF NOT EXISTS notification ( request_id UUID, recipient_id UUID NOT NULL, type VARCHAR(50) NOT NULL, + delivery_method VARCHAR(50) NOT NULL, subject VARCHAR(255), body VARCHAR(1000) NOT NULL, additional_config VARCHAR(1000), diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java index f222c05b60..f1e1fd0e76 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java @@ -106,6 +106,10 @@ public abstract class AbstractServiceTest { protected RuleNodeDebugEvent generateEvent(TenantId tenantId, EntityId entityId) throws IOException { + return generateEvent(tenantId, entityId, null); + } + + protected RuleNodeDebugEvent generateEvent(TenantId tenantId, EntityId entityId, String eventType) throws IOException { if (tenantId == null) { tenantId = TenantId.fromUUID(Uuids.timeBased()); } @@ -113,6 +117,7 @@ public abstract class AbstractServiceTest { .tenantId(tenantId) .entityId(entityId.getId()) .serviceId("server A") + .eventType(eventType) .data(JacksonUtil.toString(readFromResource("TestJsonData.json"))) .build(); } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceTest.java index 216d2abb07..d9b94fb26b 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceTest.java @@ -26,7 +26,6 @@ import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.ResultSetExtractor; import org.thingsboard.server.common.data.AttributeScope; -import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; @@ -99,6 +98,8 @@ import java.util.stream.Stream; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertEquals; +import static org.thingsboard.server.common.data.query.EntityKeyType.ATTRIBUTE; +import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD; @Slf4j @DaoSqlTest @@ -2113,6 +2114,132 @@ public class EntityServiceTest extends AbstractServiceTest { deviceService.deleteDevicesByTenantId(tenantId); } + @Test + public void testFindEntityQuery_for_5000_devices_with_3000_pageSize() { + int pageSize = 3000; + int expectedDevicesSize = 4000; + int unexpectedDevicesSize = 1000; + + for (int i = 0; i < expectedDevicesSize + unexpectedDevicesSize; i++) { + Device device = new Device(); + device.setTenantId(tenantId); + if (i < expectedDevicesSize) { + device.setName("Device_" + i); // match deviceNameFilter 'D%' + } else { + device.setName("Test_" + i); // does not match deviceNameFilter 'D%' + } + device.setType("default"); + device.setLabel("testLabel" + (int) (Math.random() * 1000)); + Device savedDevice = deviceService.saveDevice(device); + + attributesService.save(tenantId, savedDevice.getId(), AttributeScope.CLIENT_SCOPE, + new BaseAttributeKvEntry(System.currentTimeMillis(), new LongDataEntry("telemetry", (long) i))); + } + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("default")); + filter.setDeviceNameFilter("D%"); + + EntityDataSortOrder sortOrder = new EntityDataSortOrder(new EntityKey(ATTRIBUTE, "telemetry"), EntityDataSortOrder.Direction.DESC); + + List deviceTypeFilters = createStringKeyFilters("type", ENTITY_FIELD, StringFilterPredicate.StringOperation.EQUAL, "default"); + + List attributeFilters = Collections.singletonList(createNumericKeyFilter("telemetry", ATTRIBUTE, NumericFilterPredicate.NumericOperation.LESS, expectedDevicesSize)); + + List nameFilters = createStringKeyFilters("name", ENTITY_FIELD, StringFilterPredicate.StringOperation.CONTAINS, "Device"); + + List entityFields = Arrays.asList(new EntityKey(ENTITY_FIELD, "name"), new EntityKey(ENTITY_FIELD, "type")); + + // 1. Device type filters: + + // query with textSearch - optimization is not performing + EntityDataPageLink originalPageLink = new EntityDataPageLink(pageSize, 0, "Device", sortOrder); + EntityDataQuery originalQuery = new EntityDataQuery(filter, originalPageLink, entityFields, null, deviceTypeFilters); + PageData originalData = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), originalQuery); + + // query without textSearch - optimization is performing + EntityDataPageLink optimizedPageLink = new EntityDataPageLink(pageSize, 0, null, sortOrder); + EntityDataQuery optimizedQuery = new EntityDataQuery(filter, optimizedPageLink, entityFields, null, deviceTypeFilters); + PageData optimizedData = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), optimizedQuery); + List loadedEntities = getLoadedEntities(optimizedData, optimizedQuery); + Assert.assertEquals(expectedDevicesSize, loadedEntities.size()); + loadedEntities = getLoadedEntities(originalData, originalQuery); + Assert.assertEquals(expectedDevicesSize, loadedEntities.size()); + Assert.assertEquals(pageSize, optimizedData.getData().size()); + + for (int i = 0; i < pageSize; i++) { + EntityData originalElement = originalData.getData().get(i); + EntityData optimizedElement = optimizedData.getData().get(i); + Assert.assertEquals(originalElement.getEntityId(), optimizedElement.getEntityId()); + originalElement.getLatest().get(ENTITY_FIELD).forEach((key, value) -> { + Assert.assertEquals(value.getValue(), optimizedElement.getLatest().get(EntityKeyType.ENTITY_FIELD).get(key).getValue()); + Assert.assertEquals(value.getCount(), optimizedElement.getLatest().get(EntityKeyType.ENTITY_FIELD).get(key).getCount()); + }); + } + Assert.assertEquals(originalData.getTotalPages(), optimizedData.getTotalPages()); + Assert.assertEquals(originalData.getTotalElements(), optimizedData.getTotalElements()); + + // 2. Device attribute filters + + // query with textSearch - optimization is not performing + originalPageLink = new EntityDataPageLink(pageSize, 0, "Device", sortOrder); + originalQuery = new EntityDataQuery(filter, originalPageLink, entityFields, null, attributeFilters); + originalData = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), originalQuery); + + // query without textSearch - optimization is performing + optimizedPageLink = new EntityDataPageLink(pageSize, 0, null, sortOrder); + optimizedQuery = new EntityDataQuery(filter, optimizedPageLink, entityFields, null, attributeFilters); + optimizedData = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), optimizedQuery); + loadedEntities = getLoadedEntities(optimizedData, optimizedQuery); + Assert.assertEquals(expectedDevicesSize, loadedEntities.size()); + loadedEntities = getLoadedEntities(originalData, originalQuery); + Assert.assertEquals(expectedDevicesSize, loadedEntities.size()); + Assert.assertEquals(pageSize, optimizedData.getData().size()); + + for (int i = 0; i < pageSize; i++) { + EntityData originalElement = originalData.getData().get(i); + EntityData optimizedElement = optimizedData.getData().get(i); + Assert.assertEquals(originalElement.getEntityId(), optimizedElement.getEntityId()); + originalElement.getLatest().get(ENTITY_FIELD).forEach((key, value) -> { + Assert.assertEquals(value.getValue(), optimizedElement.getLatest().get(EntityKeyType.ENTITY_FIELD).get(key).getValue()); + Assert.assertEquals(value.getCount(), optimizedElement.getLatest().get(EntityKeyType.ENTITY_FIELD).get(key).getCount()); + }); + } + Assert.assertEquals(originalData.getTotalPages(), optimizedData.getTotalPages()); + Assert.assertEquals(originalData.getTotalElements(), optimizedData.getTotalElements()); + + // 3. Device name filters + + // query with textSearch - optimization is not performing + originalPageLink = new EntityDataPageLink(pageSize, 0, "Device", sortOrder); + originalQuery = new EntityDataQuery(filter, originalPageLink, entityFields, null, nameFilters); + originalData = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), originalQuery); + + // query without textSearch - optimization is performing + optimizedPageLink = new EntityDataPageLink(pageSize, 0, null, sortOrder); + optimizedQuery = new EntityDataQuery(filter, optimizedPageLink, entityFields, null, nameFilters); + optimizedData = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), optimizedQuery); + loadedEntities = getLoadedEntities(optimizedData, optimizedQuery); + Assert.assertEquals(expectedDevicesSize, loadedEntities.size()); + loadedEntities = getLoadedEntities(originalData, originalQuery); + Assert.assertEquals(expectedDevicesSize, loadedEntities.size()); + Assert.assertEquals(pageSize, optimizedData.getData().size()); + + for (int i = 0; i < pageSize; i++) { + EntityData originalElement = originalData.getData().get(i); + EntityData optimizedElement = optimizedData.getData().get(i); + Assert.assertEquals(originalElement.getEntityId(), optimizedElement.getEntityId()); + originalElement.getLatest().get(ENTITY_FIELD).forEach((key, value) -> { + Assert.assertEquals(value.getValue(), optimizedElement.getLatest().get(EntityKeyType.ENTITY_FIELD).get(key).getValue()); + Assert.assertEquals(value.getCount(), optimizedElement.getLatest().get(EntityKeyType.ENTITY_FIELD).get(key).getCount()); + }); + } + Assert.assertEquals(originalData.getTotalPages(), optimizedData.getTotalPages()); + Assert.assertEquals(originalData.getTotalElements(), optimizedData.getTotalElements()); + + deviceService.deleteDevicesByTenantId(tenantId); + } + private Boolean listEqualWithoutOrder(List A, List B) { return A.containsAll(B) && B.containsAll(A); } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/RuleChainServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/RuleChainServiceTest.java index 50f46020d8..86b76bd5a5 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/RuleChainServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/RuleChainServiceTest.java @@ -40,8 +40,11 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.UUID; import java.util.function.Function; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + /** * Created by igor on 3/13/18. */ @@ -560,4 +563,35 @@ public class RuleChainServiceTest extends AbstractServiceTest { Assert.assertFalse(pageData.hasNext()); Assert.assertEquals(0, pageData.getData().size()); } + + @Test + public void testSaveRuleChainWithExistingExternalId() { + RuleChainId externalRuleChainId = new RuleChainId(UUID.fromString("2675d180-e1e5-11ee-9f06-71b6c7dc2cbf")); + + RuleChain ruleChain = getRuleChain(); + ruleChain.setTenantId(tenantId); + ruleChain.setExternalId(externalRuleChainId); + ruleChainService.saveRuleChain(ruleChain); + + assertThatThrownBy(() -> ruleChainService.saveRuleChain(ruleChain)) + .isInstanceOf(DataValidationException.class) + .hasMessage("Rule Chain with such external id already exists!"); + + ruleChainService.deleteRuleChainsByTenantId(tenantId); + } + + private RuleChain getRuleChain() { + String ruleChainStr = "{\n" + + " \"name\": \"Root Rule Chain\",\n" + + " \"type\": \"CORE\",\n" + + " \"firstRuleNodeId\": {\n" + + " \"entityType\": \"RULE_NODE\",\n" + + " \"id\": \"91ad0b00-e779-11ee-9cf0-15d8b6079fdb\"\n" + + " },\n" + + " \"debugMode\": false,\n" + + " \"configuration\": null,\n" + + " \"additionalInfo\": null\n" + + "}"; + return JacksonUtil.fromString(ruleChainStr, RuleChain.class); + } } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/event/BaseEventServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/event/BaseEventServiceTest.java index b73728d314..8aa943484d 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/event/BaseEventServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/event/BaseEventServiceTest.java @@ -132,8 +132,28 @@ public abstract class BaseEventServiceTest extends AbstractServiceTest { eventService.cleanupEvents(timeBeforeStartTime - 1, timeAfterEndTime + 1, true); } + @Test + public void findLatestDebugRuleNodeInEvent() throws Exception { + CustomerId customerId = new CustomerId(Uuids.timeBased()); + TenantId tenantId = TenantId.fromUUID(Uuids.timeBased()); + + Event event1 = saveEventWithProvidedTimeAndEventType(eventTime, "IN", customerId, tenantId); + Event event2 = saveEventWithProvidedTimeAndEventType(eventTime + 1, "IN", customerId, tenantId); + + EventInfo event = eventService.findLatestDebugRuleNodeInEvent(tenantId, customerId); + + Assert.assertNotNull(event); + Assert.assertEquals(event2.getUuidId(), event.getUuidId()); + + eventService.cleanupEvents(timeBeforeStartTime - 1, timeAfterEndTime + 1, true); + } + private Event saveEventWithProvidedTime(long time, EntityId entityId, TenantId tenantId) throws Exception { - RuleNodeDebugEvent event = generateEvent(tenantId, entityId); + return saveEventWithProvidedTimeAndEventType(time, null, entityId, tenantId); + } + + private Event saveEventWithProvidedTimeAndEventType(long time, String eventType, EntityId entityId, TenantId tenantId) throws Exception { + RuleNodeDebugEvent event = generateEvent(tenantId, entityId, eventType); event.setId(new EventId(Uuids.timeBased())); event.setCreatedTime(time); eventService.saveAsync(event).get(); diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDaoTest.java index 5c908fa1b4..b178d7c78f 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDaoTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDaoTest.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.dao.AbstractJpaDaoTest; import org.thingsboard.server.dao.event.EventDao; +import org.thingsboard.server.dao.model.sql.RuleNodeDebugEventEntity; import java.util.List; import java.util.UUID; @@ -42,6 +43,10 @@ public class JpaBaseEventDaoTest extends AbstractJpaDaoTest { @Autowired private EventDao eventDao; + + @Autowired + private RuleNodeDebugEventRepository ruleNodeDebugEventRepository; + UUID tenantId = Uuids.timeBased(); @@ -106,6 +111,23 @@ public class JpaBaseEventDaoTest extends AbstractJpaDaoTest { } + @Test + public void findLatestDebugRuleNodeInEvent() throws Exception { + + UUID entityId = Uuids.timeBased(); + + RuleNodeDebugEventEntity event = getDebugInEventEntity(Uuids.timeBased(), tenantId, entityId); + eventDao.saveAsync(event.toData()).get(1, TimeUnit.MINUTES); + Thread.sleep(2); + RuleNodeDebugEventEntity event2 = getDebugInEventEntity(Uuids.timeBased(), tenantId, entityId); + eventDao.saveAsync(event2.toData()).get(1, TimeUnit.MINUTES); + + RuleNodeDebugEventEntity foundEvent = ruleNodeDebugEventRepository.findLatestDebugRuleNodeInEvent(tenantId, entityId).get(); + assertNotNull("Events expected to be not null", foundEvent); + assertEquals(event2.getEventType(), foundEvent.getEventType()); + assertEquals(event2.getId(), foundEvent.getId()); + } + private Event getStatsEvent(UUID eventId, UUID tenantId, UUID entityId) { StatisticsEvent.StatisticsEventBuilder event = StatisticsEvent.builder(); event.id(eventId); @@ -117,4 +139,13 @@ public class JpaBaseEventDaoTest extends AbstractJpaDaoTest { event.errorsOccurred(0); return event.build(); } + + private RuleNodeDebugEventEntity getDebugInEventEntity(UUID eventId, UUID tenantId, UUID entityId) { + RuleNodeDebugEventEntity event = new RuleNodeDebugEventEntity(); + event.setId(eventId); + event.setEventType("IN"); + event.setEntityId(entityId); + event.setTenantId(tenantId); + return event; + } } diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index ad0fa66cb9..70fea2c0a5 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -38,7 +38,7 @@ tb-postgres tb-cassandra /usr/share/${pkg.name} - 3.6.3 + 3.6.4 diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/utils/RestJsonConverter.java b/rest-client/src/main/java/org/thingsboard/rest/client/utils/RestJsonConverter.java index 903983d00f..31a0c7b63b 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/utils/RestJsonConverter.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/utils/RestJsonConverter.java @@ -62,7 +62,7 @@ public class RestJsonConverter { KvEntry entry = parseValue(key, ts.get(VALUE)); return new BasicTsKvEntry(ts.get(TS).asLong(), entry); } - ).collect(Collectors.toList())) + ).toList()) ); return result; } else { diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/NotificationCenter.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/NotificationCenter.java index fd63ace9c4..d108a6981d 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/NotificationCenter.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/NotificationCenter.java @@ -38,7 +38,7 @@ public interface NotificationCenter { void markNotificationAsRead(TenantId tenantId, UserId recipientId, NotificationId notificationId); - void markAllNotificationsAsRead(TenantId tenantId, UserId recipientId); + void markAllNotificationsAsRead(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId); void deleteNotification(TenantId tenantId, UserId recipientId, NotificationId notificationId); diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/notification/FirebaseService.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/notification/FirebaseService.java index 28aa4c96af..ef10d8cecc 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/notification/FirebaseService.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/notification/FirebaseService.java @@ -21,6 +21,6 @@ import java.util.Map; public interface FirebaseService { - void sendMessage(TenantId tenantId, String credentials, String fcmToken, String title, String body, Map data) throws Exception; + void sendMessage(TenantId tenantId, String credentials, String fcmToken, String title, String body, Map data, Integer badge) throws Exception; } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java index 9377fca636..7e535596cd 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java @@ -176,7 +176,7 @@ public class TbCreateAlarmNode extends TbAbstractAlarmNode entityBody = new HashMap<>(); JsonNode dataJson = JacksonUtil.toJsonNode(msg.getData()); switch (actionType) { - case ATTRIBUTES_UPDATED: - case POST_ATTRIBUTES: + case ATTRIBUTES_UPDATED, POST_ATTRIBUTES -> { entityBody.put("kv", dataJson); entityBody.put(SCOPE, getScope(metadata)); if (EdgeEventActionType.POST_ATTRIBUTES.equals(actionType)) { entityBody.put("isPostAttributes", true); } - break; - case ATTRIBUTES_DELETED: + } + case ATTRIBUTES_DELETED -> { List keys = JacksonUtil.convertValue(dataJson.get("attributes"), new TypeReference<>() { }); entityBody.put("keys", keys); entityBody.put(SCOPE, getScope(metadata)); - break; - case TIMESERIES_UPDATED: + } + case TIMESERIES_UPDATED -> { entityBody.put("data", dataJson); entityBody.put("ts", msg.getMetaDataTs()); - break; + } } return buildEvent(ctx.getTenantId(), actionType, @@ -179,4 +178,5 @@ public abstract class AbstractTbMsgPushNode upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException { + boolean hasChanges = false; + switch (fromVersion) { + case 0: + String excludeZeroDeltas = "excludeZeroDeltas"; + if (!oldConfiguration.has(excludeZeroDeltas)) { + hasChanges = true; + ((ObjectNode) oldConfiguration).put(excludeZeroDeltas, false); + } + break; + default: + break; + } + return new TbPair<>(hasChanges, oldConfiguration); + } + private ListenableFuture fetchLatestValueAsync(EntityId entityId) { return Futures.transform(timeseriesService.findLatest(ctx.getTenantId(), entityId, Collections.singletonList(config.getInputValueKey())), list -> extractValue(list.get(0)) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNodeConfiguration.java index 0c4e6de556..0ae558718b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNodeConfiguration.java @@ -30,6 +30,7 @@ public class CalculateDeltaNodeConfiguration implements NodeConfiguration { if (future.isSuccess()) { tellSuccess(ctx, tbMsg); @@ -153,4 +158,27 @@ public class TbMqttNode extends TbAbstractExternalNode { return this.mqttNodeConfiguration.isSsl() ? this.mqttNodeConfiguration.getCredentials().initSslContext() : null; } + private String getData(TbMsg tbMsg, boolean parseToPlainText) { + if (parseToPlainText) { + return JacksonUtil.toPlainText(tbMsg.getData()); + } + return tbMsg.getData(); + } + + @Override + public TbPair upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException { + boolean hasChanges = false; + switch (fromVersion) { + case 0: + String parseToPlainText = "parseToPlainText"; + if (!oldConfiguration.has(parseToPlainText)) { + hasChanges = true; + ((ObjectNode) oldConfiguration).put(parseToPlainText, false); + } + break; + default: + break; + } + return new TbPair<>(hasChanges, oldConfiguration); + } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeConfiguration.java index 5f13b0e677..edf3618631 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeConfiguration.java @@ -33,6 +33,7 @@ public class TbMqttNodeConfiguration implements NodeConfiguration= 2) { - final String dataBefore = data; - try { - data = JacksonUtil.fromString(data, String.class); - } catch (Exception ignored) {} - 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(); HttpStatus httpStatus = (HttpStatus) response.getStatusCode(); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbAlarmNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbAlarmNodeTest.java deleted file mode 100644 index 837a80d626..0000000000 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbAlarmNodeTest.java +++ /dev/null @@ -1,642 +0,0 @@ -/** - * Copyright © 2016-2024 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS 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.datastax.oss.driver.api.core.uuid.Uuids; -import com.fasterxml.jackson.databind.JsonNode; -import com.google.common.util.concurrent.Futures; -import org.apache.commons.lang3.NotImplementedException; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; -import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.common.util.ListeningExecutor; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; -import org.thingsboard.rule.engine.api.RuleEngineAlarmService; -import org.thingsboard.rule.engine.api.ScriptEngine; -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.alarm.Alarm; -import org.thingsboard.server.common.data.alarm.AlarmApiCallResult; -import org.thingsboard.server.common.data.alarm.AlarmCreateOrUpdateActiveRequest; -import org.thingsboard.server.common.data.alarm.AlarmInfo; -import org.thingsboard.server.common.data.alarm.AlarmSeverity; -import org.thingsboard.server.common.data.alarm.AlarmUpdateRequest; -import org.thingsboard.server.common.data.id.AlarmId; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.id.RuleChainId; -import org.thingsboard.server.common.data.id.RuleNodeId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.msg.TbMsgType; -import org.thingsboard.server.common.data.script.ScriptLanguage; -import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.common.msg.TbMsgDataType; -import org.thingsboard.server.common.msg.TbMsgMetaData; - -import javax.script.ScriptException; -import java.io.IOException; -import java.util.function.Consumer; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotSame; -import static org.junit.Assert.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.nullable; -import static org.mockito.Mockito.anyLong; -import static org.mockito.Mockito.atMost; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.same; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -@RunWith(MockitoJUnitRunner.class) -public class TbAlarmNodeTest { - - private TbAbstractAlarmNode node; - - @Mock - private TbContext ctx; - @Mock - private RuleEngineAlarmService alarmService; - - @Mock - private ScriptEngine detailsJs; - - @Captor - private ArgumentCaptor successCaptor; - @Captor - private ArgumentCaptor> failureCaptor; - - private final RuleChainId ruleChainId = new RuleChainId(Uuids.timeBased()); - private final RuleNodeId ruleNodeId = new RuleNodeId(Uuids.timeBased()); - - private ListeningExecutor dbExecutor; - - private final EntityId originator = new DeviceId(Uuids.timeBased()); - private final EntityId alarmOriginator = new AlarmId(Uuids.timeBased()); - private final TenantId tenantId = TenantId.fromUUID(Uuids.timeBased()); - private final TbMsgMetaData metaData = new TbMsgMetaData(); - private final String rawJson = "{\"name\": \"Vit\", \"passed\": 5}"; - - @Before - public void before() { - dbExecutor = new TestDbCallbackExecutor(); - } - - @Test - public void newAlarmCanBeCreated() { - initWithCreateAlarmScript(); - metaData.putValue("key", "value"); - TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, originator, metaData, TbMsgDataType.JSON, rawJson, ruleChainId, ruleNodeId); - long ts = msg.getTs(); - - when(detailsJs.executeJsonAsync(msg)).thenReturn(Futures.immediateFuture(null)); - when(alarmService.findLatestActiveByOriginatorAndType(tenantId, originator, "SomeType")).thenReturn(null); - Alarm expectedAlarm = Alarm.builder() - .startTs(ts) - .endTs(ts) - .tenantId(tenantId) - .originator(originator) - .severity(AlarmSeverity.CRITICAL) - .propagate(true) - .type("SomeType") - .details(null) - .build(); - when(alarmService.createAlarm(any(AlarmCreateOrUpdateActiveRequest.class))).thenReturn( - AlarmApiCallResult.builder() - .created(true) - .alarm(new AlarmInfo(expectedAlarm)) - .build()); - - node.onMsg(ctx, msg); - - verify(ctx).enqueue(any(), successCaptor.capture(), failureCaptor.capture()); - successCaptor.getValue().run(); - verify(ctx).tellNext(any(), eq("Created")); - - ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); - ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(TbMsgType.class); - ArgumentCaptor originatorCaptor = ArgumentCaptor.forClass(EntityId.class); - ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); - ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); - verify(ctx).transformMsg(msgCaptor.capture(), typeCaptor.capture(), originatorCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); - - assertEquals(TbMsgType.ALARM, typeCaptor.getValue()); - assertEquals(originator, originatorCaptor.getValue()); - assertEquals("value", metadataCaptor.getValue().getValue("key")); - assertEquals(Boolean.TRUE.toString(), metadataCaptor.getValue().getValue(DataConstants.IS_NEW_ALARM)); - assertNotSame(metaData, metadataCaptor.getValue()); - - Alarm actualAlarm = JacksonUtil.fromBytes(dataCaptor.getValue().getBytes(), Alarm.class); - assertEquals(expectedAlarm, actualAlarm); - } - - @Test - public void buildDetailsThrowsException() { - initWithCreateAlarmScript(); - metaData.putValue("key", "value"); - TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, originator, metaData, TbMsgDataType.JSON, rawJson, ruleChainId, ruleNodeId); - - when(detailsJs.executeJsonAsync(msg)).thenReturn(Futures.immediateFailedFuture(new NotImplementedException("message"))); - when(alarmService.findLatestActiveByOriginatorAndType(tenantId, originator, "SomeType")).thenReturn(null); - - node.onMsg(ctx, msg); - - verifyError(msg, "message", NotImplementedException.class); - - verify(ctx).createScriptEngine(ScriptLanguage.JS, "DETAILS"); - verify(ctx).getAlarmService(); - verify(ctx, times(2)).getDbCallbackExecutor(); - verify(ctx).logJsEvalRequest(); - verify(ctx).getTenantId(); - verify(alarmService).findLatestActiveByOriginatorAndType(tenantId, originator, "SomeType"); - - verifyNoMoreInteractions(ctx, alarmService); - } - - @Test - public void ifAlarmClearedCreateNew() { - initWithCreateAlarmScript(); - metaData.putValue("key", "value"); - TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, originator, metaData, TbMsgDataType.JSON, rawJson, ruleChainId, ruleNodeId); - long ts = msg.getTs(); - Alarm clearedAlarm = Alarm.builder().cleared(true).acknowledged(true).build(); - - when(detailsJs.executeJsonAsync(msg)).thenReturn(Futures.immediateFuture(null)); - when(alarmService.findLatestActiveByOriginatorAndType(tenantId, originator, "SomeType")).thenReturn(clearedAlarm); - - Alarm expectedAlarm = Alarm.builder() - .startTs(ts) - .endTs(ts) - .tenantId(tenantId) - .originator(originator) - .severity(AlarmSeverity.CRITICAL) - .propagate(true) - .type("SomeType") - .details(null) - .build(); - when(alarmService.createAlarm(any(AlarmCreateOrUpdateActiveRequest.class))).thenReturn( - AlarmApiCallResult.builder() - .successful(true) - .created(true) - .alarm(new AlarmInfo(expectedAlarm)) - .build()); - - node.onMsg(ctx, msg); - - verify(ctx).enqueue(any(), successCaptor.capture(), failureCaptor.capture()); - successCaptor.getValue().run(); - verify(ctx).tellNext(any(), eq("Created")); - - ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); - ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(TbMsgType.class); - ArgumentCaptor originatorCaptor = ArgumentCaptor.forClass(EntityId.class); - ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); - ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); - verify(ctx).transformMsg(msgCaptor.capture(), typeCaptor.capture(), originatorCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); - - assertEquals(TbMsgType.ALARM, typeCaptor.getValue()); - assertEquals(originator, originatorCaptor.getValue()); - assertEquals("value", metadataCaptor.getValue().getValue("key")); - assertEquals(Boolean.TRUE.toString(), metadataCaptor.getValue().getValue(DataConstants.IS_NEW_ALARM)); - assertNotSame(metaData, metadataCaptor.getValue()); - - - Alarm actualAlarm = JacksonUtil.fromBytes(dataCaptor.getValue().getBytes(), Alarm.class); - assertEquals(expectedAlarm, actualAlarm); - } - - @Test - public void alarmCanBeUpdated() { - initWithCreateAlarmScript(); - metaData.putValue("key", "value"); - TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, originator, metaData, TbMsgDataType.JSON, rawJson, ruleChainId, ruleNodeId); - - long oldEndDate = System.currentTimeMillis(); - Alarm activeAlarm = Alarm.builder().type("SomeType").tenantId(tenantId).originator(originator).severity(AlarmSeverity.WARNING).endTs(oldEndDate).build(); - - when(detailsJs.executeJsonAsync(msg)).thenReturn(Futures.immediateFuture(null)); - when(alarmService.findLatestActiveByOriginatorAndType(tenantId, originator, "SomeType")).thenReturn(activeAlarm); - - Alarm expectedAlarm = Alarm.builder() - .tenantId(tenantId) - .originator(originator) - .severity(AlarmSeverity.CRITICAL) - .propagate(true) - .type("SomeType") - .details(null) - .endTs(activeAlarm.getEndTs()) - .build(); - when(alarmService.updateAlarm(any(AlarmUpdateRequest.class))).thenReturn( - AlarmApiCallResult.builder() - .successful(true) - .modified(true) - .old(new Alarm(activeAlarm)) - .alarm(new AlarmInfo(expectedAlarm)) - .build()); - node.onMsg(ctx, msg); - - verify(ctx).enqueue(any(), successCaptor.capture(), failureCaptor.capture()); - successCaptor.getValue().run(); - verify(ctx).tellNext(any(), eq("Updated")); - - ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); - ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(TbMsgType.class); - ArgumentCaptor originatorCaptor = ArgumentCaptor.forClass(EntityId.class); - ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); - ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); - verify(ctx).transformMsg(msgCaptor.capture(), typeCaptor.capture(), originatorCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); - - assertEquals(TbMsgType.ALARM, typeCaptor.getValue()); - assertEquals(originator, originatorCaptor.getValue()); - assertEquals("value", metadataCaptor.getValue().getValue("key")); - assertEquals(Boolean.TRUE.toString(), metadataCaptor.getValue().getValue(DataConstants.IS_EXISTING_ALARM)); - assertNotSame(metaData, metadataCaptor.getValue()); - - Alarm actualAlarm = JacksonUtil.fromBytes(dataCaptor.getValue().getBytes(), Alarm.class); - assertTrue(activeAlarm.getEndTs() >= oldEndDate); - assertEquals(expectedAlarm, actualAlarm); - } - - @Test - public void alarmCanBeCleared() { - initWithClearAlarmScript(); - metaData.putValue("key", "value"); - TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, originator, metaData, TbMsgDataType.JSON, rawJson, ruleChainId, ruleNodeId); - - long oldEndDate = System.currentTimeMillis(); - Alarm activeAlarm = Alarm.builder().type("SomeType").tenantId(tenantId).originator(originator).severity(AlarmSeverity.WARNING).endTs(oldEndDate).build(); - - Alarm expectedAlarm = Alarm.builder() - .tenantId(tenantId) - .originator(originator) - .cleared(true) - .severity(AlarmSeverity.WARNING) - .propagate(false) - .type("SomeType") - .details(null) - .endTs(oldEndDate) - .build(); - - when(detailsJs.executeJsonAsync(msg)).thenReturn(Futures.immediateFuture(null)); - when(alarmService.findLatestActiveByOriginatorAndType(tenantId, originator, "SomeType")).thenReturn(activeAlarm); - when(alarmService.clearAlarm(eq(activeAlarm.getTenantId()), eq(activeAlarm.getId()), anyLong(), nullable(JsonNode.class))) - .thenReturn(AlarmApiCallResult.builder() - .successful(true) - .cleared(true) - .alarm(new AlarmInfo(expectedAlarm)) - .build()); - - node.onMsg(ctx, msg); - - verify(ctx).enqueue(any(), successCaptor.capture(), failureCaptor.capture()); - successCaptor.getValue().run(); - verify(ctx).tellNext(any(), eq("Cleared")); - - ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); - ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(TbMsgType.class); - ArgumentCaptor originatorCaptor = ArgumentCaptor.forClass(EntityId.class); - ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); - ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); - verify(ctx).transformMsg(msgCaptor.capture(), typeCaptor.capture(), originatorCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); - - assertEquals(TbMsgType.ALARM, typeCaptor.getValue()); - assertEquals(originator, originatorCaptor.getValue()); - assertEquals("value", metadataCaptor.getValue().getValue("key")); - assertEquals(Boolean.TRUE.toString(), metadataCaptor.getValue().getValue(DataConstants.IS_CLEARED_ALARM)); - assertNotSame(metaData, metadataCaptor.getValue()); - - Alarm actualAlarm = JacksonUtil.fromBytes(dataCaptor.getValue().getBytes(), Alarm.class); - assertEquals(expectedAlarm, actualAlarm); - } - - @Test - public void alarmCanBeClearedWithAlarmOriginator() throws ScriptException, IOException { - initWithClearAlarmScript(); - metaData.putValue("key", "value"); - TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, alarmOriginator, metaData, TbMsgDataType.JSON, rawJson, ruleChainId, ruleNodeId); - - long oldEndDate = System.currentTimeMillis(); - AlarmId id = new AlarmId(alarmOriginator.getId()); - Alarm activeAlarm = Alarm.builder().type("SomeType").tenantId(tenantId).originator(originator).severity(AlarmSeverity.WARNING).endTs(oldEndDate).build(); - activeAlarm.setId(id); - - Alarm expectedAlarm = Alarm.builder() - .tenantId(tenantId) - .originator(originator) - .cleared(true) - .severity(AlarmSeverity.WARNING) - .propagate(false) - .type("SomeType") - .details(null) - .endTs(oldEndDate) - .build(); - expectedAlarm.setId(id); - - when(detailsJs.executeJsonAsync(msg)).thenReturn(Futures.immediateFuture(null)); - when(alarmService.findAlarmById(tenantId, id)).thenReturn(activeAlarm); - when(alarmService.clearAlarm(eq(activeAlarm.getTenantId()), eq(activeAlarm.getId()), anyLong(), nullable(JsonNode.class))) - .thenReturn(AlarmApiCallResult.builder() - .successful(true) - .cleared(true) - .alarm(new AlarmInfo(expectedAlarm)) - .build()); - - node.onMsg(ctx, msg); - - verify(ctx).enqueue(any(), successCaptor.capture(), failureCaptor.capture()); - successCaptor.getValue().run(); - verify(ctx).tellNext(any(), eq("Cleared")); - - ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); - ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(TbMsgType.class); - ArgumentCaptor originatorCaptor = ArgumentCaptor.forClass(EntityId.class); - ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); - ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); - verify(ctx).transformMsg(msgCaptor.capture(), typeCaptor.capture(), originatorCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); - - assertEquals(TbMsgType.ALARM, typeCaptor.getValue()); - assertEquals(alarmOriginator, originatorCaptor.getValue()); - assertEquals("value", metadataCaptor.getValue().getValue("key")); - assertEquals(Boolean.TRUE.toString(), metadataCaptor.getValue().getValue(DataConstants.IS_CLEARED_ALARM)); - assertNotSame(metaData, metadataCaptor.getValue()); - - Alarm actualAlarm = JacksonUtil.fromBytes(dataCaptor.getValue().getBytes(), Alarm.class); - assertEquals(expectedAlarm, actualAlarm); - } - - @Test - public void testCreateAlarmWithDynamicSeverityFromMessageBody() throws Exception { - TbCreateAlarmNodeConfiguration config = new TbCreateAlarmNodeConfiguration(); - config.setPropagate(true); - config.setSeverity("$[alarmSeverity]"); - config.setAlarmType("SomeType"); - config.setScriptLang(ScriptLanguage.JS); - config.setAlarmDetailsBuildJs("DETAILS"); - config.setDynamicSeverity(true); - TbNodeConfiguration nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); - - when(ctx.createScriptEngine(ScriptLanguage.JS, "DETAILS")).thenReturn(detailsJs); - - when(ctx.getTenantId()).thenReturn(tenantId); - when(ctx.getAlarmService()).thenReturn(alarmService); - when(ctx.getDbCallbackExecutor()).thenReturn(dbExecutor); - - node = new TbCreateAlarmNode(); - node.init(ctx, nodeConfiguration); - - String rawJson = "{\"alarmSeverity\": \"WARNING\", \"passed\": 5}"; - metaData.putValue("key", "value"); - TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, originator, metaData, TbMsgDataType.JSON, rawJson, ruleChainId, ruleNodeId); - long ts = msg.getTs(); - Alarm expectedAlarm = Alarm.builder() - .startTs(ts) - .endTs(ts) - .tenantId(tenantId) - .originator(originator) - .severity(AlarmSeverity.WARNING) - .propagate(true) - .type("SomeType") - .details(null) - .build(); - - when(detailsJs.executeJsonAsync(msg)).thenReturn(Futures.immediateFuture(null)); - when(alarmService.findLatestActiveByOriginatorAndType(tenantId, originator, "SomeType")).thenReturn(null); - when(alarmService.createAlarm(any(AlarmCreateOrUpdateActiveRequest.class))).thenReturn( - AlarmApiCallResult.builder() - .successful(true) - .created(true) - .alarm(new AlarmInfo(expectedAlarm)) - .build()); - - node.onMsg(ctx, msg); - - verify(ctx).enqueue(any(), successCaptor.capture(), failureCaptor.capture()); - successCaptor.getValue().run(); - verify(ctx).tellNext(any(), eq("Created")); - - ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); - ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(TbMsgType.class); - ArgumentCaptor originatorCaptor = ArgumentCaptor.forClass(EntityId.class); - ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); - ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); - verify(ctx).transformMsg(msgCaptor.capture(), typeCaptor.capture(), originatorCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); - - assertEquals(TbMsgType.ALARM, typeCaptor.getValue()); - assertEquals(originator, originatorCaptor.getValue()); - assertEquals("value", metadataCaptor.getValue().getValue("key")); - assertEquals(Boolean.TRUE.toString(), metadataCaptor.getValue().getValue(DataConstants.IS_NEW_ALARM)); - assertNotSame(metaData, metadataCaptor.getValue()); - - Alarm actualAlarm = JacksonUtil.fromBytes(dataCaptor.getValue().getBytes(), Alarm.class); - assertEquals(expectedAlarm, actualAlarm); - } - - @Test - public void testCreateAlarmWithDynamicSeverityFromMetadata() throws Exception { - TbCreateAlarmNodeConfiguration config = new TbCreateAlarmNodeConfiguration(); - config.setPropagate(true); - config.setScriptLang(ScriptLanguage.JS); - config.setSeverity("${alarmSeverity}"); - config.setAlarmType("SomeType"); - config.setAlarmDetailsBuildJs("DETAILS"); - config.setDynamicSeverity(true); - TbNodeConfiguration nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); - - when(ctx.createScriptEngine(ScriptLanguage.JS, "DETAILS")).thenReturn(detailsJs); - - when(ctx.getTenantId()).thenReturn(tenantId); - when(ctx.getAlarmService()).thenReturn(alarmService); - when(ctx.getDbCallbackExecutor()).thenReturn(dbExecutor); - - node = new TbCreateAlarmNode(); - node.init(ctx, nodeConfiguration); - - metaData.putValue("alarmSeverity", "WARNING"); - TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, originator, metaData, TbMsgDataType.JSON, rawJson, ruleChainId, ruleNodeId); - long ts = msg.getTs(); - Alarm expectedAlarm = Alarm.builder() - .startTs(ts) - .endTs(ts) - .tenantId(tenantId) - .originator(originator) - .severity(AlarmSeverity.WARNING) - .propagate(true) - .type("SomeType") - .details(null) - .build(); - - when(detailsJs.executeJsonAsync(msg)).thenReturn(Futures.immediateFuture(null)); - when(alarmService.findLatestActiveByOriginatorAndType(tenantId, originator, "SomeType")).thenReturn(null); - when(alarmService.createAlarm(any(AlarmCreateOrUpdateActiveRequest.class))).thenReturn( - AlarmApiCallResult.builder() - .successful(true) - .created(true) - .alarm(new AlarmInfo(expectedAlarm)) - .build()); - - node.onMsg(ctx, msg); - - verify(ctx).enqueue(any(), successCaptor.capture(), failureCaptor.capture()); - successCaptor.getValue().run(); - verify(ctx).tellNext(any(), eq("Created")); - - ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); - ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(TbMsgType.class); - ArgumentCaptor originatorCaptor = ArgumentCaptor.forClass(EntityId.class); - ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); - ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); - verify(ctx).transformMsg(msgCaptor.capture(), typeCaptor.capture(), originatorCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); - - assertEquals(TbMsgType.ALARM, typeCaptor.getValue()); - assertEquals(originator, originatorCaptor.getValue()); - assertEquals(Boolean.TRUE.toString(), metadataCaptor.getValue().getValue(DataConstants.IS_NEW_ALARM)); - assertNotSame(metaData, metadataCaptor.getValue()); - - Alarm actualAlarm = JacksonUtil.fromBytes(dataCaptor.getValue().getBytes(), Alarm.class); - assertEquals(expectedAlarm, actualAlarm); - } - - @Test - public void testCreateAlarmsWithPropagationToTenantWithDynamicTypes() throws Exception { - for (int i = 0; i < 10; i++) { - var config = new TbCreateAlarmNodeConfiguration(); - config.setPropagateToTenant(true); - config.setSeverity(AlarmSeverity.CRITICAL.name()); - config.setAlarmType("SomeType" + i); - config.setScriptLang(ScriptLanguage.JS); - config.setAlarmDetailsBuildJs("DETAILS"); - config.setDynamicSeverity(true); - TbNodeConfiguration nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); - - when(ctx.createScriptEngine(ScriptLanguage.JS, "DETAILS")).thenReturn(detailsJs); - - when(ctx.getTenantId()).thenReturn(tenantId); - when(ctx.getAlarmService()).thenReturn(alarmService); - when(ctx.getDbCallbackExecutor()).thenReturn(dbExecutor); - - node = new TbCreateAlarmNode(); - node.init(ctx, nodeConfiguration); - - metaData.putValue("key", "value"); - TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, originator, metaData, TbMsgDataType.JSON, rawJson, ruleChainId, ruleNodeId); - long ts = msg.getTs(); - Alarm expectedAlarm = Alarm.builder() - .startTs(ts) - .endTs(ts) - .tenantId(tenantId) - .originator(originator) - .severity(AlarmSeverity.CRITICAL) - .propagateToTenant(true) - .type("SomeType" + i) - .details(null) - .build(); - - when(detailsJs.executeJsonAsync(msg)).thenReturn(Futures.immediateFuture(null)); - when(alarmService.findLatestActiveByOriginatorAndType(tenantId, originator, "SomeType" + i)).thenReturn(null); - when(alarmService.createAlarm(any(AlarmCreateOrUpdateActiveRequest.class))).thenReturn( - AlarmApiCallResult.builder() - .successful(true) - .created(true) - .alarm(new AlarmInfo(expectedAlarm)) - .build()); - node.onMsg(ctx, msg); - - verify(ctx, atMost(10)).enqueue(any(), successCaptor.capture(), failureCaptor.capture()); - successCaptor.getValue().run(); - verify(ctx, atMost(10)).tellNext(any(), eq("Created")); - - ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); - ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(TbMsgType.class); - ArgumentCaptor originatorCaptor = ArgumentCaptor.forClass(EntityId.class); - ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); - ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); - verify(ctx, atMost(10)).transformMsg(msgCaptor.capture(), typeCaptor.capture(), originatorCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); - - assertEquals(TbMsgType.ALARM, typeCaptor.getValue()); - assertEquals(originator, originatorCaptor.getValue()); - assertEquals("value", metadataCaptor.getValue().getValue("key")); - assertEquals(Boolean.TRUE.toString(), metadataCaptor.getValue().getValue(DataConstants.IS_NEW_ALARM)); - assertNotSame(metaData, metadataCaptor.getValue()); - - Alarm actualAlarm = JacksonUtil.fromBytes(dataCaptor.getValue().getBytes(), Alarm.class); - assertEquals(expectedAlarm, actualAlarm); - } - } - - private void initWithCreateAlarmScript() { - try { - TbCreateAlarmNodeConfiguration config = new TbCreateAlarmNodeConfiguration(); - config.setPropagate(true); - config.setSeverity(AlarmSeverity.CRITICAL.name()); - config.setAlarmType("SomeType"); - config.setScriptLang(ScriptLanguage.JS); - config.setAlarmDetailsBuildJs("DETAILS"); - TbNodeConfiguration nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); - - when(ctx.createScriptEngine(ScriptLanguage.JS, "DETAILS")).thenReturn(detailsJs); - - when(ctx.getTenantId()).thenReturn(tenantId); - when(ctx.getAlarmService()).thenReturn(alarmService); - when(ctx.getDbCallbackExecutor()).thenReturn(dbExecutor); - - node = new TbCreateAlarmNode(); - node.init(ctx, nodeConfiguration); - } catch (TbNodeException ex) { - throw new IllegalStateException(ex); - } - } - - private void initWithClearAlarmScript() { - try { - TbClearAlarmNodeConfiguration config = new TbClearAlarmNodeConfiguration(); - config.setAlarmType("SomeType"); - config.setScriptLang(ScriptLanguage.JS); - config.setAlarmDetailsBuildJs("DETAILS"); - TbNodeConfiguration nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); - - when(ctx.createScriptEngine(ScriptLanguage.JS, "DETAILS")).thenReturn(detailsJs); - - when(ctx.getTenantId()).thenReturn(tenantId); - when(ctx.getAlarmService()).thenReturn(alarmService); - when(ctx.getDbCallbackExecutor()).thenReturn(dbExecutor); - - node = new TbClearAlarmNode(); - node.init(ctx, nodeConfiguration); - } catch (TbNodeException ex) { - throw new IllegalStateException(ex); - } - } - - private void verifyError(TbMsg msg, String message, Class expectedClass) { - ArgumentCaptor captor = ArgumentCaptor.forClass(Throwable.class); - verify(ctx).tellFailure(same(msg), captor.capture()); - - Throwable value = captor.getValue(); - assertEquals(expectedClass, value.getClass()); - assertEquals(message, value.getMessage()); - } - -} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbClearAlarmNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbClearAlarmNodeTest.java new file mode 100644 index 0000000000..afab0a0a99 --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbClearAlarmNodeTest.java @@ -0,0 +1,218 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS 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.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.Futures; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ListeningExecutor; +import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.rule.engine.api.RuleEngineAlarmService; +import org.thingsboard.rule.engine.api.ScriptEngine; +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.alarm.Alarm; +import org.thingsboard.server.common.data.alarm.AlarmApiCallResult; +import org.thingsboard.server.common.data.alarm.AlarmInfo; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.id.AlarmId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.script.ScriptLanguage; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; + +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TbClearAlarmNodeTest { + + @Mock + TbContext ctxMock; + @Mock + RuleEngineAlarmService alarmServiceMock; + @Mock + ScriptEngine alarmDetailsScriptMock; + + @Captor + ArgumentCaptor successCaptor; + @Captor + ArgumentCaptor> failureCaptor; + + TbClearAlarmNode node; + + final TenantId tenantId = TenantId.fromUUID(Uuids.timeBased()); + final EntityId msgOriginator = new DeviceId(Uuids.timeBased()); + final EntityId alarmOriginator = new AlarmId(Uuids.timeBased()); + TbMsgMetaData metadata; + + ListeningExecutor dbExecutor; + + @BeforeEach + void before() { + dbExecutor = new TestDbCallbackExecutor(); + metadata = new TbMsgMetaData(); + } + + @Test + void alarmCanBeCleared() { + initWithClearAlarmScript(); + metadata.putValue("key", "value"); + TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, msgOriginator, metadata, "{\"temperature\": 50}"); + + long oldEndDate = System.currentTimeMillis(); + Alarm activeAlarm = Alarm.builder().type("SomeType").tenantId(tenantId).originator(msgOriginator).severity(AlarmSeverity.WARNING).endTs(oldEndDate).build(); + + Alarm expectedAlarm = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(true) + .severity(AlarmSeverity.WARNING) + .propagate(false) + .type("SomeType") + .details(null) + .endTs(oldEndDate) + .build(); + + when(alarmDetailsScriptMock.executeJsonAsync(msg)).thenReturn(Futures.immediateFuture(null)); + when(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, "SomeType")).thenReturn(activeAlarm); + when(alarmServiceMock.clearAlarm(eq(activeAlarm.getTenantId()), eq(activeAlarm.getId()), anyLong(), nullable(JsonNode.class))) + .thenReturn(AlarmApiCallResult.builder() + .successful(true) + .cleared(true) + .alarm(new AlarmInfo(expectedAlarm)) + .build()); + + node.onMsg(ctxMock, msg); + + verify(ctxMock).enqueue(any(), successCaptor.capture(), failureCaptor.capture()); + successCaptor.getValue().run(); + verify(ctxMock).tellNext(any(), eq("Cleared")); + + ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); + ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(TbMsgType.class); + ArgumentCaptor originatorCaptor = ArgumentCaptor.forClass(EntityId.class); + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); + verify(ctxMock).transformMsg(msgCaptor.capture(), typeCaptor.capture(), originatorCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); + + assertThat(TbMsgType.ALARM).isEqualTo(typeCaptor.getValue()); + assertThat(msgOriginator).isEqualTo(originatorCaptor.getValue()); + assertThat("value").isEqualTo(metadataCaptor.getValue().getValue("key")); + assertThat(Boolean.TRUE.toString()).isEqualTo(metadataCaptor.getValue().getValue(DataConstants.IS_CLEARED_ALARM)); + assertThat(metadata).isNotSameAs(metadataCaptor.getValue()); + + Alarm actualAlarm = JacksonUtil.fromBytes(dataCaptor.getValue().getBytes(), Alarm.class); + assertThat(actualAlarm).isEqualTo(expectedAlarm); + } + + @Test + void alarmCanBeClearedWithAlarmOriginator() { + initWithClearAlarmScript(); + metadata.putValue("key", "value"); + TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, alarmOriginator, metadata, "{\"temperature\": 50}"); + + long oldEndDate = System.currentTimeMillis(); + AlarmId id = new AlarmId(alarmOriginator.getId()); + Alarm activeAlarm = Alarm.builder().type("SomeType").tenantId(tenantId).originator(msgOriginator).severity(AlarmSeverity.WARNING).endTs(oldEndDate).build(); + activeAlarm.setId(id); + + Alarm expectedAlarm = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(true) + .severity(AlarmSeverity.WARNING) + .propagate(false) + .type("SomeType") + .details(null) + .endTs(oldEndDate) + .build(); + expectedAlarm.setId(id); + + when(alarmDetailsScriptMock.executeJsonAsync(msg)).thenReturn(Futures.immediateFuture(null)); + when(alarmServiceMock.findAlarmById(tenantId, id)).thenReturn(activeAlarm); + when(alarmServiceMock.clearAlarm(eq(activeAlarm.getTenantId()), eq(activeAlarm.getId()), anyLong(), nullable(JsonNode.class))) + .thenReturn(AlarmApiCallResult.builder() + .successful(true) + .cleared(true) + .alarm(new AlarmInfo(expectedAlarm)) + .build()); + + node.onMsg(ctxMock, msg); + + verify(ctxMock).enqueue(any(), successCaptor.capture(), failureCaptor.capture()); + successCaptor.getValue().run(); + verify(ctxMock).tellNext(any(), eq("Cleared")); + + ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); + ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(TbMsgType.class); + ArgumentCaptor originatorCaptor = ArgumentCaptor.forClass(EntityId.class); + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); + verify(ctxMock).transformMsg(msgCaptor.capture(), typeCaptor.capture(), originatorCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); + + assertThat(TbMsgType.ALARM).isEqualTo(typeCaptor.getValue()); + assertThat(alarmOriginator).isEqualTo(originatorCaptor.getValue()); + assertThat("value").isEqualTo(metadataCaptor.getValue().getValue("key")); + assertThat(Boolean.TRUE.toString()).isEqualTo(metadataCaptor.getValue().getValue(DataConstants.IS_CLEARED_ALARM)); + assertThat(metadata).isNotSameAs(metadataCaptor.getValue()); + + Alarm actualAlarm = JacksonUtil.fromBytes(dataCaptor.getValue().getBytes(), Alarm.class); + assertThat(actualAlarm).isEqualTo(expectedAlarm); + } + + private void initWithClearAlarmScript() { + try { + TbClearAlarmNodeConfiguration config = new TbClearAlarmNodeConfiguration(); + config.setAlarmType("SomeType"); + config.setScriptLang(ScriptLanguage.JS); + config.setAlarmDetailsBuildJs("DETAILS"); + TbNodeConfiguration nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + + when(ctxMock.createScriptEngine(ScriptLanguage.JS, "DETAILS")).thenReturn(alarmDetailsScriptMock); + + when(ctxMock.getTenantId()).thenReturn(tenantId); + when(ctxMock.getAlarmService()).thenReturn(alarmServiceMock); + when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); + + node = new TbClearAlarmNode(); + node.init(ctxMock, nodeConfiguration); + } catch (TbNodeException ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java new file mode 100644 index 0000000000..c0e27231c4 --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java @@ -0,0 +1,1223 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS 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.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.Futures; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ListeningExecutor; +import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.rule.engine.api.RuleEngineAlarmService; +import org.thingsboard.rule.engine.api.ScriptEngine; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNodeConfiguration; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.alarm.AlarmApiCallResult; +import org.thingsboard.server.common.data.alarm.AlarmCreateOrUpdateActiveRequest; +import org.thingsboard.server.common.data.alarm.AlarmInfo; +import org.thingsboard.server.common.data.alarm.AlarmPropagationInfo; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.alarm.AlarmUpdateRequest; +import org.thingsboard.server.common.data.id.AlarmId; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.msg.TbNodeConnectionType; +import org.thingsboard.server.common.data.script.ScriptLanguage; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class TbCreateAlarmNodeTest { + + @Mock + TbContext ctxMock; + @Mock + RuleEngineAlarmService alarmServiceMock; + @Mock + ScriptEngine alarmDetailsScriptMock; + @Mock + TbMsg alarmActionMsgMock; + + @Captor + ArgumentCaptor successCaptor; + + @Spy + TbCreateAlarmNode nodeSpy; + TbCreateAlarmNodeConfiguration config; + + final TenantId tenantId = TenantId.fromUUID(Uuids.timeBased()); + final EntityId msgOriginator = new DeviceId(Uuids.timeBased()); + TbMsgMetaData metadata; + + ListeningExecutor dbExecutor; + + @BeforeEach + void before() { + dbExecutor = new TestDbCallbackExecutor(); + metadata = new TbMsgMetaData(); + config = new TbCreateAlarmNodeConfiguration(); + } + + @Test + @DisplayName("When defaultConfiguration() is called, then correct values are set.") + void whenDefaultConfiguration_thenShouldSetCorrectValues() { + // GIVEN-WHEN + config = config.defaultConfiguration(); + + // THEN + assertThat(config.getAlarmType()).isEqualTo("General Alarm"); + assertThat(config.getScriptLang()).isEqualTo(ScriptLanguage.TBEL); + assertThat(config.getAlarmDetailsBuildJs()).isEqualTo(""" + \ + var details = {}; + if (metadata.prevAlarmDetails) { + details = JSON.parse(metadata.prevAlarmDetails); + //remove prevAlarmDetails from metadata + delete metadata.prevAlarmDetails; + //now metadata is the same as it comes IN this rule node + } + + + return details;"""); + assertThat(config.getAlarmDetailsBuildTbel()).isEqualTo(""" + \ + var details = {}; + if (metadata.prevAlarmDetails != null) { + details = JSON.parse(metadata.prevAlarmDetails); + //remove prevAlarmDetails from metadata + metadata.remove('prevAlarmDetails'); + //now metadata is the same as it comes IN this rule node + } + + + return details;"""); + assertThat(config.getSeverity()).isEqualTo(AlarmSeverity.CRITICAL.name()); + assertThat(config.isPropagate()).isFalse(); + assertThat(config.isPropagateToOwner()).isFalse(); + assertThat(config.isPropagateToTenant()).isFalse(); + assertThat(config.isUseMessageAlarmData()).isFalse(); + assertThat(config.isOverwriteAlarmDetails()).isFalse(); + assertThat(config.isDynamicSeverity()).isFalse(); + assertThat(config.getRelationTypes()).isEmpty(); + } + + @Test + @DisplayName("When node is taking alarm info from default node config and alarm does not exist, then should create new alarm using info from default config.") + void whenAlarmDataIsTakenFromDefaultNodeConfigAndAlarmDoesNotExist_thenNewAlarmIsCreated() throws Exception { + // GIVEN + + // node configuration + config = config.defaultConfiguration(); + + // other values + String alarmType = config.getAlarmType(); + AlarmSeverity alarmSeverity = AlarmSeverity.valueOf(config.getSeverity()); + JsonNode alarmDetails = JacksonUtil.newObjectNode(); + + long metadataTs = 1711631716127L; + metadata.putValue("ts", Long.toString(metadataTs)); + metadata.putValue("location", "Company office"); + + var ruleNodeSelfId = new RuleNodeId(Uuids.timeBased()); + + var incomingMsg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, msgOriginator, metadata, "{\"temperature\": 50}"); + + Alarm existingAlarm = null; + + // expected values + var expectedAlarm = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(false) + .acknowledged(false) + .severity(alarmSeverity) + .propagate(false) + .propagateToOwner(false) + .propagateToTenant(false) + .propagateRelationTypes(Collections.emptyList()) + .type(alarmType) + .startTs(metadataTs) + .endTs(metadataTs) + .details(alarmDetails) + .build(); + var expectedCreatedAlarmInfo = new AlarmInfo(expectedAlarm); + expectedCreatedAlarmInfo.setId(new AlarmId(Uuids.timeBased())); + + var expectedCreateAlarmRequest = AlarmCreateOrUpdateActiveRequest.builder() + .tenantId(tenantId) + .customerId(null) + .type(alarmType) + .originator(msgOriginator) + .severity(alarmSeverity) + .startTs(metadataTs) + .endTs(metadataTs) + .details(alarmDetails) + .propagation(AlarmPropagationInfo.builder() + .propagate(false) + .propagateToOwner(false) + .propagateToTenant(false) + .propagateRelationTypes(Collections.emptyList()).build()) + .userId(null) + .edgeAlarmId(null) + .build(); + + // mocks + given(ctxMock.getTenantId()).willReturn(tenantId); + given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); + given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); + given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); + given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingAlarm); + given(alarmDetailsScriptMock.executeJsonAsync(incomingMsg)).willReturn(Futures.immediateFuture(alarmDetails)); + var apiCallResult = AlarmApiCallResult.builder() + .successful(true) + .created(true) + .modified(false) + .cleared(false) + .deleted(false) + .alarm(expectedCreatedAlarmInfo) + .old(null) + .propagatedEntitiesList(Collections.emptyList()) + .build(); + given(alarmServiceMock.createAlarm(expectedCreateAlarmRequest)).willReturn(apiCallResult); + given(ctxMock.alarmActionMsg(expectedCreatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_CREATED)).willReturn(alarmActionMsgMock); + given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) + .willAnswer(answer -> TbMsg.transformMsg( + answer.getArgument(0, TbMsg.class), + answer.getArgument(1, TbMsgType.class), + answer.getArgument(2, EntityId.class), + answer.getArgument(3, TbMsgMetaData.class), + answer.getArgument(4, String.class)) + ); + given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, TbAbstractAlarmNodeConfiguration.ALARM_DETAILS_BUILD_TBEL_TEMPLATE)).willReturn(alarmDetailsScriptMock); + + // node initialization + nodeSpy.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + // WHEN + nodeSpy.onMsg(ctxMock, incomingMsg); + + // THEN + + // verify alarm details script evaluation + then(ctxMock).should().logJsEvalRequest(); + then(alarmDetailsScriptMock).should().executeJsonAsync(incomingMsg); + then(ctxMock).should().logJsEvalResponse(); + + // verify we called createAlarm() with correct AlarmCreateOrUpdateActiveRequest + then(alarmServiceMock).should().createAlarm(expectedCreateAlarmRequest); + then(alarmServiceMock).should(never()).updateAlarm(any()); + + // verify that we created a correct alarm action message and enqueued it + then(ctxMock).should().alarmActionMsg(expectedCreatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_CREATED); + then(ctxMock).should().enqueue(eq(alarmActionMsgMock), successCaptor.capture(), any()); + + // run success captor to emulate successful sending and to trigger further processing on the success path + successCaptor.getValue().run(); + + // capture and verify an outgoing message + var outgoingMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + then(ctxMock).should().tellNext(outgoingMsgCaptor.capture(), eq("Created")); + var actualOutgoingMsg = outgoingMsgCaptor.getValue(); + assertThat(actualOutgoingMsg.getType()).isEqualTo(TbMsgType.ALARM.name()); + assertThat(actualOutgoingMsg.getOriginator()).isEqualTo(msgOriginator); + assertThat(actualOutgoingMsg.getData()).isEqualTo(JacksonUtil.valueToTree(expectedCreatedAlarmInfo).toString()); + + Map actualOutgoingMsgMetadataContent = actualOutgoingMsg.getMetaData().getData(); + assertThat(actualOutgoingMsgMetadataContent).containsAllEntriesOf(metadata.getData()); + assertThat(actualOutgoingMsgMetadataContent).containsEntry(DataConstants.IS_NEW_ALARM, Boolean.TRUE.toString()); + assertThat(actualOutgoingMsgMetadataContent).size().isEqualTo(metadata.getData().size() + 1); + + // verify wrong processing paths were not taken + then(ctxMock).should(never()).tellNext(any(), eq(TbNodeConnectionType.FALSE)); + then(ctxMock).should(never()).tellNext(any(), eq("Updated")); + then(ctxMock).should(never()).tellNext(any(), eq("Cleared")); + then(ctxMock).should(never()).tellSuccess(any()); + then(ctxMock).should(never()).tellFailure(any(), any()); + } + + @Test + @DisplayName("When node is taking alarm info from node config and cleared alarm exists, then should create new alarm using info from config.") + void whenAlarmDataIsTakenFromNodeConfigAndClearedAlarmExists_thenNewAlarmIsCreated() throws Exception { + // GIVEN + + // node configuration + config.setAlarmType("$[alarmType]"); + config.setScriptLang(ScriptLanguage.JS); + config.setAlarmDetailsBuildJs(""" + return { + alarmDetails: "Some alarm details" + }; + """); + config.setAlarmDetailsBuildTbel(""" + return { + alarmDetails: "Some alarm details" + }; + """); + config.setDynamicSeverity(true); + config.setSeverity("${alarmSeverity}"); + config.setPropagate(true); + config.setPropagateToOwner(true); + config.setPropagateToTenant(true); + config.setRelationTypes(List.of("RELATION_TYPE_1", "RELATION_TYPE_2", "RELATION_TYPE_3")); + config.setUseMessageAlarmData(false); + config.setOverwriteAlarmDetails(false); + + // other values + String alarmType = "High Temperature"; + AlarmSeverity alarmSeverity = AlarmSeverity.MAJOR; + JsonNode alarmDetails = JacksonUtil.newObjectNode().put("alarmDetails", "Some alarm details"); + + long metadataTs = 1711631716127L; + metadata.putValue("ts", Long.toString(metadataTs)); + metadata.putValue("alarmSeverity", alarmSeverity.name()); + metadata.putValue("location", "Company office"); + + var ruleNodeSelfId = new RuleNodeId(Uuids.timeBased()); + + var incomingMsg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, msgOriginator, metadata, "{\"temperature\": 50, \"alarmType\": \"" + alarmType + "\"}"); + + var existingClearedAlarm = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(true) + .acknowledged(false) + .severity(AlarmSeverity.WARNING) + .propagate(false) + .propagateToOwner(false) + .propagateToTenant(false) + .propagateRelationTypes(Collections.emptyList()) + .type(alarmType) + .startTs(100L) + .endTs(200L) + .details(JacksonUtil.newObjectNode().put("oldAlarmDetailsProperty", "oldAlarmDetailsPropertyValue")) + .build(); + existingClearedAlarm.setId(new AlarmId(Uuids.timeBased())); + + // expected values + var expectedCreatedAlarm = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(false) + .acknowledged(false) + .severity(alarmSeverity) + .propagate(true) + .propagateToOwner(true) + .propagateToTenant(true) + .propagateRelationTypes(config.getRelationTypes()) + .type(alarmType) + .startTs(metadataTs) + .endTs(metadataTs) + .details(alarmDetails) + .build(); + var expectedCreatedAlarmInfo = new AlarmInfo(expectedCreatedAlarm); + expectedCreatedAlarmInfo.setId(new AlarmId(Uuids.timeBased())); + + var expectedCreateAlarmRequest = AlarmCreateOrUpdateActiveRequest.builder() + .tenantId(tenantId) + .customerId(null) + .type(alarmType) + .originator(msgOriginator) + .severity(alarmSeverity) + .startTs(metadataTs) + .endTs(metadataTs) + .details(alarmDetails) + .propagation(AlarmPropagationInfo.builder() + .propagate(true) + .propagateToOwner(true) + .propagateToTenant(true) + .propagateRelationTypes(config.getRelationTypes()).build()) + .userId(null) + .edgeAlarmId(null) + .build(); + + // mocks + given(ctxMock.getTenantId()).willReturn(tenantId); + given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); + given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); + given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); + given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingClearedAlarm); + given(alarmDetailsScriptMock.executeJsonAsync(incomingMsg)).willReturn(Futures.immediateFuture(alarmDetails)); + var apiCallResult = AlarmApiCallResult.builder() + .successful(true) + .created(true) + .modified(false) + .cleared(false) + .deleted(false) + .alarm(expectedCreatedAlarmInfo) + .old(null) + .propagatedEntitiesList(List.of(TenantId.fromUUID(Uuids.timeBased()), new CustomerId(Uuids.timeBased()), new AssetId(Uuids.timeBased()))) + .build(); + given(alarmServiceMock.createAlarm(expectedCreateAlarmRequest)).willReturn(apiCallResult); + given(ctxMock.alarmActionMsg(expectedCreatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_CREATED)).willReturn(alarmActionMsgMock); + given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) + .willAnswer(answer -> TbMsg.transformMsg( + answer.getArgument(0, TbMsg.class), + answer.getArgument(1, TbMsgType.class), + answer.getArgument(2, EntityId.class), + answer.getArgument(3, TbMsgMetaData.class), + answer.getArgument(4, String.class)) + ); + given(ctxMock.createScriptEngine(ScriptLanguage.JS, config.getAlarmDetailsBuildJs())).willReturn(alarmDetailsScriptMock); + + // node initialization + nodeSpy.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + // WHEN + nodeSpy.onMsg(ctxMock, incomingMsg); + + // THEN + + // verify alarm details script evaluation + then(ctxMock).should().logJsEvalRequest(); + then(alarmDetailsScriptMock).should().executeJsonAsync(incomingMsg); + then(ctxMock).should().logJsEvalResponse(); + + // verify we called createAlarm() with correct AlarmCreateOrUpdateActiveRequest + then(alarmServiceMock).should().createAlarm(expectedCreateAlarmRequest); + then(alarmServiceMock).should(never()).updateAlarm(any()); + + // verify that we created a correct alarm action message and enqueued it + then(ctxMock).should().alarmActionMsg(expectedCreatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_CREATED); + then(ctxMock).should().enqueue(eq(alarmActionMsgMock), successCaptor.capture(), any()); + + // run success captor to emulate successful sending and to trigger further processing on the success path + successCaptor.getValue().run(); + + // capture and verify an outgoing message + var outgoingMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + then(ctxMock).should().tellNext(outgoingMsgCaptor.capture(), eq("Created")); + var actualOutgoingMsg = outgoingMsgCaptor.getValue(); + assertThat(actualOutgoingMsg.getType()).isEqualTo(TbMsgType.ALARM.name()); + assertThat(actualOutgoingMsg.getOriginator()).isEqualTo(msgOriginator); + assertThat(actualOutgoingMsg.getData()).isEqualTo(JacksonUtil.valueToTree(expectedCreatedAlarmInfo).toString()); + + Map actualOutgoingMsgMetadataContent = actualOutgoingMsg.getMetaData().getData(); + assertThat(actualOutgoingMsgMetadataContent).containsAllEntriesOf(metadata.getData()); + assertThat(actualOutgoingMsgMetadataContent).containsEntry(DataConstants.IS_NEW_ALARM, Boolean.TRUE.toString()); + assertThat(actualOutgoingMsgMetadataContent).size().isEqualTo(metadata.getData().size() + 1); + + // verify wrong processing paths were not taken + then(ctxMock).should(never()).tellNext(any(), eq(TbNodeConnectionType.FALSE)); + then(ctxMock).should(never()).tellNext(any(), eq("Updated")); + then(ctxMock).should(never()).tellNext(any(), eq("Cleared")); + then(ctxMock).should(never()).tellSuccess(any()); + then(ctxMock).should(never()).tellFailure(any(), any()); + } + + @Test + @DisplayName("When node is taking alarm info from node config and active alarm exists, then should update existing alarm using info from config.") + void whenAlarmDataIsTakenFromNodeConfigAndActiveAlarmExists_thenExistingAlarmIsUpdated() throws Exception { + // GIVEN + + // values that changed between existing alarm and updated alarm + AlarmSeverity oldAlarmSeverity = AlarmSeverity.WARNING; + AlarmSeverity newAlarmSeverity = AlarmSeverity.MAJOR; + + boolean oldPropagate = true; + boolean newPropagate = false; + + boolean oldPropagateToOwner = false; + boolean newPropagateToOwner = true; + + boolean oldPropagateToTenant = false; + boolean newPropagateToTenant = true; + + List oldPropagateRelationTypes = List.of("RELATION_TYPE_1", "RELATION_TYPE_2", "RELATION_TYPE_3"); + List newPropagateRelationTypes = Collections.emptyList(); + + JsonNode oldAlarmDetails = JacksonUtil.newObjectNode().put("oldAlarmDetailsKey", "oldAlarmDetailsValue"); + JsonNode newAlarmDetails = JacksonUtil.newObjectNode().put("newAlarmDetails", "Some alarm details TBEL").set("oldAlarmDetails", oldAlarmDetails); + + long oldEndTs = 200L; + long newEndTs = 300L; + + // node configuration + config.setAlarmType("${alarmType}"); + config.setScriptLang(ScriptLanguage.TBEL); + config.setAlarmDetailsBuildJs(""" + return { + oldAlarmDetails: metadata.prevAlarmDetails, + newAlarmDetails: "Some alarm details JS" + }; + """); + config.setAlarmDetailsBuildTbel(""" + return { + oldAlarmDetails: metadata.prevAlarmDetails, + newAlarmDetails: "Some alarm details TBEL" + }; + """); + config.setDynamicSeverity(true); + config.setSeverity("$[alarmSeverity]"); + config.setPropagate(newPropagate); + config.setPropagateToOwner(newPropagateToOwner); + config.setPropagateToTenant(newPropagateToTenant); + config.setRelationTypes(newPropagateRelationTypes); + config.setUseMessageAlarmData(false); + config.setOverwriteAlarmDetails(false); + + // other values + String alarmType = "High Temperature"; + + long metadataTs = 1711631716127L; + metadata.putValue("ts", Long.toString(metadataTs)); + metadata.putValue("alarmType", alarmType); + metadata.putValue("location", "Company office"); + + var ruleNodeSelfId = new RuleNodeId(Uuids.timeBased()); + + var incomingMsg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, msgOriginator, metadata, "{\"temperature\": 50, \"alarmSeverity\": \"" + newAlarmSeverity.name() + "\"}"); + + var existingAlarmId = new AlarmId(Uuids.timeBased()); + var existingActiveAlarm = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(false) + .acknowledged(false) + .severity(oldAlarmSeverity) + .propagate(oldPropagate) + .propagateToOwner(oldPropagateToOwner) + .propagateToTenant(oldPropagateToTenant) + .propagateRelationTypes(oldPropagateRelationTypes) + .type(alarmType) + .startTs(100L) + .endTs(oldEndTs) + .details(oldAlarmDetails) + .build(); + existingActiveAlarm.setId(existingAlarmId); + + // expected values + var expectedUpdatedAlarm = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(false) + .acknowledged(false) + .severity(newAlarmSeverity) + .propagate(newPropagate) + .propagateToOwner(newPropagateToOwner) + .propagateToTenant(newPropagateToTenant) + .propagateRelationTypes(newPropagateRelationTypes) + .type(alarmType) + .startTs(100L) + .endTs(newEndTs) + .details(newAlarmDetails) + .build(); + expectedUpdatedAlarm.setId(existingAlarmId); + var expectedUpdatedAlarmInfo = new AlarmInfo(expectedUpdatedAlarm); + + var expectedUpdateAlarmRequest = AlarmUpdateRequest.builder() + .tenantId(tenantId) + .alarmId(existingAlarmId) + .severity(newAlarmSeverity) + .startTs(100L) + .endTs(newEndTs) + .details(newAlarmDetails) + .propagation(AlarmPropagationInfo.builder() + .propagate(newPropagate) + .propagateToOwner(newPropagateToOwner) + .propagateToTenant(newPropagateToTenant) + .propagateRelationTypes(newPropagateRelationTypes).build()) + .userId(null) + .build(); + + // mocks + given(ctxMock.getTenantId()).willReturn(tenantId); + given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); + given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); + given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); + given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingActiveAlarm); + given(alarmDetailsScriptMock.executeJsonAsync(any())).willReturn(Futures.immediateFuture(newAlarmDetails)); + doReturn(newEndTs).when(nodeSpy).currentTimeMillis(); + var apiCallResult = AlarmApiCallResult.builder() + .successful(true) + .created(false) + .modified(true) + .cleared(false) + .deleted(false) + .alarm(expectedUpdatedAlarmInfo) + .old(new Alarm(existingActiveAlarm)) + .propagatedEntitiesList(List.of(tenantId)) + .build(); + given(alarmServiceMock.updateAlarm(expectedUpdateAlarmRequest)).willReturn(apiCallResult); + given(ctxMock.alarmActionMsg(expectedUpdatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_UPDATED)).willReturn(alarmActionMsgMock); + given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) + .willAnswer(answer -> TbMsg.transformMsg( + answer.getArgument(0, TbMsg.class), + answer.getArgument(1, TbMsgType.class), + answer.getArgument(2, EntityId.class), + answer.getArgument(3, TbMsgMetaData.class), + answer.getArgument(4, String.class)) + ); + given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, config.getAlarmDetailsBuildTbel())).willReturn(alarmDetailsScriptMock); + + // node initialization + nodeSpy.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + // WHEN + nodeSpy.onMsg(ctxMock, incomingMsg); + + // THEN + + // verify alarm details script evaluation + then(ctxMock).should().logJsEvalRequest(); + var dummyMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + then(alarmDetailsScriptMock).should().executeJsonAsync(dummyMsgCaptor.capture()); + TbMsg actualDummyMsg = dummyMsgCaptor.getValue(); + assertThat(actualDummyMsg.getType()).isEqualTo(incomingMsg.getType()); + assertThat(actualDummyMsg.getData()).isEqualTo(incomingMsg.getData()); + assertThat(actualDummyMsg.getMetaData().getData()).containsEntry("prevAlarmDetails", JacksonUtil.toString(oldAlarmDetails)); + then(ctxMock).should().logJsEvalResponse(); + + // verify we called updateAlarm() with correct AlarmUpdateRequest + then(alarmServiceMock).should().updateAlarm(expectedUpdateAlarmRequest); + then(alarmServiceMock).should(never()).createAlarm(any()); + + // verify that we created a correct alarm action message and enqueued it + then(ctxMock).should().alarmActionMsg(expectedUpdatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_UPDATED); + then(ctxMock).should().enqueue(eq(alarmActionMsgMock), successCaptor.capture(), any()); + + // run success captor to emulate successful queueing of an alarm action message and to trigger further processing on the success path + successCaptor.getValue().run(); + + // capture and verify an outgoing message + var outgoingMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + then(ctxMock).should().tellNext(outgoingMsgCaptor.capture(), eq("Updated")); + var actualOutgoingMsg = outgoingMsgCaptor.getValue(); + assertThat(actualOutgoingMsg.getType()).isEqualTo(TbMsgType.ALARM.name()); + assertThat(actualOutgoingMsg.getOriginator()).isEqualTo(msgOriginator); + assertThat(actualOutgoingMsg.getData()).isEqualTo(JacksonUtil.valueToTree(expectedUpdatedAlarmInfo).toString()); + + Map actualOutgoingMsgMetadataContent = actualOutgoingMsg.getMetaData().getData(); + assertThat(actualOutgoingMsgMetadataContent).containsAllEntriesOf(metadata.getData()); + assertThat(actualOutgoingMsgMetadataContent).containsEntry(DataConstants.IS_EXISTING_ALARM, Boolean.TRUE.toString()); + assertThat(actualOutgoingMsgMetadataContent).size().isEqualTo(metadata.getData().size() + 1); + + // verify wrong processing paths were not taken + then(ctxMock).should(never()).tellNext(any(), eq(TbNodeConnectionType.FALSE)); + then(ctxMock).should(never()).tellNext(any(), eq("Created")); + then(ctxMock).should(never()).tellNext(any(), eq("Cleared")); + then(ctxMock).should(never()).tellSuccess(any()); + then(ctxMock).should(never()).tellFailure(any(), any()); + } + + @Test + @DisplayName("When node is taking alarm data from incoming message and cleared alarm exists, then should create new alarm using info from incoming message.") + void whenAlarmDataIsTakenFromMsgAndClearedAlarmExists_thenNewAlarmIsCreated() throws Exception { + // GIVEN + + // node configuration + config = config.defaultConfiguration(); + config.setUseMessageAlarmData(true); + config.setOverwriteAlarmDetails(false); + + // other values + String alarmType = "High Temperature"; + AlarmSeverity alarmSeverity = AlarmSeverity.MAJOR; + JsonNode alarmDetails = JacksonUtil.newObjectNode().put("alarmDetails", "Some alarm details"); + + // alarm that is inside an incoming message + var alarmFromIncomingMessage = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(false) + .acknowledged(false) + .severity(alarmSeverity) + .propagate(true) + .propagateToOwner(true) + .propagateToTenant(true) + .propagateRelationTypes(Collections.emptyList()) + .type(alarmType) + .startTs(100L) + .endTs(300L) + .details(alarmDetails) + .build(); + + long metadataTs = 1711631716127L; + metadata.putValue("ts", Long.toString(metadataTs)); + metadata.putValue("location", "Company office"); + + var ruleNodeSelfId = new RuleNodeId(Uuids.timeBased()); + + var incomingMsg = TbMsg.newMsg(TbMsgType.ALARM, msgOriginator, metadata, JacksonUtil.toString(alarmFromIncomingMessage)); + + var existingClearedAlarm = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(true) + .acknowledged(false) + .severity(AlarmSeverity.WARNING) + .propagate(false) + .propagateToOwner(true) + .propagateToTenant(true) + .propagateRelationTypes(Collections.emptyList()) + .type(alarmType) + .startTs(100L) + .endTs(200L) + .details(JacksonUtil.newObjectNode()) + .build(); + existingClearedAlarm.setId(new AlarmId(Uuids.timeBased())); + + // expected values + var expectedCreatedAlarm = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(false) + .acknowledged(false) + .severity(alarmSeverity) + .propagate(true) + .propagateToOwner(true) + .propagateToTenant(true) + .propagateRelationTypes(Collections.emptyList()) + .type(alarmType) + .startTs(100L) + .endTs(300L) + .details(alarmDetails) + .build(); + var expectedCreatedAlarmInfo = new AlarmInfo(expectedCreatedAlarm); + expectedCreatedAlarmInfo.setId(new AlarmId(Uuids.timeBased())); + + var expectedCreateAlarmRequest = AlarmCreateOrUpdateActiveRequest.builder() + .tenantId(tenantId) + .customerId(null) + .type(alarmType) + .originator(msgOriginator) + .severity(alarmSeverity) + .startTs(100L) + .endTs(300L) + .details(alarmDetails) + .propagation(AlarmPropagationInfo.builder() + .propagate(true) + .propagateToOwner(true) + .propagateToTenant(true) + .propagateRelationTypes(Collections.emptyList()).build()) + .userId(null) + .edgeAlarmId(null) + .build(); + + // mocks + given(ctxMock.getTenantId()).willReturn(tenantId); + given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); + given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); + given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); + given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingClearedAlarm); + var apiCallResult = AlarmApiCallResult.builder() + .successful(true) + .created(true) + .modified(false) + .cleared(false) + .deleted(false) + .alarm(expectedCreatedAlarmInfo) + .old(null) + .propagatedEntitiesList(List.of(TenantId.fromUUID(Uuids.timeBased()), new CustomerId(Uuids.timeBased()), new AssetId(Uuids.timeBased()))) + .build(); + given(alarmServiceMock.createAlarm(expectedCreateAlarmRequest)).willReturn(apiCallResult); + given(ctxMock.alarmActionMsg(expectedCreatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_CREATED)).willReturn(alarmActionMsgMock); + given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) + .willAnswer(answer -> TbMsg.transformMsg( + answer.getArgument(0, TbMsg.class), + answer.getArgument(1, TbMsgType.class), + answer.getArgument(2, EntityId.class), + answer.getArgument(3, TbMsgMetaData.class), + answer.getArgument(4, String.class)) + ); + given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, config.getAlarmDetailsBuildTbel())).willReturn(alarmDetailsScriptMock); + + // node initialization + nodeSpy.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + // WHEN + nodeSpy.onMsg(ctxMock, incomingMsg); + + // THEN + + // verify alarm details script was not evaluated + then(ctxMock).should(never()).logJsEvalRequest(); + then(alarmDetailsScriptMock).should(never()).executeJsonAsync(any()); + then(ctxMock).should(never()).logJsEvalResponse(); + + // verify we called createAlarm() with correct AlarmCreateOrUpdateActiveRequest + then(alarmServiceMock).should().createAlarm(expectedCreateAlarmRequest); + then(alarmServiceMock).should(never()).updateAlarm(any()); + + // verify that we created a correct alarm action message and enqueued it + then(ctxMock).should().alarmActionMsg(expectedCreatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_CREATED); + then(ctxMock).should().enqueue(eq(alarmActionMsgMock), successCaptor.capture(), any()); + + // run success captor to emulate successful sending and to trigger further processing on the success path + successCaptor.getValue().run(); + + // capture and verify an outgoing message + var outgoingMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + then(ctxMock).should().tellNext(outgoingMsgCaptor.capture(), eq("Created")); + var actualOutgoingMsg = outgoingMsgCaptor.getValue(); + assertThat(actualOutgoingMsg.getType()).isEqualTo(TbMsgType.ALARM.name()); + assertThat(actualOutgoingMsg.getOriginator()).isEqualTo(msgOriginator); + assertThat(actualOutgoingMsg.getData()).isEqualTo(JacksonUtil.valueToTree(expectedCreatedAlarmInfo).toString()); + + Map actualOutgoingMsgMetadataContent = actualOutgoingMsg.getMetaData().getData(); + assertThat(actualOutgoingMsgMetadataContent).containsAllEntriesOf(metadata.getData()); + assertThat(actualOutgoingMsgMetadataContent).containsEntry(DataConstants.IS_NEW_ALARM, Boolean.TRUE.toString()); + assertThat(actualOutgoingMsgMetadataContent).size().isEqualTo(metadata.getData().size() + 1); + + // verify wrong processing paths were not taken + then(ctxMock).should(never()).tellNext(any(), eq(TbNodeConnectionType.FALSE)); + then(ctxMock).should(never()).tellNext(any(), eq("Updated")); + then(ctxMock).should(never()).tellNext(any(), eq("Cleared")); + then(ctxMock).should(never()).tellSuccess(any()); + then(ctxMock).should(never()).tellFailure(any(), any()); + } + + @Test + @DisplayName("When node is taking alarm data from incoming message and active alarm exists, then should update existing alarm using info from incoming message.") + void whenAlarmDataIsTakenFromMsgAndActiveAlarmExists_thenExistingAlarmIsUpdated() throws Exception { + // GIVEN + + // values that changed between existing alarm and updated alarm + AlarmSeverity oldAlarmSeverity = AlarmSeverity.WARNING; + AlarmSeverity newAlarmSeverity = AlarmSeverity.MAJOR; + + boolean oldPropagate = true; + boolean newPropagate = false; + + boolean oldPropagateToOwner = false; + boolean newPropagateToOwner = true; + + boolean oldPropagateToTenant = false; + boolean newPropagateToTenant = true; + + List oldPropagateRelationTypes = List.of("RELATION_TYPE_1", "RELATION_TYPE_2", "RELATION_TYPE_3"); + List newPropagateRelationTypes = Collections.emptyList(); + + JsonNode oldAlarmDetails = JacksonUtil.newObjectNode().put("oldAlarmDetailsKey", "oldAlarmDetailsValue"); + JsonNode newAlarmDetails = JacksonUtil.newObjectNode().put("newAlarmDetails", "Some alarm details TBEL").set("oldAlarmDetails", oldAlarmDetails); + + long oldEndTs = 200L; + long newEndTs = 300L; + + // node configuration + config = config.defaultConfiguration(); + config.setUseMessageAlarmData(true); + config.setOverwriteAlarmDetails(true); + + // other values + String alarmType = "High Temperature"; + + long metadataTs = 1711631716127L; + metadata.putValue("ts", Long.toString(metadataTs)); + metadata.putValue("location", "Company office"); + + var ruleNodeSelfId = new RuleNodeId(Uuids.timeBased()); + + // alarm that is inside an incoming message + var alarmFromIncomingMessage = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(false) + .acknowledged(false) + .severity(newAlarmSeverity) + .propagate(newPropagate) + .propagateToOwner(newPropagateToOwner) + .propagateToTenant(newPropagateToTenant) + .propagateRelationTypes(newPropagateRelationTypes) + .type(alarmType) + .startTs(100L) + .endTs(newEndTs) + .details(newAlarmDetails) + .build(); + + var incomingMsg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, msgOriginator, metadata, JacksonUtil.toString(alarmFromIncomingMessage)); + + var existingAlarmId = new AlarmId(Uuids.timeBased()); + var existingActiveAlarm = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(false) + .acknowledged(false) + .severity(oldAlarmSeverity) + .propagate(oldPropagate) + .propagateToOwner(oldPropagateToOwner) + .propagateToTenant(oldPropagateToTenant) + .propagateRelationTypes(oldPropagateRelationTypes) + .type(alarmType) + .startTs(100L) + .endTs(oldEndTs) + .details(oldAlarmDetails) + .build(); + existingActiveAlarm.setId(existingAlarmId); + + // expected values + var expectedUpdatedAlarm = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(false) + .acknowledged(false) + .severity(newAlarmSeverity) + .propagate(newPropagate) + .propagateToOwner(newPropagateToOwner) + .propagateToTenant(newPropagateToTenant) + .propagateRelationTypes(newPropagateRelationTypes) + .type(alarmType) + .startTs(100L) + .endTs(newEndTs) + .details(newAlarmDetails) + .build(); + expectedUpdatedAlarm.setId(existingAlarmId); + var expectedUpdatedAlarmInfo = new AlarmInfo(expectedUpdatedAlarm); + + var expectedUpdateAlarmRequest = AlarmUpdateRequest.builder() + .tenantId(tenantId) + .alarmId(existingAlarmId) + .severity(newAlarmSeverity) + .startTs(100L) + .endTs(newEndTs) + .details(newAlarmDetails) + .propagation(AlarmPropagationInfo.builder() + .propagate(newPropagate) + .propagateToOwner(newPropagateToOwner) + .propagateToTenant(newPropagateToTenant) + .propagateRelationTypes(newPropagateRelationTypes).build()) + .userId(null) + .build(); + + // mocks + given(ctxMock.getTenantId()).willReturn(tenantId); + given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); + given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); + given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); + given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingActiveAlarm); + given(alarmDetailsScriptMock.executeJsonAsync(any())).willReturn(Futures.immediateFuture(newAlarmDetails)); + doReturn(newEndTs).when(nodeSpy).currentTimeMillis(); + var apiCallResult = AlarmApiCallResult.builder() + .successful(true) + .created(false) + .modified(true) + .cleared(false) + .deleted(false) + .alarm(expectedUpdatedAlarmInfo) + .old(new Alarm(existingActiveAlarm)) + .propagatedEntitiesList(List.of(tenantId)) + .build(); + given(alarmServiceMock.updateAlarm(expectedUpdateAlarmRequest)).willReturn(apiCallResult); + given(ctxMock.alarmActionMsg(expectedUpdatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_UPDATED)).willReturn(alarmActionMsgMock); + given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) + .willAnswer(answer -> TbMsg.transformMsg( + answer.getArgument(0, TbMsg.class), + answer.getArgument(1, TbMsgType.class), + answer.getArgument(2, EntityId.class), + answer.getArgument(3, TbMsgMetaData.class), + answer.getArgument(4, String.class)) + ); + given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, config.getAlarmDetailsBuildTbel())).willReturn(alarmDetailsScriptMock); + + // node initialization + nodeSpy.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + // WHEN + nodeSpy.onMsg(ctxMock, incomingMsg); + + // THEN + + // verify alarm details script evaluation + then(ctxMock).should().logJsEvalRequest(); + var dummyMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + then(alarmDetailsScriptMock).should().executeJsonAsync(dummyMsgCaptor.capture()); + TbMsg actualDummyMsg = dummyMsgCaptor.getValue(); + assertThat(actualDummyMsg.getType()).isEqualTo(incomingMsg.getType()); + assertThat(actualDummyMsg.getData()).isEqualTo(incomingMsg.getData()); + assertThat(actualDummyMsg.getMetaData().getData()).containsEntry("prevAlarmDetails", JacksonUtil.toString(oldAlarmDetails)); + then(ctxMock).should().logJsEvalResponse(); + + // verify we called updateAlarm() with correct AlarmUpdateRequest + then(alarmServiceMock).should().updateAlarm(expectedUpdateAlarmRequest); + then(alarmServiceMock).should(never()).createAlarm(any()); + + // verify that we created a correct alarm action message and enqueued it + then(ctxMock).should().alarmActionMsg(expectedUpdatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_UPDATED); + then(ctxMock).should().enqueue(eq(alarmActionMsgMock), successCaptor.capture(), any()); + + // run success captor to emulate successful queueing of an alarm action message and to trigger further processing on the success path + successCaptor.getValue().run(); + + // capture and verify an outgoing message + var outgoingMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + then(ctxMock).should().tellNext(outgoingMsgCaptor.capture(), eq("Updated")); + var actualOutgoingMsg = outgoingMsgCaptor.getValue(); + assertThat(actualOutgoingMsg.getType()).isEqualTo(TbMsgType.ALARM.name()); + assertThat(actualOutgoingMsg.getOriginator()).isEqualTo(msgOriginator); + assertThat(actualOutgoingMsg.getData()).isEqualTo(JacksonUtil.valueToTree(expectedUpdatedAlarmInfo).toString()); + + Map actualOutgoingMsgMetadataContent = actualOutgoingMsg.getMetaData().getData(); + assertThat(actualOutgoingMsgMetadataContent).containsAllEntriesOf(metadata.getData()); + assertThat(actualOutgoingMsgMetadataContent).containsEntry(DataConstants.IS_EXISTING_ALARM, Boolean.TRUE.toString()); + assertThat(actualOutgoingMsgMetadataContent).size().isEqualTo(metadata.getData().size() + 1); + + // verify wrong processing paths were not taken + then(ctxMock).should(never()).tellNext(any(), eq(TbNodeConnectionType.FALSE)); + then(ctxMock).should(never()).tellNext(any(), eq("Created")); + then(ctxMock).should(never()).tellNext(any(), eq("Cleared")); + then(ctxMock).should(never()).tellSuccess(any()); + then(ctxMock).should(never()).tellFailure(any(), any()); + } + + @Test + @DisplayName("When only severity was updated (other fields the same), then should consider this as an alarm update and take update processing path.") + void whenOnlySeverityWasUpdated_thenShouldTakeAlarmUpdatedPath() throws Exception { + // GIVEN + + // values that changed between existing alarm and updated alarm + AlarmSeverity oldAlarmSeverity = AlarmSeverity.WARNING; + AlarmSeverity newAlarmSeverity = AlarmSeverity.MAJOR; + + boolean propagate = true; + boolean propagateToOwner = false; + boolean propagateToTenant = false; + List propagateRelationTypes = List.of("RELATION_TYPE_1", "RELATION_TYPE_2", "RELATION_TYPE_3"); + JsonNode alarmDetails = JacksonUtil.newObjectNode().put("oldAlarmDetailsKey", "oldAlarmDetailsValue"); + long endTs = 200L; + + // node configuration + config = config.defaultConfiguration(); + config.setUseMessageAlarmData(true); + config.setOverwriteAlarmDetails(true); + + // other values + String alarmType = "High Temperature"; + + long metadataTs = 1711631716127L; + metadata.putValue("ts", Long.toString(metadataTs)); + metadata.putValue("location", "Company office"); + + var ruleNodeSelfId = new RuleNodeId(Uuids.timeBased()); + + // alarm that is inside an incoming message + var alarmFromIncomingMessage = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(false) + .acknowledged(false) + .severity(newAlarmSeverity) + .propagate(propagate) + .propagateToOwner(propagateToOwner) + .propagateToTenant(propagateToTenant) + .propagateRelationTypes(propagateRelationTypes) + .type(alarmType) + .startTs(100L) + .endTs(endTs) + .details(alarmDetails) + .build(); + + var incomingMsg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, msgOriginator, metadata, JacksonUtil.toString(alarmFromIncomingMessage)); + + var existingAlarmId = new AlarmId(Uuids.timeBased()); + var existingActiveAlarm = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(false) + .acknowledged(false) + .severity(oldAlarmSeverity) + .propagate(propagate) + .propagateToOwner(propagateToOwner) + .propagateToTenant(propagateToTenant) + .propagateRelationTypes(propagateRelationTypes) + .type(alarmType) + .startTs(100L) + .endTs(endTs) + .details(alarmDetails) + .build(); + existingActiveAlarm.setId(existingAlarmId); + + // expected values + var expectedUpdatedAlarm = Alarm.builder() + .tenantId(tenantId) + .originator(msgOriginator) + .cleared(false) + .acknowledged(false) + .severity(newAlarmSeverity) + .propagate(propagate) + .propagateToOwner(propagateToOwner) + .propagateToTenant(propagateToTenant) + .propagateRelationTypes(propagateRelationTypes) + .type(alarmType) + .startTs(100L) + .endTs(endTs) + .details(alarmDetails) + .build(); + expectedUpdatedAlarm.setId(existingAlarmId); + var expectedUpdatedAlarmInfo = new AlarmInfo(expectedUpdatedAlarm); + + var expectedUpdateAlarmRequest = AlarmUpdateRequest.builder() + .tenantId(tenantId) + .alarmId(existingAlarmId) + .severity(newAlarmSeverity) + .startTs(100L) + .endTs(endTs) + .details(alarmDetails) + .propagation(AlarmPropagationInfo.builder() + .propagate(propagate) + .propagateToOwner(propagateToOwner) + .propagateToTenant(propagateToTenant) + .propagateRelationTypes(propagateRelationTypes).build()) + .userId(null) + .build(); + + // mocks + given(ctxMock.getTenantId()).willReturn(tenantId); + given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); + given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); + given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); + given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingActiveAlarm); + given(alarmDetailsScriptMock.executeJsonAsync(any())).willReturn(Futures.immediateFuture(alarmDetails)); + doReturn(endTs).when(nodeSpy).currentTimeMillis(); + var apiCallResult = AlarmApiCallResult.builder() + .successful(true) + .created(false) + .modified(true) + .cleared(false) + .deleted(false) + .alarm(expectedUpdatedAlarmInfo) + .old(new Alarm(existingActiveAlarm)) + .propagatedEntitiesList(List.of(tenantId)) + .build(); + given(alarmServiceMock.updateAlarm(expectedUpdateAlarmRequest)).willReturn(apiCallResult); + given(ctxMock.alarmActionMsg(expectedUpdatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_UPDATED)).willReturn(alarmActionMsgMock); + given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) + .willAnswer(answer -> TbMsg.transformMsg( + answer.getArgument(0, TbMsg.class), + answer.getArgument(1, TbMsgType.class), + answer.getArgument(2, EntityId.class), + answer.getArgument(3, TbMsgMetaData.class), + answer.getArgument(4, String.class)) + ); + given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, config.getAlarmDetailsBuildTbel())).willReturn(alarmDetailsScriptMock); + + // node initialization + nodeSpy.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + // WHEN + nodeSpy.onMsg(ctxMock, incomingMsg); + + // THEN + + // verify alarm details script evaluation + then(ctxMock).should().logJsEvalRequest(); + var dummyMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + then(alarmDetailsScriptMock).should().executeJsonAsync(dummyMsgCaptor.capture()); + TbMsg actualDummyMsg = dummyMsgCaptor.getValue(); + assertThat(actualDummyMsg.getType()).isEqualTo(incomingMsg.getType()); + assertThat(actualDummyMsg.getData()).isEqualTo(incomingMsg.getData()); + assertThat(actualDummyMsg.getMetaData().getData()).containsEntry("prevAlarmDetails", JacksonUtil.toString(alarmDetails)); + then(ctxMock).should().logJsEvalResponse(); + + // verify we called updateAlarm() with correct AlarmUpdateRequest + then(alarmServiceMock).should().updateAlarm(expectedUpdateAlarmRequest); + then(alarmServiceMock).should(never()).createAlarm(any()); + + // verify that we created a correct alarm action message and enqueued it + then(ctxMock).should().alarmActionMsg(expectedUpdatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_UPDATED); + then(ctxMock).should().enqueue(eq(alarmActionMsgMock), successCaptor.capture(), any()); + + // run success captor to emulate successful queueing of an alarm action message and to trigger further processing on the success path + successCaptor.getValue().run(); + + // capture and verify an outgoing message + var outgoingMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + then(ctxMock).should().tellNext(outgoingMsgCaptor.capture(), eq("Updated")); + var actualOutgoingMsg = outgoingMsgCaptor.getValue(); + assertThat(actualOutgoingMsg.getType()).isEqualTo(TbMsgType.ALARM.name()); + assertThat(actualOutgoingMsg.getOriginator()).isEqualTo(msgOriginator); + assertThat(actualOutgoingMsg.getData()).isEqualTo(JacksonUtil.valueToTree(expectedUpdatedAlarmInfo).toString()); + + Map actualOutgoingMsgMetadataContent = actualOutgoingMsg.getMetaData().getData(); + assertThat(actualOutgoingMsgMetadataContent).containsAllEntriesOf(metadata.getData()); + assertThat(actualOutgoingMsgMetadataContent).containsEntry(DataConstants.IS_EXISTING_ALARM, Boolean.TRUE.toString()); + assertThat(actualOutgoingMsgMetadataContent).size().isEqualTo(metadata.getData().size() + 1); + + // verify wrong processing paths were not taken + then(ctxMock).should(never()).tellNext(any(), eq(TbNodeConnectionType.FALSE)); + then(ctxMock).should(never()).tellNext(any(), eq("Created")); + then(ctxMock).should(never()).tellNext(any(), eq("Cleared")); + then(ctxMock).should(never()).tellSuccess(any()); + then(ctxMock).should(never()).tellFailure(any(), any()); + } + + @Test + @DisplayName("When the alarm details script throws an exception, " + + "node should tell failure with that exception, and it should neither create nor update any alarms, nor should it send any other messages.") + void whenAlarmDetailsScriptThrowsException_thenShouldTellFailureAndNoOtherActions() throws Exception { + // GIVEN + config = config.defaultConfiguration(); + + var incomingMsg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, msgOriginator, metadata, "{\"temperature\": 50}"); + + given(ctxMock.getTenantId()).willReturn(tenantId); + given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); + given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); + given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, config.getAlarmDetailsBuildTbel())).willReturn(alarmDetailsScriptMock); + + var expectedException = new ExecutionException("Failed to execute script.", new RuntimeException("Something went wrong!")); + given(alarmDetailsScriptMock.executeJsonAsync(incomingMsg)).willReturn(Futures.immediateFailedFuture(expectedException)); + + nodeSpy.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + // WHEN + nodeSpy.onMsg(ctxMock, incomingMsg); + + // THEN + var exceptionCaptor = ArgumentCaptor.forClass(Throwable.class); + then(ctxMock).should().tellFailure(eq(incomingMsg), exceptionCaptor.capture()); + Throwable actualException = exceptionCaptor.getValue(); + assertThat(actualException).isEqualTo(expectedException); + + then(alarmServiceMock).should(never()).createAlarm(any()); + then(alarmServiceMock).should(never()).updateAlarm(any()); + + then(ctxMock).should(never()).tellNext(any(), eq(TbNodeConnectionType.FALSE)); + then(ctxMock).should(never()).tellNext(any(), eq("Created")); + then(ctxMock).should(never()).tellNext(any(), eq("Updated")); + then(ctxMock).should(never()).tellNext(any(), eq("Cleared")); + then(ctxMock).should(never()).tellSuccess(any()); + } + +} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNodeTest.java index 707de1741f..74269b9be6 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNodeTest.java @@ -16,19 +16,26 @@ package org.thingsboard.rule.engine.metadata; import com.google.common.util.concurrent.Futures; +import lombok.Data; import lombok.RequiredArgsConstructor; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatcher; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; +import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; import org.thingsboard.rule.engine.TestDbCallbackExecutor; import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.server.common.data.id.DeviceId; @@ -48,7 +55,10 @@ import org.thingsboard.server.dao.timeseries.TimeseriesService; import java.util.List; import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.stream.Stream; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -63,10 +73,11 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -public class CalculateDeltaNodeTest { +public class CalculateDeltaNodeTest extends AbstractRuleNodeUpgradeTest { private static final DeviceId DUMMY_DEVICE_ORIGINATOR = new DeviceId(UUID.randomUUID()); private static final TenantId TENANT_ID = new TenantId(UUID.randomUUID()); @@ -75,13 +86,13 @@ public class CalculateDeltaNodeTest { private TbContext ctxMock; @Mock private TimeseriesService timeseriesServiceMock; + @Spy private CalculateDeltaNode node; private CalculateDeltaNodeConfiguration config; private TbNodeConfiguration nodeConfiguration; @BeforeEach public void setUp() throws TbNodeException { - node = new CalculateDeltaNode(); config = new CalculateDeltaNodeConfiguration().defaultConfiguration(); nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); when(ctxMock.getTimeseriesService()).thenReturn(timeseriesServiceMock); @@ -424,6 +435,91 @@ public class CalculateDeltaNodeTest { .hasMessage("Calculation failed. JSON values are not supported!"); } + @ParameterizedTest + @MethodSource("CalculateDeltaTestConfig") + public void givenCalculateDeltaConfig_whenOnMsg_thenVerify(CalculateDeltaTestConfig testConfig) throws TbNodeException { + // GIVEN + config.setTellFailureIfDeltaIsNegative(testConfig.isTellFailureIfDeltaIsNegative()); + config.setExcludeZeroDeltas(testConfig.isExcludeZeroDeltas()); + config.setInputValueKey("temperature"); + nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + mockFindLatest(new BasicTsKvEntry(1L, new DoubleDataEntry("temperature", testConfig.getPrevValue()))); + + var msgData = "{\"temperature\":" + testConfig.getCurrentValue() + ",\"airPressure\":123}"; + var msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DUMMY_DEVICE_ORIGINATOR, TbMsgMetaData.EMPTY, msgData); + + // WHEN + + node.onMsg(ctxMock, msg); + + // THEN + testConfig.getVerificationMethod().accept(ctxMock, msg); + } + + private static Stream CalculateDeltaTestConfig() { + return Stream.of( + // delta = 0, tell failure if delta is negative is set to true and exclude zero deltas is set to true so delta should filter out the message. + new CalculateDeltaTestConfig(true, true, 40, 40, (ctx, msg) -> { + verify(ctx).tellSuccess(eq(msg)); + verify(ctx).getDbCallbackExecutor(); + verifyNoMoreInteractions(ctx); + }), + // delta < 0, tell failure if delta is negative is set to true so it should throw exception. + new CalculateDeltaTestConfig(true, true, 41, 40, (ctx, msg) -> { + var errorCaptor = ArgumentCaptor.forClass(Throwable.class); + verify(ctx).tellFailure(eq(msg), errorCaptor.capture()); + verify(ctx).getDbCallbackExecutor(); + verifyNoMoreInteractions(ctx); + assertThat(errorCaptor.getValue()).isInstanceOf(IllegalArgumentException.class).hasMessage("Delta value is negative!"); + }), + // delta < 0, exclude zero deltas is set to true so it should return message with delta if delta is negative is set to false. + new CalculateDeltaTestConfig(false, true, 41, 40, (ctx, msg) -> { + var actualMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + verify(ctx).tellSuccess(actualMsgCaptor.capture()); + verify(ctx).getDbCallbackExecutor(); + verifyNoMoreInteractions(ctx); + String expectedMsgData = "{\"temperature\":40.0,\"airPressure\":123,\"delta\":-1}"; + assertEquals(expectedMsgData, actualMsgCaptor.getValue().getData()); + }), + // delta = 0, tell failure if delta is negative is set to false and exclude zero deltas is set to true so delta should filter out the message. + new CalculateDeltaTestConfig(false, true, 40, 40, (ctx, msg) -> { + verify(ctx).tellSuccess(eq(msg)); + verify(ctx).getDbCallbackExecutor(); + verifyNoMoreInteractions(ctx); + }), + // delta > 0, exclude zero deltas is set to true so it should return message with delta. + new CalculateDeltaTestConfig(false, true, 39, 40, (ctx, msg) -> { + var actualMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + verify(ctx).tellSuccess(actualMsgCaptor.capture()); + verify(ctx).getDbCallbackExecutor(); + verifyNoMoreInteractions(ctx); + String expectedMsgData = "{\"temperature\":40.0,\"airPressure\":123,\"delta\":1}"; + assertEquals(expectedMsgData, actualMsgCaptor.getValue().getData()); + }), + // delta > 0, exclude zero deltas is set to false so it should return message with delta. + new CalculateDeltaTestConfig(false, false, 39, 40, (ctx, msg) -> { + var actualMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + verify(ctx).tellSuccess(actualMsgCaptor.capture()); + verify(ctx).getDbCallbackExecutor(); + verifyNoMoreInteractions(ctx); + String expectedMsgData = "{\"temperature\":40.0,\"airPressure\":123,\"delta\":1}"; + assertEquals(expectedMsgData, actualMsgCaptor.getValue().getData()); + }) + ); + } + + @Data + @RequiredArgsConstructor + private static class CalculateDeltaTestConfig { + private final boolean tellFailureIfDeltaIsNegative; + private final boolean excludeZeroDeltas; + private final double prevValue; + private final double currentValue; + private final BiConsumer verificationMethod; + } + private void mockFindLatest(TsKvEntry tsKvEntry) { when(ctxMock.getTenantId()).thenReturn(TENANT_ID); when(timeseriesServiceMock.findLatestSync( @@ -457,4 +553,24 @@ public class CalculateDeltaNodeTest { } + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { + return Stream.of( + // default config for version 0 + Arguments.of(0, + "{\"inputValueKey\":\"pulseCounter\",\"outputValueKey\":\"delta\",\"useCache\":true,\"addPeriodBetweenMsgs\":false, \"periodValueKey\":\"periodInMs\", \"round\":null,\"tellFailureIfDeltaIsNegative\":true}", + true, + "{\"inputValueKey\":\"pulseCounter\",\"outputValueKey\":\"delta\",\"useCache\":true,\"addPeriodBetweenMsgs\":false, \"periodValueKey\":\"periodInMs\", \"round\":null,\"tellFailureIfDeltaIsNegative\":true, \"excludeZeroDeltas\":false}"), + // default config for version 1 with upgrade from version 0 + Arguments.of(1, + "{\"inputValueKey\":\"pulseCounter\",\"outputValueKey\":\"delta\",\"useCache\":true,\"addPeriodBetweenMsgs\":false, \"periodValueKey\":\"periodInMs\", \"round\":null,\"tellFailureIfDeltaIsNegative\":true, \"excludeZeroDeltas\":false}", + false, + "{\"inputValueKey\":\"pulseCounter\",\"outputValueKey\":\"delta\",\"useCache\":true,\"addPeriodBetweenMsgs\":false, \"periodValueKey\":\"periodInMs\", \"round\":null,\"tellFailureIfDeltaIsNegative\":true, \"excludeZeroDeltas\":false}") + ); + + } + + @Override + protected TbNode getTestNode() { + return node; + } } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java new file mode 100644 index 0000000000..b46ecdf6fe --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java @@ -0,0 +1,60 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS 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.mqtt; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.provider.Arguments; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; +import org.thingsboard.rule.engine.api.TbNode; + +import java.util.stream.Stream; + +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class TbMqttNodeTest extends AbstractRuleNodeUpgradeTest { + @Spy + TbMqttNode node; + + @BeforeEach + public void setUp() throws Exception { + node = mock(TbMqttNode.class); + } + + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { + return Stream.of( + // default config for version 0 + Arguments.of(0, + "{\"topicPattern\":\"my-topic\",\"port\":1883,\"connectTimeoutSec\":10,\"cleanSession\":true, \"ssl\":false, \"retainedMessage\":false,\"credentials\":{\"type\":\"anonymous\"}}", + true, + "{\"topicPattern\":\"my-topic\",\"port\":1883,\"connectTimeoutSec\":10,\"cleanSession\":true, \"ssl\":false, \"retainedMessage\":false,\"credentials\":{\"type\":\"anonymous\"},\"parseToPlainText\":false}"), + // default config for version 1 with upgrade from version 0 + Arguments.of(1, + "{\"topicPattern\":\"my-topic\",\"port\":1883,\"connectTimeoutSec\":10,\"cleanSession\":true, \"ssl\":false, \"retainedMessage\":false,\"credentials\":{\"type\":\"anonymous\"},\"parseToPlainText\":false}", + false, + "{\"topicPattern\":\"my-topic\",\"port\":1883,\"connectTimeoutSec\":10,\"cleanSession\":true, \"ssl\":false, \"retainedMessage\":false,\"credentials\":{\"type\":\"anonymous\"},\"parseToPlainText\":false}") + ); + + } + + @Override + protected TbNode getTestNode() { + return node; + } +} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java index 617c553770..2eb65c1787 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java @@ -22,13 +22,10 @@ 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.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.mockserver.integration.ClientAndServer; import org.springframework.util.LinkedMultiValueMap; -import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; @@ -47,7 +44,6 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.willCallRealMethod; import static org.mockito.Mockito.mock; @@ -222,16 +218,4 @@ public class TbHttpClientTest { Assertions.assertEquals(data.get("Set-Cookie"), "[\"sap-context=sap-client=075; path=/\",\"sap-token=sap-client=075; path=/\"]"); } - @ParameterizedTest - @ValueSource(strings = { "false", "\"", "\"\"", "\"This is a string with double quotes\"", "Path: /home/developer/test.txt", - "First line\nSecond line\n\nFourth line", "Before\rAfter", "Tab\tSeparated\tValues", "Test\bbackspace", "[]", - "[1, 2, 3]", "{\"key\": \"value\"}", "{\n\"temperature\": 25.5,\n\"humidity\": 50.2\n\"}", "Expression: (a + b) * c", - "世界", "Україна", "\u1F1FA\u1F1E6", "🇺🇦"}) - public void testParseJsonStringToPlainText(String original) { - Mockito.when(client.parseJsonStringToPlainText(anyString())).thenCallRealMethod(); - - String serialized = JacksonUtil.toString(original); - Assertions.assertNotNull(serialized); - Assertions.assertEquals(original, client.parseJsonStringToPlainText(serialized)); - } } diff --git a/ui-ngx/src/app/core/api/data-aggregator.ts b/ui-ngx/src/app/core/api/data-aggregator.ts index 61cb1f1a85..5bce9115e8 100644 --- a/ui-ngx/src/app/core/api/data-aggregator.ts +++ b/ui-ngx/src/app/core/api/data-aggregator.ts @@ -378,6 +378,7 @@ export class DataAggregator { private updateStateBounds(keyData: DataSet, lastPrevKvPair: DataEntry) { if (lastPrevKvPair) { lastPrevKvPair[0] = this.startTs; + lastPrevKvPair[2] = [this.startTs, this.startTs]; } let firstKvPair: DataEntry; if (!keyData.length) { @@ -398,6 +399,7 @@ export class DataAggregator { if (lastKvPair[0] < this.endTs) { lastKvPair = deepClone(lastKvPair); lastKvPair[0] = this.endTs; + lastKvPair[2] = [this.endTs, this.endTs]; keyData.push(lastKvPair); } } diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts index 54552c714f..1848733006 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -30,7 +30,7 @@ import { import { TimeService } from '../services/time.service'; import { DeviceService } from '../http/device.service'; import { UtilsService } from '@core/services/utils.service'; -import { Timewindow, WidgetTimewindow } from '@shared/models/time/time.models'; +import { SubscriptionTimewindow, Timewindow, WidgetTimewindow } from '@shared/models/time/time.models'; import { EntityType } from '@shared/models/entity-type.models'; import { HttpErrorResponse } from '@angular/common/http'; import { RafService } from '@core/services/raf.service'; @@ -303,6 +303,7 @@ export interface IWidgetSubscription { hiddenData?: Array<{data: DataSet}>; timeWindowConfig?: Timewindow; timeWindow?: WidgetTimewindow; + subscriptionTimewindow: SubscriptionTimewindow; onTimewindowChangeFunction?: (timewindow: Timewindow) => Timewindow; widgetTimewindowChanged$: Observable; comparisonEnabled?: boolean; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html index 94866da201..80e13a96a2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html @@ -41,6 +41,10 @@ [entityAliasId]="datasource?.entityAliasId" formControlName="series"> + + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts index 31dca75c7e..8ab44db11d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts @@ -46,8 +46,9 @@ import { } from '@home/components/widget/lib/chart/time-series-chart-widget.models'; import { EChartsTooltipTrigger } from '@home/components/widget/lib/chart/echarts-widget.models'; import { - TimeSeriesChartKeySettings, TimeSeriesChartThreshold, - TimeSeriesChartType, TimeSeriesChartYAxes, + TimeSeriesChartKeySettings, + TimeSeriesChartType, + TimeSeriesChartYAxes, TimeSeriesChartYAxisId } from '@home/components/widget/lib/chart/time-series-chart.models'; @@ -178,6 +179,9 @@ export class TimeSeriesChartBasicConfigComponent extends BasicWidgetConfigCompon actions: [configData.config.actions || {}, []] }); + if (this.chartType === TimeSeriesChartType.state) { + this.timeSeriesChartWidgetConfigForm.addControl('states', this.fb.control(settings.states, [])); + } } protected prepareOutputConfig(config: any): WidgetConfigComponentData { @@ -233,6 +237,10 @@ export class TimeSeriesChartBasicConfigComponent extends BasicWidgetConfigCompon this.widgetConfig.config.settings.padding = config.padding; this.widgetConfig.config.actions = config.actions; + + if (this.chartType === TimeSeriesChartType.state) { + this.widgetConfig.config.settings.states = config.states; + } return this.widgetConfig; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/echarts-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/echarts-widget.models.ts index 81ffb457b9..0a1bb673cb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/echarts-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/echarts-widget.models.ts @@ -17,7 +17,7 @@ import * as echarts from 'echarts/core'; import AxisModel from 'echarts/types/src/coord/cartesian/AxisModel'; import { estimateLabelUnionRect } from 'echarts/lib/coord/axisHelper'; -import { formatValue, isDefinedAndNotNull, isNumber } from '@core/utils'; +import { isDefinedAndNotNull, isFunction, isNumber } from '@core/utils'; import { DataZoomComponent, DataZoomComponentOption, @@ -42,7 +42,7 @@ import { } from 'echarts/charts'; import { LabelLayout } from 'echarts/features'; import { CanvasRenderer, SVGRenderer } from 'echarts/renderers'; -import { DataEntry, DataKey, DataSet } from '@shared/models/widget.models'; +import { DataEntry, DataKey, DataSet, Datasource, FormattedData } from '@shared/models/widget.models'; import { calculateAggIntervalWithWidgetTimeWindow, Interval, @@ -56,6 +56,7 @@ import GlobalModel from 'echarts/types/src/model/Global'; import Axis2D from 'echarts/types/src/coord/cartesian/Axis2D'; import SeriesModel from 'echarts/types/src/model/Series'; import { MarkLine2DDataItemOption } from 'echarts/types/src/component/marker/MarkLineModel'; +import { TimeAxisBaseOption } from 'echarts/types/src/coord/axisCommonTypes'; class EChartsModule { private initialized = false; @@ -101,14 +102,19 @@ export type EChartsDataItem = [number, any, number, number]; export type NamedDataSet = {name: string; value: EChartsDataItem}[]; +export type EChartsTooltipValueFormatFunction = (value: any, latestData: FormattedData, units?: string, decimals?: number) => string; + export type EChartsSeriesItem = { id: string; + datasource: Datasource; dataKey: DataKey; data: NamedDataSet; dataSet?: DataSet; enabled: boolean; units?: string; decimals?: number; + latestData?: FormattedData; + tooltipValueFormatFunction?: EChartsTooltipValueFormatFunction; }; export enum EChartsShape { @@ -270,7 +276,7 @@ const measureSymbolOffset = (symbol: string, symbolSize: any): number => { } else { return 0; } -} +}; export const measureThresholdOffset = (chart: ECharts, axisId: string, thresholdId: string, value: any): [number, number] => { const offset: [number, number] = [0,0]; @@ -395,7 +401,7 @@ export const getFocusedSeriesIndex = (chart: ECharts): number => { return -1; }; -export const toNamedData = (data: DataSet): NamedDataSet => { +export const toNamedData = (data: DataSet, valueConverter?: (value: any) => any): NamedDataSet => { if (!data?.length) { return []; } else { @@ -403,14 +409,43 @@ export const toNamedData = (data: DataSet): NamedDataSet => { const ts = isDefinedAndNotNull(d[2]) ? d[2][0] : d[0]; return { name: ts + '', - value: toEChartsDataItem(d) + value: toEChartsDataItem(d, valueConverter) }; }); } }; -const toEChartsDataItem = (entry: DataEntry): EChartsDataItem => { - const item: EChartsDataItem = [entry[0], entry[1], entry[0], entry[0]]; +const minDataTs = (dataSet: NamedDataSet): number => dataSet.length ? dataSet.map(data => + Number(data.name)).reduce((a, b) => Math.min(a, b)) : undefined; + +const maxDataTs = (dataSet: NamedDataSet): number => dataSet.length ? dataSet.map(data => + Number(data.name)).reduce((a, b) => Math.max(a, b)) : undefined; + +export const adjustTimeAxisExtentToData = (timeAxisOption: TimeAxisBaseOption, + dataItems: EChartsSeriesItem[], + defaultMin: number, + defaultMax: number): void => { + let min: number; + let max: number; + for (const item of dataItems) { + if (item.enabled) { + const minTs = minDataTs(item.data); + if (typeof minTs !== 'undefined') { + min = (typeof min !== 'undefined') ? Math.min(min, minTs) : minTs; + } + const maxTs = maxDataTs(item.data); + if (typeof maxTs !== 'undefined') { + max = (typeof max !== 'undefined') ? Math.max(max, maxTs) : maxTs; + } + } + } + timeAxisOption.min = (typeof min !== 'undefined') ? min : defaultMin; + timeAxisOption.max = (typeof max !== 'undefined') ? max : defaultMax; +}; + +const toEChartsDataItem = (entry: DataEntry, valueConverter?: (value: any) => any): EChartsDataItem => { + const value = valueConverter ? valueConverter(entry[1]) : entry[1]; + const item: EChartsDataItem = [entry[0], value, entry[0], entry[0]]; if (isDefinedAndNotNull(entry[2])) { item[2] = entry[2][0]; item[3] = entry[2][1]; @@ -436,6 +471,7 @@ export interface EChartsTooltipWidgetSettings { tooltipShowFocusedSeries?: boolean; tooltipValueFont: Font; tooltipValueColor: string; + tooltipValueFormatter?: string | EChartsTooltipValueFormatFunction; tooltipShowDate: boolean; tooltipDateInterval?: boolean; tooltipDateFormat: DateFormatSettings; @@ -445,12 +481,25 @@ export interface EChartsTooltipWidgetSettings { tooltipBackgroundBlur: number; } +export const createTooltipValueFormatFunction = + (tooltipValueFormatter: string | EChartsTooltipValueFormatFunction): EChartsTooltipValueFormatFunction => { + let tooltipValueFormatFunction: EChartsTooltipValueFormatFunction; + if (isFunction(tooltipValueFormatter)) { + tooltipValueFormatFunction = tooltipValueFormatter as EChartsTooltipValueFormatFunction; + } else if (typeof tooltipValueFormatter === 'string' && tooltipValueFormatter.length) { + try { + tooltipValueFormatFunction = + new Function('value', 'latestData', tooltipValueFormatter) as EChartsTooltipValueFormatFunction; + } catch (e) {} + } + return tooltipValueFormatFunction; +}; + export const echartsTooltipFormatter = (renderer: Renderer2, tooltipDateFormat: DateFormatProcessor, settings: EChartsTooltipWidgetSettings, params: CallbackDataParams[] | CallbackDataParams, - decimals: number, - units: string, + valueFormatFunction: EChartsTooltipValueFormatFunction, focusedSeriesIndex: number, series?: EChartsSeriesItem[], interval?: Interval): null | HTMLElement => { @@ -499,10 +548,12 @@ export const echartsTooltipFormatter = (renderer: Renderer2, seriesParams = params; } if (seriesParams) { - renderer.appendChild(tooltipElement, constructEchartsTooltipSeriesElement(renderer, settings, seriesParams, decimals, units, series)); + renderer.appendChild(tooltipElement, + constructEchartsTooltipSeriesElement(renderer, settings, seriesParams, valueFormatFunction, series)); } else if (Array.isArray(params)) { for (seriesParams of params) { - renderer.appendChild(tooltipElement, constructEchartsTooltipSeriesElement(renderer, settings, seriesParams, decimals, units, series)); + renderer.appendChild(tooltipElement, + constructEchartsTooltipSeriesElement(renderer, settings, seriesParams, valueFormatFunction, series)); } } return tooltipElement; @@ -511,8 +562,7 @@ export const echartsTooltipFormatter = (renderer: Renderer2, const constructEchartsTooltipSeriesElement = (renderer: Renderer2, settings: EChartsTooltipWidgetSettings, seriesParams: CallbackDataParams, - decimals: number, - units: string, + valueFormatFunction: EChartsTooltipValueFormatFunction, series?: EChartsSeriesItem[]): HTMLElement => { const labelValueElement: HTMLElement = renderer.createElement('div'); renderer.setStyle(labelValueElement, 'display', 'flex'); @@ -542,16 +592,25 @@ const constructEchartsTooltipSeriesElement = (renderer: Renderer2, renderer.setStyle(labelTextElement, 'color', 'rgba(0, 0, 0, 0.76)'); renderer.appendChild(labelElement, labelTextElement); const valueElement: HTMLElement = renderer.createElement('div'); - let formatDecimals = decimals; - let formatUnits = units; + let formatFunction = valueFormatFunction; + let latestData: FormattedData; + let units = ''; + let decimals = 0; if (series) { const item = series.find(s => s.id === seriesParams.seriesId); if (item) { - formatDecimals = item.decimals; - formatUnits = item.units; + if (item.tooltipValueFormatFunction) { + formatFunction = item.tooltipValueFormatFunction; + } + latestData = item.latestData; + units = item.units; + decimals = item.decimals; } } - const value = formatValue(seriesParams.value[1], formatDecimals, formatUnits, false); + if (!latestData) { + latestData = {} as FormattedData; + } + const value = formatFunction(seriesParams.value[1], latestData, units, decimals); renderer.appendChild(valueElement, renderer.createText(value)); renderer.setStyle(valueElement, 'flex', '1'); renderer.setStyle(valueElement, 'text-align', 'end'); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-state.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-state.models.ts new file mode 100644 index 0000000000..9a72361632 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-state.models.ts @@ -0,0 +1,109 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + TimeSeriesChartStateSettings, + TimeSeriesChartStateSourceType, + TimeSeriesChartTicksFormatter, + TimeSeriesChartTicksGenerator +} from '@home/components/widget/lib/chart/time-series-chart.models'; +import { UtilsService } from '@core/services/utils.service'; +import { EChartsTooltipValueFormatFunction } from '@home/components/widget/lib/chart/echarts-widget.models'; +import { FormattedData } from '@shared/models/widget.models'; +import { formatValue, isDefinedAndNotNull, isNumber, isNumeric } from '@core/utils'; +import { LabelFormatterCallback } from 'echarts'; + +export class TimeSeriesChartStateValueConverter { + + private readonly constantsMap = new Map(); + private readonly rangeStates: TimeSeriesChartStateSettings[] = []; + private readonly ticks: {value: number}[] = []; + private readonly labelsMap = new Map(); + + public readonly ticksGenerator: TimeSeriesChartTicksGenerator; + public readonly ticksFormatter: TimeSeriesChartTicksFormatter; + public readonly tooltipFormatter: EChartsTooltipValueFormatFunction; + public readonly labelFormatter: LabelFormatterCallback; + public readonly valueConverter: (value: any) => any; + + constructor(utils: UtilsService, + states: TimeSeriesChartStateSettings[]) { + const ticks: number[] = []; + for (const state of states) { + if (state.sourceType === TimeSeriesChartStateSourceType.constant) { + this.constantsMap.set(state.sourceValue, state.value); + } else { + this.rangeStates.push(state); + } + if (!ticks.includes(state.value)) { + ticks.push(state.value); + const label = utils.customTranslation(state.label, state.label); + this.labelsMap.set(state.value, label); + } + } + this.ticks = ticks.map(val => ({value: val})); + this.ticksGenerator = () => this.ticks; + this.ticksFormatter = (value: any) => { + const result = this.labelsMap.get(value); + return result || ''; + }; + this.tooltipFormatter = (value: any, latestData: FormattedData, units?: string, decimals?: number) => { + const result = this.labelsMap.get(value); + if (typeof result === 'string') { + return result; + } else { + return formatValue(value, decimals, units, false); + } + }; + this.labelFormatter = (params) => { + const value = params.value[1]; + const result = this.labelsMap.get(value); + if (typeof result === 'string') { + return `{value|${result}}`; + } else { + return undefined; + } + }; + this.valueConverter = (value: any) => { + let key = value; + if (key === 'true') { + key = true; + } else if (key === 'false') { + key = false; + } + const result = this.constantsMap.get(key); + if (typeof result === 'number') { + return result; + } else if (this.rangeStates.length && isDefinedAndNotNull(value) && isNumeric(value)) { + for (const state of this.rangeStates) { + const num = Number(value); + if (TimeSeriesChartStateValueConverter.constantRange(state) && state.sourceRangeFrom === num) { + return state.value; + } else if ((!isNumber(state.sourceRangeFrom) || num >= state.sourceRangeFrom) && + (!isNumber(state.sourceRangeTo) || num < state.sourceRangeTo)) { + return state.value; + } + } + } + return value; + }; + } + + static constantRange(state: TimeSeriesChartStateSettings): boolean { + return isNumber(state.sourceRangeFrom) && isNumber(state.sourceRangeTo) && state.sourceRangeFrom === state.sourceRangeTo; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts index 3589d69d4f..252a284aaa 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts @@ -17,8 +17,10 @@ import { ECharts, EChartsOption, - EChartsSeriesItem, EChartsShape, + EChartsSeriesItem, + EChartsShape, EChartsTooltipTrigger, + EChartsTooltipValueFormatFunction, EChartsTooltipWidgetSettings, measureThresholdOffset, timeAxisBandWidthCalculator @@ -41,7 +43,8 @@ import { CustomSeriesOption, LineSeriesOption } from 'echarts/charts'; import { formatValue, isDefinedAndNotNull, - isFunction, isNumber, + isFunction, + isNumber, isNumeric, isUndefined, isUndefinedOrNull, @@ -51,7 +54,8 @@ import { import { LinearGradientObject } from 'zrender/lib/graphic/LinearGradient'; import tinycolor from 'tinycolor2'; import { ValueAxisBaseOption } from 'echarts/types/src/coord/axisCommonTypes'; -import { LabelFormatterCallback, LabelLayoutOption, SeriesLabelOption } from 'echarts/types/src/util/types'; +import { LabelLayoutOption, SeriesLabelOption } from 'echarts/types/src/util/types'; +import { LabelFormatterCallback } from 'echarts'; import { BarRenderContext, BarRenderSharedContext, @@ -70,7 +74,8 @@ export enum TimeSeriesChartType { default = 'default', line = 'line', bar = 'bar', - point = 'point' + point = 'point', + state = 'state' } export const timeSeriesChartTypeTranslations = new Map( @@ -200,6 +205,21 @@ export const timeSeriesThresholdTypeTranslations = new Map( + [ + [TimeSeriesChartStateSourceType.constant, 'widgets.time-series-chart.state.type-constant'], + [TimeSeriesChartStateSourceType.range, 'widgets.time-series-chart.state.type-range'] + ] +); + export enum SeriesFillType { none = 'none', opacity = 'opacity', @@ -639,6 +659,39 @@ export interface TimeSeriesChartVisualMapSettings { pieces: TimeSeriesChartVisualMapPiece[]; } +export interface TimeSeriesChartStateSettings { + label: string; + value: number; + sourceType: TimeSeriesChartStateSourceType; + sourceValue?: any; + sourceRangeFrom?: number; + sourceRangeTo?: number; +} + +export const timeSeriesChartStateValid = (state: TimeSeriesChartStateSettings): boolean => { + if (isUndefinedOrNull(state.value) || !state.sourceType) { + return false; + } + switch (state.sourceType) { + case TimeSeriesChartStateSourceType.constant: + if (isUndefinedOrNull(state.sourceValue)) { + return false; + } + break; + } + return true; +}; + +export const timeSeriesChartStateValidator = (control: AbstractControl): ValidationErrors | null => { + const state: TimeSeriesChartStateSettings = control.value; + if (!timeSeriesChartStateValid(state)) { + return { + state: true + }; + } + return null; +}; + export interface TimeSeriesChartSettings extends EChartsTooltipWidgetSettings { thresholds: TimeSeriesChartThreshold[]; darkMode: boolean; @@ -650,6 +703,7 @@ export interface TimeSeriesChartSettings extends EChartsTooltipWidgetSettings { barWidthSettings: TimeSeriesChartBarWidthSettings; noAggregationBarWidthSettings: TimeSeriesChartNoAggregationBarWidthSettings; visualMapSettings?: TimeSeriesChartVisualMapSettings; + states?: TimeSeriesChartStateSettings[]; } export const timeSeriesChartDefaultSettings: TimeSeriesChartSettings = { @@ -751,6 +805,7 @@ export interface TimeSeriesChartKeySettings { type: TimeSeriesChartSeriesType; lineSettings: LineSeriesSettings; barSettings: BarSeriesSettings; + tooltipValueFormatter?: string | EChartsTooltipValueFormatFunction; } export const timeSeriesChartKeyDefaultSettings: TimeSeriesChartKeySettings = { @@ -1359,28 +1414,28 @@ const createSeriesLabelOption = (item: TimeSeriesChartDataItem, show: boolean, if (show) { labelStyle = createChartTextStyle(labelFont, labelColor, darkMode, 'series.label', labelColorFill); } - let formatter: LabelFormatterCallback; - if (isFunction(labelFormatter)) { - formatter = labelFormatter as LabelFormatterCallback; - } else if (labelFormatter?.length) { - const formatFunction = parseFunction(labelFormatter, ['value']); - formatter = (params): string => { - let result: string; - try { - result = formatFunction(params.value[1]); - } catch (_e) { - } - if (isUndefined(result)) { - result = formatValue(params.value[1], item.decimals, item.units, false); - } - return `{value|${result}}`; - }; - } else { - formatter = (params): string => { - const value = formatValue(params.value[1], item.decimals, item.units, false); - return `{value|${value}}`; - }; + let formatFunction: (...args: any[]) => any; + if (typeof labelFormatter === 'string' && labelFormatter.length) { + formatFunction = parseFunction(labelFormatter, ['value']); } + const formatter: LabelFormatterCallback = (params): string => { + let result: string; + if (typeof labelFormatter === 'string') { + if (formatFunction) { + try { + result = formatFunction(params.value[1]); + } catch (_e) { + } + } + } else if (isFunction(labelFormatter)) { + result = labelFormatter(params); + } + if (isUndefined(result)) { + result = formatValue(params.value[1], item.decimals, item.units, false); + result = `{value|${result}}`; + } + return result; + }; const labelOption: SeriesLabelOption = { show, position, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts index 00f2c55d3f..700f07da15 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts @@ -23,6 +23,7 @@ import { createTimeSeriesYAxis, defaultTimeSeriesChartYAxisSettings, generateChartData, + LineSeriesStepType, parseThresholdData, SeriesLabelPosition, TimeSeriesChartDataItem, @@ -44,13 +45,17 @@ import { } from '@home/components/widget/lib/chart/time-series-chart.models'; import { ResizeObserver } from '@juggle/resize-observer'; import { + adjustTimeAxisExtentToData, calculateXAxisHeight, calculateYAxisWidth, + createTooltipValueFormatFunction, ECharts, echartsModule, - EChartsOption, EChartsShape, + EChartsOption, + EChartsShape, echartsTooltipFormatter, EChartsTooltipTrigger, + EChartsTooltipValueFormatFunction, getAxisExtent, getFocusedSeriesIndex, measureXAxisNameHeight, @@ -58,12 +63,11 @@ import { toNamedData } from '@home/components/widget/lib/chart/echarts-widget.models'; import { DateFormatProcessor } from '@shared/models/widget-settings.models'; -import { isDefinedAndNotNull, isEqual, mergeDeep } from '@core/utils'; -import { DataKey, Datasource, DatasourceType, widgetType } from '@shared/models/widget.models'; +import { formattedDataFormDatasourceData, formatValue, isDefinedAndNotNull, isEqual, mergeDeep } from '@core/utils'; +import { DataKey, Datasource, DatasourceType, FormattedData, widgetType } from '@shared/models/widget.models'; import * as echarts from 'echarts/core'; import { CallbackDataParams, PiecewiseVisualMapOption } from 'echarts/types/dist/shared'; import { Renderer2 } from '@angular/core'; -import { CustomSeriesOption, LineSeriesOption } from 'echarts/charts'; import { BehaviorSubject } from 'rxjs'; import { AggregationType } from '@shared/models/time/time.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; @@ -71,6 +75,7 @@ import { WidgetSubscriptionOptions } from '@core/api/widget-api.models'; import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; import { DeepPartial } from '@shared/models/common'; import { BarRenderSharedContext } from '@home/components/widget/lib/chart/time-series-chart-bar.models'; +import { TimeSeriesChartStateValueConverter } from '@home/components/widget/lib/chart/time-series-chart-state.models'; export class TbTimeSeriesChart { @@ -89,6 +94,13 @@ export class TbTimeSeriesChart { settings.lineSettings.showPoints = true; settings.lineSettings.pointShape = EChartsShape.circle; settings.lineSettings.pointSize = 8; + } else if (type === TimeSeriesChartType.state) { + settings.type = TimeSeriesChartSeriesType.line; + settings.lineSettings.showLine = true; + settings.lineSettings.step = true; + settings.lineSettings.stepType = LineSeriesStepType.end; + settings.lineSettings.pointShape = EChartsShape.circle; + settings.lineSettings.pointSize = 12; } return settings; } @@ -97,7 +109,11 @@ export class TbTimeSeriesChart { } private get noAggregation(): boolean { - return this.ctx.defaultSubscription.timeWindowConfig?.aggregation?.type === AggregationType.NONE; + return this.ctx.defaultSubscription.subscriptionTimewindow?.aggregation?.type === AggregationType.NONE; + } + + private get stateData(): boolean { + return this.ctx.defaultSubscription.subscriptionTimewindow?.aggregation?.stateData === true; } private readonly shapeResize$: ResizeObserver; @@ -115,6 +131,8 @@ export class TbTimeSeriesChart { private timeSeriesChartOptions: EChartsOption; private readonly tooltipDateFormat: DateFormatProcessor; + private readonly tooltipValueFormatFunction: EChartsTooltipValueFormatFunction; + private readonly stateValueConverter: TimeSeriesChartStateValueConverter; private yMinSubject = new BehaviorSubject(-1); private yMaxSubject = new BehaviorSubject(1); @@ -131,6 +149,8 @@ export class TbTimeSeriesChart { private barRenderSharedContext: BarRenderSharedContext; + private latestData: FormattedData[] = []; + yMin$ = this.yMinSubject.asObservable(); yMax$ = this.yMaxSubject.asObservable(); @@ -143,6 +163,10 @@ export class TbTimeSeriesChart { this.settings = mergeDeep({} as TimeSeriesChartSettings, timeSeriesChartDefaultSettings, this.inputSettings as TimeSeriesChartSettings); + if (this.settings.states && this.settings.states.length) { + this.stateValueConverter = new TimeSeriesChartStateValueConverter(this.ctx.dashboard.utils, this.settings.states); + this.tooltipValueFormatFunction = this.stateValueConverter.tooltipFormatter; + } const $dashboardPageElement = this.ctx.$containerParent.parents('.tb-dashboard-page'); const dashboardPageElement = $dashboardPageElement.length ? $($dashboardPageElement[$dashboardPageElement.length-1]) : null; this.darkMode = this.settings.darkMode || dashboardPageElement?.hasClass('dark'); @@ -150,8 +174,17 @@ export class TbTimeSeriesChart { this.setupData(); this.setupThresholds(); this.setupVisualMap(); - if (this.settings.showTooltip && this.settings.tooltipShowDate) { - this.tooltipDateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.tooltipDateFormat); + if (this.settings.showTooltip) { + if (this.settings.tooltipShowDate) { + this.tooltipDateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.tooltipDateFormat); + } + if (!this.tooltipValueFormatFunction) { + this.tooltipValueFormatFunction = + createTooltipValueFormatFunction(this.settings.tooltipValueFormatter); + if (!this.tooltipValueFormatFunction) { + this.tooltipValueFormatFunction = (value, latestData, units, decimals) => formatValue(value, decimals, units, false); + } + } } this.onResize(); if (this.autoResize) { @@ -178,7 +211,7 @@ export class TbTimeSeriesChart { const datasourceData = this.ctx.data ? this.ctx.data.find(d => d.dataKey === item.dataKey) : null; if (!isEqual(item.dataSet, datasourceData?.data)) { item.dataSet = datasourceData?.data; - item.data = datasourceData?.data ? toNamedData(datasourceData.data) : []; + item.data = datasourceData?.data ? toNamedData(datasourceData.data, this.stateValueConverter?.valueConverter) : []; } } this.onResize(); @@ -205,6 +238,14 @@ export class TbTimeSeriesChart { public latestUpdated() { let update = false; if (this.ctx.latestData) { + this.latestData = formattedDataFormDatasourceData(this.ctx.latestData); + for (const item of this.dataItems) { + let latestData = this.latestData.find(data => data.$datasource === item.datasource); + if (!latestData) { + latestData = {} as FormattedData; + } + item.latestData = latestData; + } for (const item of this.thresholdItems) { if (item.settings.type === TimeSeriesChartThresholdType.latestKey && item.latestDataKey) { const data = this.ctx.latestData.find(d => d.dataKey === item.latestDataKey); @@ -253,7 +294,7 @@ export class TbTimeSeriesChart { seriesId: dataItem.id }); } - this.timeSeriesChartOptions.series = this.updateSeries(); + this.updateSeries(); const mergeList = ['series']; if (this.updateYAxisScale(this.yAxisList)) { this.timeSeriesChartOptions.yAxis = this.yAxisList.map(axis => axis.option); @@ -338,9 +379,12 @@ export class TbTimeSeriesChart { .includes(keySettings.barSettings.labelPosition as SeriesLabelPosition))) { this.topPointLabels = true; } + if (this.stateValueConverter && keySettings.type === TimeSeriesChartSeriesType.line) { + keySettings.lineSettings.pointLabelFormatter = this.stateValueConverter.labelFormatter; + } dataKey.settings = keySettings; const datasourceData = this.ctx.data ? this.ctx.data.find(d => d.dataKey === dataKey) : null; - const namedData = datasourceData?.data ? toNamedData(datasourceData.data) : []; + const namedData = datasourceData?.data ? toNamedData(datasourceData.data, this.stateValueConverter?.valueConverter) : []; const units = dataKey.units && dataKey.units.length ? dataKey.units : this.ctx.units; const decimals = isDefinedAndNotNull(dataKey.decimals) ? dataKey.decimals : (isDefinedAndNotNull(this.ctx.decimals) ? this.ctx.decimals : 2); @@ -354,9 +398,11 @@ export class TbTimeSeriesChart { decimals, yAxisId, yAxisIndex: this.getYAxisIndex(yAxisId), + datasource, dataKey, data: namedData, - enabled: !keySettings.dataHiddenByDefault + enabled: !keySettings.dataHiddenByDefault, + tooltipValueFormatFunction: createTooltipValueFormatFunction(keySettings.tooltipValueFormatter) }); } } @@ -451,6 +497,10 @@ export class TbTimeSeriesChart { const units = axisSettings.units && axisSettings.units.length ? axisSettings.units : this.ctx.units; const decimals = isDefinedAndNotNull(axisSettings.decimals) ? axisSettings.decimals : (isDefinedAndNotNull(this.ctx.decimals) ? this.ctx.decimals : 2); + if (this.stateValueConverter) { + axisSettings.ticksGenerator = this.stateValueConverter.ticksGenerator; + axisSettings.ticksFormatter = this.stateValueConverter.ticksFormatter; + } const yAxis = createTimeSeriesYAxis(units, decimals, axisSettings, this.darkMode); this.yAxisList.push(yAxis); } @@ -528,7 +578,7 @@ export class TbTimeSeriesChart { }, formatter: (params: CallbackDataParams[]) => this.settings.showTooltip ? echartsTooltipFormatter(this.renderer, this.tooltipDateFormat, - this.settings, params, 0, '', + this.settings, params, this.tooltipValueFormatFunction, this.settings.tooltipShowFocusedSeries ? getFocusedSeriesIndex(this.timeSeriesChart) : -1, this.dataItems, this.noAggregation ? null : this.ctx.timeWindow.interval) : undefined, padding: [8, 12], @@ -551,13 +601,15 @@ export class TbTimeSeriesChart { { type: 'inside', disabled: !this.settings.dataZoom, - realtime: true + realtime: true, + filterMode: this.stateData ? 'none' : 'filter' }, { type: 'slider', show: this.settings.dataZoom, showDetail: false, realtime: true, + filterMode: this.stateData ? 'none' : 'filter', bottom: 10 } ], @@ -577,7 +629,7 @@ export class TbTimeSeriesChart { this.timeSeriesChartOptions.xAxis[0].tbTimeWindow = this.ctx.defaultSubscription.timeWindow; - this.timeSeriesChartOptions.series = this.updateSeries(); + this.updateSeries(); if (this.updateYAxisScale(this.yAxisList)) { this.timeSeriesChartOptions.yAxis = this.yAxisList.map(axis => axis.option); } @@ -593,7 +645,7 @@ export class TbTimeSeriesChart { } private updateSeriesData(updateScale = false): void { - this.timeSeriesChartOptions.series = this.updateSeries(); + this.updateSeries(); if (updateScale && this.updateYAxisScale(this.yAxisList)) { this.timeSeriesChartOptions.yAxis = this.yAxisList.map(axis => axis.option); } @@ -601,11 +653,16 @@ export class TbTimeSeriesChart { this.updateAxes(); } - private updateSeries(): Array { - return generateChartData(this.dataItems, this.thresholdItems, + private updateSeries(): void { + this.timeSeriesChartOptions.series = generateChartData(this.dataItems, this.thresholdItems, this.settings.stack, this.noAggregation, this.barRenderSharedContext, this.darkMode); + if (this.stateData) { + adjustTimeAxisExtentToData(this.timeSeriesChartOptions.xAxis[0], this.dataItems, + this.ctx.defaultSubscription.timeWindow.minTime, + this.ctx.defaultSubscription.timeWindow.maxTime); + } } private updateAxes(lazy = true) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.html index 5f0fbb9e37..6d015e04fa 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.html @@ -56,6 +56,16 @@ formControlName="barSettings"> +
+
widgets.chart.tooltip-settings
+ + +
{{ timeSeriesChartTypeTranslations.get(chartType) | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.ts index d1ac428396..3b49476a59 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.ts @@ -29,6 +29,7 @@ import { } from '@home/components/widget/lib/chart/time-series-chart.models'; import { WidgetConfigComponentData } from '@home/models/widget-component.models'; import { TimeSeriesChartWidgetSettings } from '@home/components/widget/lib/chart/time-series-chart-widget.models'; +import { WidgetService } from '@core/http/widget.service'; @Component({ selector: 'tb-time-series-chart-key-settings', @@ -53,7 +54,10 @@ export class TimeSeriesChartKeySettingsComponent extends WidgetSettingsComponent yAxisIds: TimeSeriesChartYAxisId[]; + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + constructor(protected store: Store, + private widgetService: WidgetService, private fb: UntypedFormBuilder) { super(store); } @@ -88,7 +92,8 @@ export class TimeSeriesChartKeySettingsComponent extends WidgetSettingsComponent dataHiddenByDefault: [seriesSettings.dataHiddenByDefault, []], type: [seriesSettings.type, []], lineSettings: [seriesSettings.lineSettings, []], - barSettings: [seriesSettings.barSettings, []] + barSettings: [seriesSettings.barSettings, []], + tooltipValueFormatter: [seriesSettings.tooltipValueFormatter, []], }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.html index cc3102c475..f20f1323b3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.html @@ -18,7 +18,7 @@
widgets.time-series-chart.series.line.line
-
+
{{ 'widgets.time-series-chart.series.line.show-line' | translate }} @@ -35,7 +35,7 @@
-
+
{{ 'widgets.time-series-chart.series.line.smooth-line' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts index 5b4701bf07..1d57220b4e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts @@ -147,12 +147,22 @@ export class TimeSeriesChartLineSettingsComponent implements OnInit, ControlValu } private updateValidators() { + const state = this.chartType === TimeSeriesChartType.state; const showLine: boolean = this.lineSettingsFormGroup.get('showLine').value; const step: boolean = this.lineSettingsFormGroup.get('step').value; const showPointLabel: boolean = this.lineSettingsFormGroup.get('showPointLabel').value; const enablePointLabelBackground: boolean = this.lineSettingsFormGroup.get('enablePointLabelBackground').value; + if (state) { + this.lineSettingsFormGroup.get('showLine').disable({emitEvent: false}); + } else { + this.lineSettingsFormGroup.get('showLine').enable({emitEvent: false}); + } if (showLine) { - this.lineSettingsFormGroup.get('step').enable({emitEvent: false}); + if (state) { + this.lineSettingsFormGroup.get('step').disable({emitEvent: false}); + } else { + this.lineSettingsFormGroup.get('step').enable({emitEvent: false}); + } if (step) { this.lineSettingsFormGroup.get('stepType').enable({emitEvent: false}); this.lineSettingsFormGroup.get('smooth').disable({emitEvent: false}); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html index fbd960be18..d98f9ee319 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html @@ -16,6 +16,10 @@ --> + +
+ +
{{ 'tooltip.date' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts index 6edab7ecf2..01ecca2944 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts @@ -39,6 +39,7 @@ import { TimeSeriesChartYAxisId } from '@home/components/widget/lib/chart/time-series-chart.models'; import { WidgetConfigComponentData } from '@home/models/widget-component.models'; +import { WidgetService } from '@core/http/widget.service'; @Component({ selector: 'tb-time-series-chart-widget-settings', @@ -77,8 +78,11 @@ export class TimeSeriesChartWidgetSettingsComponent extends WidgetSettingsCompon chartType: TimeSeriesChartType = TimeSeriesChartType.default; + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + constructor(protected store: Store, private $injector: Injector, + private widgetService: WidgetService, private fb: UntypedFormBuilder) { super(store); } @@ -129,6 +133,7 @@ export class TimeSeriesChartWidgetSettingsComponent extends WidgetSettingsCompon tooltipTrigger: [settings.tooltipTrigger, []], tooltipValueFont: [settings.tooltipValueFont, []], tooltipValueColor: [settings.tooltipValueColor, []], + tooltipValueFormatter: [settings.tooltipValueFormatter, []], tooltipShowDate: [settings.tooltipShowDate, []], tooltipDateFormat: [settings.tooltipDateFormat, []], tooltipDateFont: [settings.tooltipDateFont, []], @@ -143,6 +148,9 @@ export class TimeSeriesChartWidgetSettingsComponent extends WidgetSettingsCompon background: [settings.background, []], padding: [settings.padding, []] }); + if (this.chartType === TimeSeriesChartType.state) { + this.timeSeriesChartWidgetSettingsForm.addControl('states', this.fb.control(settings.states, [])); + } } protected validatorTriggers(): string[] { @@ -168,6 +176,7 @@ export class TimeSeriesChartWidgetSettingsComponent extends WidgetSettingsCompon this.timeSeriesChartWidgetSettingsForm.get('tooltipTrigger').enable(); this.timeSeriesChartWidgetSettingsForm.get('tooltipValueFont').enable(); this.timeSeriesChartWidgetSettingsForm.get('tooltipValueColor').enable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipValueFormatter').enable(); this.timeSeriesChartWidgetSettingsForm.get('tooltipShowDate').enable({emitEvent: false}); this.timeSeriesChartWidgetSettingsForm.get('tooltipBackgroundColor').enable(); this.timeSeriesChartWidgetSettingsForm.get('tooltipBackgroundBlur').enable(); @@ -185,6 +194,7 @@ export class TimeSeriesChartWidgetSettingsComponent extends WidgetSettingsCompon } else { this.timeSeriesChartWidgetSettingsForm.get('tooltipValueFont').disable(); this.timeSeriesChartWidgetSettingsForm.get('tooltipValueColor').disable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipValueFormatter').disable(); this.timeSeriesChartWidgetSettingsForm.get('tooltipShowDate').disable({emitEvent: false}); this.timeSeriesChartWidgetSettingsForm.get('tooltipDateFormat').disable(); this.timeSeriesChartWidgetSettingsForm.get('tooltipDateFont').disable(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.html index 66b6c9896d..cd240ba8e4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.html @@ -68,6 +68,15 @@
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.ts index ac49b8137d..91fe61caa3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.ts @@ -85,7 +85,7 @@ export class TimeSeriesChartAxisSettingsComponent implements OnInit, ControlValu public axisSettingsFormGroup: UntypedFormGroup; constructor(private fb: UntypedFormBuilder, - private widgetService: WidgetService,) { + private widgetService: WidgetService) { } ngOnInit(): void { @@ -113,6 +113,7 @@ export class TimeSeriesChartAxisSettingsComponent implements OnInit, ControlValu this.axisSettingsFormGroup.addControl('units', this.fb.control(null, [])); this.axisSettingsFormGroup.addControl('decimals', this.fb.control(null, [Validators.min(0)])); this.axisSettingsFormGroup.addControl('ticksFormatter', this.fb.control(null, [])); + this.axisSettingsFormGroup.addControl('ticksGenerator', this.fb.control(null, [])); this.axisSettingsFormGroup.addControl('interval', this.fb.control(null, [Validators.min(0)])); this.axisSettingsFormGroup.addControl('splitNumber', this.fb.control(null, [Validators.min(1)])); this.axisSettingsFormGroup.addControl('min', this.fb.control(null, [])); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-state-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-state-row.component.html new file mode 100644 index 0000000000..a2e11c3a8e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-state-row.component.html @@ -0,0 +1,62 @@ + +
+ + + + + + + + + + {{ timeSeriesStateSourceTypeTranslations.get(type) | translate }} + + + +
+ + + + + + + + +
+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-state-row.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-state-row.component.scss new file mode 100644 index 0000000000..247eb5d28f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-state-row.component.scss @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import '../../../../../../../../../scss/constants'; + +.tb-form-table-row.tb-time-series-state-row { + @media #{$mat-lt-md} { + align-items: flex-start; + } + .tb-state-label-field { + flex: 1; + min-width: 70px; + } + .tb-state-value-field { + width: 80px; + min-width: 80px; + } + .tb-state-source-field { + width: 100px; + min-width: 100px; + } + .tb-state-source-value-field { + flex: 1; + min-width: 200px; + display: flex; + flex-direction: column; + gap: 8px; + @media #{$mat-gt-sm} { + min-width: 358px; + flex-direction: row; + align-items: center; + gap: 12px; + } + .tb-inline-field { + flex: 1; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-state-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-state-row.component.ts new file mode 100644 index 0000000000..97c87432c2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-state-row.component.ts @@ -0,0 +1,138 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES 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, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { + TimeSeriesChartStateSettings, + TimeSeriesChartStateSourceType, + timeSeriesStateSourceTypes, + timeSeriesStateSourceTypeTranslations +} from '@home/components/widget/lib/chart/time-series-chart.models'; + +@Component({ + selector: 'tb-time-series-chart-state-row', + templateUrl: './time-series-chart-state-row.component.html', + styleUrls: ['./time-series-chart-state-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartStateRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class TimeSeriesChartStateRowComponent implements ControlValueAccessor, OnInit { + + TimeSeriesChartStateSourceType = TimeSeriesChartStateSourceType; + + timeSeriesStateSourceTypes = timeSeriesStateSourceTypes; + + timeSeriesStateSourceTypeTranslations = timeSeriesStateSourceTypeTranslations; + + @Input() + disabled: boolean; + + @Output() + stateRemoved = new EventEmitter(); + + stateFormGroup: UntypedFormGroup; + + modelValue: TimeSeriesChartStateSettings; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private cd: ChangeDetectorRef) { + } + + ngOnInit() { + this.stateFormGroup = this.fb.group({ + label: [null, []], + value: [null, [Validators.required]], + sourceType: [null, [Validators.required]], + sourceValue: [null, [Validators.required]], + sourceRangeFrom: [null, []], + sourceRangeTo: [null, []] + }); + this.stateFormGroup.valueChanges.subscribe( + () => this.updateModel() + ); + this.stateFormGroup.get('sourceType').valueChanges.subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.stateFormGroup.disable({emitEvent: false}); + } else { + this.stateFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: TimeSeriesChartStateSettings): void { + this.modelValue = value; + this.stateFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + this.cd.markForCheck(); + } + + private updateValidators() { + const sourceType: TimeSeriesChartStateSourceType = this.stateFormGroup.get('sourceType').value; + if (sourceType === TimeSeriesChartStateSourceType.constant) { + this.stateFormGroup.get('sourceValue').enable({emitEvent: false}); + this.stateFormGroup.get('sourceRangeFrom').disable({emitEvent: false}); + this.stateFormGroup.get('sourceRangeTo').disable({emitEvent: false}); + } else if (sourceType === TimeSeriesChartStateSourceType.range) { + this.stateFormGroup.get('sourceValue').disable({emitEvent: false}); + this.stateFormGroup.get('sourceRangeFrom').enable({emitEvent: false}); + this.stateFormGroup.get('sourceRangeTo').enable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = this.stateFormGroup.value; + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-states-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-states-panel.component.html new file mode 100644 index 0000000000..5db76d1493 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-states-panel.component.html @@ -0,0 +1,47 @@ + +
+
{{ 'widgets.time-series-chart.state.states' | translate }}
+
+
+
widgets.time-series-chart.state.label
+
widgets.time-series-chart.state.ticks-value
+
widgets.time-series-chart.state.source
+
widgets.time-series-chart.state.value-range
+
+
+
+
+ + + +
+
+
+
+ +
+
+ + {{ 'widgets.time-series-chart.state.no-states' | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-states-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-states-panel.component.scss new file mode 100644 index 0000000000..3177b8cdc1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-states-panel.component.scss @@ -0,0 +1,60 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import '../../../../../../../../../scss/constants'; + +.tb-time-series-states-panel { + .tb-form-table { + overflow-x: auto; + } + .tb-form-table-header-cell { + &.tb-state-label-header { + flex: 1; + min-width: 80px; + } + &.tb-state-value-header { + width: 80px; + min-width: 80px; + } + &.tb-state-source-header { + width: 100px; + min-width: 100px; + } + &.tb-state-source-value-header { + flex: 1; + min-width: 200px; + @media #{$mat-gt-sm} { + min-width: 358px; + } + } + &.tb-actions-header { + width: 40px; + min-width: 40px; + } + } + .tb-form-table-header { + min-width: fit-content; + } + .tb-form-table-body { + min-width: fit-content; + .mat-divider { + margin-top: 8px; + @media #{$mat-gt-sm} { + display: none; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-states-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-states-panel.component.ts new file mode 100644 index 0000000000..8c30582425 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-states-panel.component.ts @@ -0,0 +1,144 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { + TimeSeriesChartStateSettings, + TimeSeriesChartStateSourceType, + timeSeriesChartStateValid, + timeSeriesChartStateValidator +} from '@home/components/widget/lib/chart/time-series-chart.models'; + +@Component({ + selector: 'tb-time-series-chart-states-panel', + templateUrl: './time-series-chart-states-panel.component.html', + styleUrls: ['./time-series-chart-states-panel.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartStatesPanelComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TimeSeriesChartStatesPanelComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class TimeSeriesChartStatesPanelComponent implements ControlValueAccessor, OnInit, Validator { + + @Input() + disabled: boolean; + + statesFormGroup: UntypedFormGroup; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder) { + } + + ngOnInit() { + this.statesFormGroup = this.fb.group({ + states: [this.fb.array([]), []] + }); + this.statesFormGroup.valueChanges.subscribe( + () => { + let states: TimeSeriesChartStateSettings[] = this.statesFormGroup.get('states').value; + if (states) { + states = states.filter(s => timeSeriesChartStateValid(s)); + } + this.propagateChange(states); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.statesFormGroup.disable({emitEvent: false}); + } else { + this.statesFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: TimeSeriesChartStateSettings[] | undefined): void { + const states = value || []; + this.statesFormGroup.setControl('states', this.prepareStatesFormArray(states), {emitEvent: false}); + } + + public validate(c: UntypedFormControl) { + const valid = this.statesFormGroup.valid; + return valid ? null : { + states: { + valid: false, + }, + }; + } + + statesFormArray(): UntypedFormArray { + return this.statesFormGroup.get('states') as UntypedFormArray; + } + + trackByState(index: number, stateControl: AbstractControl): any { + return stateControl; + } + + removeState(index: number) { + (this.statesFormGroup.get('states') as UntypedFormArray).removeAt(index); + } + + addState() { + const state: TimeSeriesChartStateSettings = { + label: '', + value: 0, + sourceType: TimeSeriesChartStateSourceType.constant + }; + const statesArray = this.statesFormGroup.get('states') as UntypedFormArray; + const stateControl = this.fb.control(state, [timeSeriesChartStateValidator]); + statesArray.push(stateControl); + } + + private prepareStatesFormArray(states: TimeSeriesChartStateSettings[] | undefined): UntypedFormArray { + const statesControls: Array = []; + if (states) { + states.forEach((state) => { + statesControls.push(this.fb.control(state, [timeSeriesChartStateValidator])); + }); + } + return this.fb.array(statesControls); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index 86e460ff9d..e25282bb4c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -133,6 +133,12 @@ import { import { TimeSeriesChartThresholdSettingsComponent } from '@home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings.component'; +import { + TimeSeriesChartStateRowComponent +} from '@home/components/widget/lib/settings/common/chart/time-series-chart-state-row.component'; +import { + TimeSeriesChartStatesPanelComponent +} from '@home/components/widget/lib/settings/common/chart/time-series-chart-states-panel.component'; @NgModule({ declarations: [ @@ -182,6 +188,8 @@ import { TimeSeriesChartAnimationSettingsComponent, TimeSeriesChartFillSettingsComponent, TimeSeriesChartThresholdSettingsComponent, + TimeSeriesChartStatesPanelComponent, + TimeSeriesChartStateRowComponent, DataKeyInputComponent, EntityAliasInputComponent ], @@ -237,6 +245,8 @@ import { TimeSeriesChartAnimationSettingsComponent, TimeSeriesChartFillSettingsComponent, TimeSeriesChartThresholdSettingsComponent, + TimeSeriesChartStatesPanelComponent, + TimeSeriesChartStateRowComponent, DataKeyInputComponent, EntityAliasInputComponent ], diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/chart/ticks_generator_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/chart/ticks_generator_fn.md new file mode 100644 index 0000000000..e3b5bf817a --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/chart/ticks_generator_fn.md @@ -0,0 +1,61 @@ +#### Ticks generator function + +
+
+ +*function (extent): {value: number}[]* + +A JavaScript function used to generate Y axis ticks. + +**Parameters:** + +
    +
  • extent: number[] - An array of two numbers holding axis min and max values [axisMin, axisMax]. +
  • +
+ +**Returns:** + +An array of tick values with the following structure: + +```typescript +{ + value: number +} +``` + +
+ +##### Examples + +* Always display only one tick in the middle: + +```javascript +return extent ? [{ value: (extent[0] + extent[1]) / 2}] : []; +{:copy-code} +``` + +* Display only min and max ticks: + +```javascript +if (extent) { + return [ {value: extent[0]}, {value: extent[1]} ]; +} else { + return []; +} +{:copy-code} +``` + +* Disable ticks: + +```javascript +return []; +{:copy-code} +``` + +* Constant ticks (1,2,3): + +```javascript +return [ {value: 1}, {value: 2}, {value: 3} ]; +{:copy-code} +``` diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index ea3d0f62d0..1f3b79f292 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -6786,6 +6786,20 @@ "label-position-inside-end-bottom": "Inside end bottom", "label-background": "Label background" }, + "state": { + "states": "States", + "label": "Label", + "ticks-value": "Ticks value", + "source": "Source", + "value-range": "Value / Range", + "no-states": "No states configured", + "add-state": "Add state", + "type-constant": "Constant", + "type-range": "Range", + "from": "From", + "to": "To", + "remove-state": "Remove state" + }, "axis": { "axes": "Axes", "x-axis": "X axis", @@ -6801,6 +6815,7 @@ "position-bottom": "Bottom", "tick-labels": "Tick labels", "ticks-formatter-function": "Ticks formatter function", + "ticks-generator-function": "Ticks generator function", "show-ticks": "Show ticks", "show-line": "Show line", "show-split-lines": "Show split lines",