Browse Source

Merge branch 'master' into fix/4336-gateway-default-credentials

pull/11592/head
Max Petrov 2 years ago
committed by GitHub
parent
commit
9ec4cfc681
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 158
      application/src/main/data/json/system/scada_symbols/leak-sensor.svg
  2. 679
      application/src/main/data/json/system/scada_symbols/left-analog-water-level-meter.svg
  3. 679
      application/src/main/data/json/system/scada_symbols/right-analog-water-level-meter.svg
  4. 16
      application/src/main/data/json/system/scada_symbols/stand-filter.svg
  5. 276
      application/src/main/data/json/system/scada_symbols/waterstop.svg
  6. 4
      application/src/main/data/json/system/widget_bundles/scada_water_system_symbols.json
  7. 2
      application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java
  8. 63
      application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java
  9. 108
      application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java
  10. 37
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java
  11. 94
      common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java
  12. 2
      pom.xml
  13. 10
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNode.java
  14. 8
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNodeConfiguration.java
  15. 16
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java
  16. 41
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java
  17. 2
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java
  18. 65
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNodeTest.java
  19. 36
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java
  20. 5
      ui-ngx/angular.json
  21. 2
      ui-ngx/src/app/core/auth/auth.service.ts
  22. 8
      ui-ngx/src/app/core/services/dashboard-utils.service.ts
  23. 11
      ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts
  24. 2
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts
  25. 23
      ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts
  26. 36
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
  27. 45
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts
  28. 176
      ui-ngx/src/app/modules/home/models/dashboard-component.models.ts
  29. 2
      ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.html
  30. 25
      ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html
  31. 10
      ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.scss
  32. 75
      ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts
  33. 1
      ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts
  34. 6
      ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html
  35. 8
      ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts
  36. 38
      ui-ngx/src/app/shared/components/svg-xml.component.html
  37. 85
      ui-ngx/src/app/shared/components/svg-xml.component.scss
  38. 211
      ui-ngx/src/app/shared/components/svg-xml.component.ts
  39. 31
      ui-ngx/src/app/shared/components/toggle-header.component.html
  40. 38
      ui-ngx/src/app/shared/components/toggle-header.component.scss
  41. 12
      ui-ngx/src/app/shared/components/toggle-header.component.ts
  42. 4
      ui-ngx/src/app/shared/components/toggle-select.component.html
  43. 22
      ui-ngx/src/app/shared/components/toggle-select.component.scss
  44. 14
      ui-ngx/src/app/shared/components/toggle-select.component.ts
  45. 4
      ui-ngx/src/app/shared/models/ace/ace.models.ts
  46. 2
      ui-ngx/src/app/shared/models/dashboard.models.ts
  47. 2
      ui-ngx/src/app/shared/models/widget.models.ts
  48. 7
      ui-ngx/src/app/shared/shared.module.ts
  49. 19
      ui-ngx/src/assets/locale/locale.constant-en_US.json

158
application/src/main/data/json/system/scada_symbols/leak-sensor.svg

@ -0,0 +1,158 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:tb="https://thingsboard.io/svg" width="100" height="100" fill="none" version="1.1" viewBox="0 0 100 100">
<tb:metadata xmlns=""><![CDATA[{
"title": "Leak sensor",
"description": "Leak sensor",
"searchTags": [
"leak"
],
"widgetSizeX": 1,
"widgetSizeY": 1,
"tags": [
{
"tag": "background",
"stateRenderFunction": "var fill = ctx.properties.backgroundColor;\nelement.attr({fill: fill});",
"actions": null
},
{
"tag": "clickArea",
"stateRenderFunction": null,
"actions": {
"click": {
"actionFunction": "ctx.api.callAction(event, 'click');"
}
}
},
{
"tag": "icon",
"stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.attr({stroke: ctx.properties.leakColor});\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n} else {\n element.attr({stroke: ctx.properties.defaultColor});\n ctx.api.resetAnimation(element);\n}\n",
"actions": null
}
],
"behavior": [
{
"id": "leak",
"name": "{i18n:scada.symbol.leak}",
"hint": "{i18n:scada.symbol.water-leak-hint}",
"group": null,
"type": "value",
"valueType": "BOOLEAN",
"trueLabel": null,
"falseLabel": null,
"stateLabel": null,
"defaultGetValueSettings": {
"action": "GET_TIME_SERIES",
"defaultValue": false,
"executeRpc": {
"method": "getState",
"requestTimeout": 5000,
"requestPersistent": false,
"persistentPollingInterval": 1000
},
"getAttribute": {
"scope": null,
"key": "state"
},
"getTimeSeries": {
"key": "leak"
},
"dataToValue": {
"type": "NONE",
"dataToValueFunction": "/* Should return boolean value */\nreturn data;",
"compareToValue": true
}
},
"defaultSetValueSettings": null,
"defaultWidgetActionSettings": null
},
{
"id": "click",
"name": "{i18n:scada.symbol.on-click}",
"hint": "{i18n:scada.symbol.on-click-hint}",
"group": null,
"type": "widgetAction",
"valueType": "BOOLEAN",
"trueLabel": null,
"falseLabel": null,
"stateLabel": null,
"defaultGetValueSettings": null,
"defaultSetValueSettings": null,
"defaultWidgetActionSettings": {
"type": "doNothing",
"targetDashboardStateId": null,
"openRightLayout": false,
"setEntityId": false,
"stateEntityParamName": null
}
}
],
"properties": [
{
"id": "backgroundColor",
"name": "{i18n:scada.symbol.background-color}",
"type": "color",
"default": "#FFFFFF",
"required": null,
"subLabel": null,
"divider": null,
"fieldSuffix": null,
"disableOnProperty": null,
"rowClass": "",
"fieldClass": "",
"min": null,
"max": null,
"step": null
},
{
"id": "defaultColor",
"name": "{i18n:scada.symbol.default-color}",
"type": "color",
"default": "#1C943E",
"required": null,
"subLabel": null,
"divider": null,
"fieldSuffix": null,
"disableOnProperty": null,
"rowClass": "",
"fieldClass": "",
"min": null,
"max": null,
"step": null
},
{
"id": "leakColor",
"name": "{i18n:scada.symbol.leak-color}",
"type": "color",
"default": "#D12730",
"required": null,
"subLabel": null,
"divider": null,
"fieldSuffix": null,
"disableOnProperty": null,
"rowClass": "",
"fieldClass": "",
"min": null,
"max": null,
"step": null
}
]
}]]></tb:metadata><rect x=".5" y=".5" width="99" height="99" rx="23.5" fill="#fff" tb:tag="background"/><rect x=".5" y=".5" width="99" height="99" rx="23.5" fill="url(#paint0_linear_2089_217648)"/><rect x=".5" y=".5" width="99" height="99" rx="23.5" fill="url(#paint1_linear_2089_217648)"/><rect x=".5" y=".5" width="99" height="99" rx="23.5" stroke="#fff"/><path d="m56.668 52.309c0 2.04-0.7902 3.9966-2.1967 5.4391-0.7985 0.819-1.7586 1.4347-2.8035 1.8135m10-6.7401c0-10.962-11.667-19.487-11.667-19.487s-11.667 8.5256-11.667 19.487c0 3.2301 1.2292 6.3283 3.4171 8.6125 2.1879 2.284 5.1556 3.5659 8.2497 3.5659 3.0942 0 6.0615-1.2826 8.2493-3.5666 2.188-2.2842 3.4172-5.3817 3.4172-8.6118z" stroke="#1C943E" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" tb:tag="icon"/><path d="m33.631 0s-33.631 0-33.631 16.75v82.09c0 0.6628 0.89543 1.1601 2 1.1601h96c1.1045 0 2-0.4973 2-1.1601v-82.09c0-16.75-33.035-16.75-33.035-16.75h-16.965zm33.535 20.3c-0.64434 0-1.1666 0.3134-1.1666 0.7v75.1c0 0.3866 0.52234 0.7 1.1666 0.7h7.3336c0.64434 0 1.1666-0.3134 1.1666-0.7v-75.1c0-0.3866-0.52233-0.7-1.1666-0.7z" fill="#000" fill-opacity="0" style="stroke-width:.059761" tb:tag="clickArea"/><defs>
<linearGradient id="paint0_linear_2089_217648" x1="100" x2="1.5002" y1="50.119" y2="49.881" gradientUnits="userSpaceOnUse">
<stop stop-color="#020202" stop-opacity=".2" offset="0"/>
<stop stop-color="#020202" stop-opacity=".12" offset=".05"/>
<stop stop-color="#fff" stop-opacity=".12" offset=".15"/>
<stop stop-color="#fff" stop-opacity=".2" offset=".51114"/>
<stop stop-color="#fff" stop-opacity=".12" offset=".85"/>
<stop stop-color="#020202" stop-opacity=".12" offset=".95"/>
<stop stop-color="#020202" stop-opacity=".2" offset=".99341"/>
</linearGradient>
<linearGradient id="paint1_linear_2089_217648" x1="50" x2="50" y2="100" gradientUnits="userSpaceOnUse">
<stop stop-color="#020202" stop-opacity=".2" offset=".01"/>
<stop stop-color="#020202" stop-opacity=".12" offset=".05"/>
<stop stop-color="#fff" stop-opacity=".12" offset=".15343"/>
<stop stop-color="#fff" stop-opacity=".2" offset=".5"/>
<stop stop-color="#fff" stop-opacity=".12" offset=".85"/>
<stop stop-opacity=".12" offset=".95"/>
<stop stop-opacity=".2" offset="1"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

679
application/src/main/data/json/system/scada_symbols/left-analog-water-level-meter.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 55 KiB

679
application/src/main/data/json/system/scada_symbols/right-analog-water-level-meter.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 55 KiB

16
application/src/main/data/json/system/scada_symbols/stand-filter.svg

@ -7,7 +7,7 @@
], ],
"widgetSizeX": 3, "widgetSizeX": 3,
"widgetSizeY": 5, "widgetSizeY": 5,
"stateRenderFunction": "", "stateRenderFunction": "var running = ctx.values.running;\nif (running) {\n ctx.api.enable(svg.children());\n} else {\n ctx.api.disable(svg.children());\n}",
"tags": [ "tags": [
{ {
"tag": "background", "tag": "background",
@ -25,7 +25,7 @@
}, },
{ {
"tag": "filterMode", "tag": "filterMode",
"stateRenderFunction": "var defaultBorderColor = ctx.properties.defaultBorderColor;\nvar activeBorderColor = ctx.properties.activeBorderColor;\nvar defaultLabelColor = ctx.properties.defaultLabelColor;\nvar activeLabelColor = ctx.properties.activeLabelColor;\nvar boxBackground = ctx.properties.modeBoxBackground;\n\nvar running = ctx.values.running;\nvar filtrationMode = ctx.values.filtrationMode;\n\nvar filtrationMap = {};\nvar bottomShift = 104;\nvar rightShift = 226;\nvar middleShift = 121;\n\nvar filterModeSet = element.remember('filterModeSet');\n\nif (!filterModeSet) {\n element.remember('filterModeSet', true);\n var clone = element.children()[0];\n setFilterModeColors(clone);\n element.clear();\n \n var i = 0;\n if (ctx.properties.filtrationMode) {\n i++;\n filtrationMap[i] = 'filter';\n }\n if (ctx.properties.wasteMode) {\n i++;\n filtrationMap[i] = 'waste';\n }\n if (ctx.properties.backwashMode) {\n i++;\n filtrationMap[i] = 'backwash';\n }\n if (ctx.properties.recirculateMode) {\n i++;\n filtrationMap[i] = 'recirculate';\n }\n if (ctx.properties.rinseMode) {\n i++;\n filtrationMap[i] = 'rinse';\n }\n if (ctx.properties.closedMode) {\n i++;\n filtrationMap[i] = 'closed';\n }\n \n var filterMode = Object.values(filtrationMap);\n var lastToMiddle = filterMode.length % 2;\n \n filterMode.forEach((mode, index, arr) => {\n var template = clone.clone();\n var x = (index % 2) * rightShift;\n var y = Math.floor((index % filterMode.length) / 2) * bottomShift;\n if (index === filterMode.length-1 && lastToMiddle) {\n x = middleShift;\n }\n template.attr({'class': mode}).css('cursor', 'pointer').translate(x, y);\n ctx.api.text(template.findOne('text'), capitalizeFirstLetter(mode));\n template.click((event) => click(event, getFilterModeKey(mode)));\n element.add(template);\n })\n if (isFinite(filtrationMode)) {\n setFilterModeColorsByMap(filtrationMode, running);\n }\n}\n\nif (running) {\n ctx.api.enable(element.children());\n} else {\n ctx.api.disable(element.children());\n}\n\nfunction click(event, index) {\n var filtrationMode = ctx.values.filtrationMode;\n if (ctx.values.running && isFinite(filtrationMode)) {\n ctx.api.disable(element.children());\n var newValue = +index;\n if (newValue === filtrationMode) {\n newValue = 0;\n } else {\n setFilterModeColorsByMap(filtrationMode);\n }\n ctx.api.setValue('filtrationMode', newValue);\n ctx.api.callAction(event, 'filtrationModeUpdateState', newValue, {\n next: () => {\n setFilterModeColorsByMap(newValue ? newValue: filtrationMode, newValue);\n ctx.api.enable(element.children());\n },\n error: () => {\n setFilterModeColorsByMap(newValue);\n ctx.api.setValue('filtrationMode', filtrationMode);\n }\n });\n }\n}\n\nfunction getFilterModeKey(value) {\n return Object.keys(filtrationMap).find(key => filtrationMap[key] === value);\n}\n\nfunction setFilterModeColorsByMap(mode, active = false) {\n var filterBox = element.findOne('g.'+filtrationMap[mode])\n if (filterBox) {\n return setFilterModeColors(filterBox, active);\n }\n}\n\nfunction setFilterModeColors(filterBox, active = false) {\n if (filterBox) {\n var borderColor = active ? activeBorderColor : defaultBorderColor;\n var labelColor = active ? activeLabelColor : defaultLabelColor;\n filterBox.children()[0].fill(boxBackground);\n filterBox.children()[1].stroke(borderColor);\n filterBox.children()[2].fill(borderColor);\n ctx.api.font(filterBox.findOne('text'), null, labelColor);\n }\n}\n\nfunction capitalizeFirstLetter(string) {\n return string.charAt(0).toUpperCase() + string.slice(1);\n}", "stateRenderFunction": "var defaultBorderColor = ctx.properties.defaultBorderColor;\nvar activeBorderColor = ctx.properties.activeBorderColor;\nvar defaultLabelColor = ctx.properties.defaultLabelColor;\nvar activeLabelColor = ctx.properties.activeLabelColor;\nvar boxBackground = ctx.properties.modeBoxBackground;\n\nvar running = ctx.values.running;\nvar filtrationMode = ctx.values.filtrationMode;\n\nvar filtrationMap = {};\nvar bottomShift = 104;\nvar rightShift = 226;\nvar middleShift = 121;\n\nvar filterModeSet = element.remember('filterModeSet');\n\nvar i = 0;\nif (ctx.properties.filtrationMode) {\n i++;\n filtrationMap[i] = 'filter';\n}\nif (ctx.properties.wasteMode) {\n i++;\n filtrationMap[i] = 'waste';\n}\nif (ctx.properties.backwashMode) {\n i++;\n filtrationMap[i] = 'backwash';\n}\nif (ctx.properties.recirculateMode) {\n i++;\n filtrationMap[i] = 'recirculate';\n}\nif (ctx.properties.rinseMode) {\n i++;\n filtrationMap[i] = 'rinse';\n}\nif (ctx.properties.closedMode) {\n i++;\n filtrationMap[i] = 'closed';\n}\n\nif (!filterModeSet) {\n element.remember('filterModeSet', true);\n var clone = element.children()[0];\n setFilterModeColors(clone);\n element.clear();\n \n var filterMode = Object.values(filtrationMap);\n var lastToMiddle = filterMode.length % 2;\n \n filterMode.forEach((mode, index, arr) => {\n var template = clone.clone();\n var x = (index % 2) * rightShift;\n var y = Math.floor((index % filterMode.length) / 2) * bottomShift;\n if (index === filterMode.length-1 && lastToMiddle) {\n x = middleShift;\n }\n template.attr({'class': mode}).css('cursor', 'pointer').translate(x, y);\n ctx.api.text(template.findOne('text'), capitalizeFirstLetter(mode));\n template.click((event) => click(event, getFilterModeKey(mode)));\n element.add(template);\n })\n}\n\nif (isFinite(filtrationMode)) {\n if (element.findOne('.active')) {\n setFilterModeColors(element.findOne('.active'));\n }\n setFilterModeColorsByMap(filtrationMode, running);\n}\n\nfunction click(event, index) {\n var filtrationMode = ctx.values.filtrationMode;\n if (ctx.values.running && isFinite(filtrationMode)) {\n ctx.api.disable(element.children());\n var newValue = +index;\n if (newValue === filtrationMode) {\n newValue = 0;\n } else {\n setFilterModeColorsByMap(filtrationMode);\n }\n ctx.api.setValue('filtrationMode', newValue);\n ctx.api.callAction(event, 'filtrationModeUpdateState', newValue, {\n next: () => {\n setFilterModeColorsByMap(newValue ? newValue: filtrationMode, newValue);\n ctx.api.enable(element.children());\n },\n error: () => {\n setFilterModeColorsByMap(newValue);\n ctx.api.setValue('filtrationMode', filtrationMode);\n ctx.api.enable(element.children());\n }\n });\n }\n}\n\nfunction getFilterModeKey(value) {\n return Object.keys(filtrationMap).find(key => filtrationMap[key] === value);\n}\n\nfunction setFilterModeColorsByMap(mode, active = false) {\n var filterBox = element.findOne('g.'+filtrationMap[mode])\n if (filterBox) {\n return setFilterModeColors(filterBox, active);\n }\n}\n\nfunction setFilterModeColors(filterBox, active = false) {\n if (filterBox) {\n if (active) {\n filterBox.addClass('active');\n } else {\n filterBox.removeClass('active');\n }\n var borderColor = active ? activeBorderColor : defaultBorderColor;\n var labelColor = active ? activeLabelColor : defaultLabelColor;\n filterBox.children()[0].fill(boxBackground);\n filterBox.children()[1].stroke(borderColor);\n filterBox.children()[2].fill(borderColor);\n ctx.api.font(filterBox.findOne('text'), null, labelColor);\n }\n}\n\nfunction capitalizeFirstLetter(string) {\n return string.charAt(0).toUpperCase() + string.slice(1);\n}",
"actions": null "actions": null
} }
], ],
@ -159,7 +159,7 @@
"id": "filtrationMode", "id": "filtrationMode",
"name": "{i18n:scada.symbol.filter-mode}", "name": "{i18n:scada.symbol.filter-mode}",
"type": "switch", "type": "switch",
"default": false, "default": true,
"required": null, "required": null,
"subLabel": null, "subLabel": null,
"divider": null, "divider": null,
@ -175,7 +175,7 @@
"id": "wasteMode", "id": "wasteMode",
"name": "{i18n:scada.symbol.waste-mode}", "name": "{i18n:scada.symbol.waste-mode}",
"type": "switch", "type": "switch",
"default": false, "default": true,
"required": null, "required": null,
"subLabel": null, "subLabel": null,
"divider": null, "divider": null,
@ -191,7 +191,7 @@
"id": "backwashMode", "id": "backwashMode",
"name": "{i18n:scada.symbol.backwash-mode}", "name": "{i18n:scada.symbol.backwash-mode}",
"type": "switch", "type": "switch",
"default": false, "default": true,
"required": null, "required": null,
"subLabel": null, "subLabel": null,
"divider": null, "divider": null,
@ -207,7 +207,7 @@
"id": "recirculateMode", "id": "recirculateMode",
"name": "{i18n:scada.symbol.recirculate-mode}", "name": "{i18n:scada.symbol.recirculate-mode}",
"type": "switch", "type": "switch",
"default": false, "default": true,
"required": null, "required": null,
"subLabel": null, "subLabel": null,
"divider": null, "divider": null,
@ -223,7 +223,7 @@
"id": "rinseMode", "id": "rinseMode",
"name": "{i18n:scada.symbol.rinse-mode}", "name": "{i18n:scada.symbol.rinse-mode}",
"type": "switch", "type": "switch",
"default": false, "default": true,
"required": null, "required": null,
"subLabel": null, "subLabel": null,
"divider": null, "divider": null,
@ -239,7 +239,7 @@
"id": "closedMode", "id": "closedMode",
"name": "{i18n:scada.symbol.closed-mode}", "name": "{i18n:scada.symbol.closed-mode}",
"type": "switch", "type": "switch",
"default": false, "default": true,
"required": null, "required": null,
"subLabel": null, "subLabel": null,
"divider": null, "divider": null,

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

276
application/src/main/data/json/system/scada_symbols/waterstop.svg

@ -0,0 +1,276 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:tb="https://thingsboard.io/svg" width="200" height="200" fill="none" version="1.1" viewBox="0 0 200 200">
<tb:metadata xmlns=""><![CDATA[{
"title": "Water stop",
"description": "Remotely controlled water shutoff valve with configurable connectors and various states.",
"searchTags": [
"water stop"
],
"widgetSizeX": 1,
"widgetSizeY": 1,
"stateRenderFunction": "",
"tags": [
{
"tag": "clickArea",
"stateRenderFunction": null,
"actions": {
"click": {
"actionFunction": "var opened = ctx.values.opened;\nvar action = opened ? 'close' : 'open';\n\nctx.api.callAction(event, action, undefined, {\n next: () => {\n ctx.api.setValue('opened', !opened);\n }\n});"
}
}
},
{
"tag": "pipe-color",
"stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});",
"actions": null
},
{
"tag": "walve",
"stateRenderFunction": "var opened = ctx.values.opened;\nif (opened) {\n element.attr({fill: ctx.properties.openedColor});\n ctx.api.resetAnimation(element);\n} else {\n element.attr({fill: ctx.properties.closedColor});\n if (ctx.values.closeAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n}\n",
"actions": null
}
],
"behavior": [
{
"id": "opened",
"name": "{i18n:scada.symbol.opened}",
"hint": "{i18n:scada.symbol.opened-hint}",
"group": null,
"type": "value",
"valueType": "BOOLEAN",
"trueLabel": null,
"falseLabel": null,
"stateLabel": "{i18n:scada.symbol.opened}",
"defaultGetValueSettings": {
"action": "GET_ATTRIBUTE",
"defaultValue": false,
"executeRpc": {
"method": "getState",
"requestTimeout": 5000,
"requestPersistent": false,
"persistentPollingInterval": 1000
},
"getAttribute": {
"scope": "SHARED_SCOPE",
"key": "open"
},
"getTimeSeries": {
"key": "state"
},
"dataToValue": {
"type": "NONE",
"dataToValueFunction": "/* Should return boolean value */\nreturn data;",
"compareToValue": true
}
},
"defaultSetValueSettings": null,
"defaultWidgetActionSettings": null
},
{
"id": "open",
"name": "{i18n:scada.symbol.open}",
"hint": "{i18n:scada.symbol.open-hint}",
"group": null,
"type": "action",
"valueType": "BOOLEAN",
"trueLabel": null,
"falseLabel": null,
"stateLabel": null,
"defaultGetValueSettings": null,
"defaultSetValueSettings": {
"action": "SET_ATTRIBUTE",
"executeRpc": {
"method": "setState",
"requestTimeout": 5000,
"requestPersistent": false,
"persistentPollingInterval": 1000
},
"setAttribute": {
"scope": "SHARED_SCOPE",
"key": "open"
},
"putTimeSeries": {
"key": "state"
},
"valueToData": {
"type": "CONSTANT",
"constantValue": false,
"valueToDataFunction": "/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;"
}
},
"defaultWidgetActionSettings": null
},
{
"id": "close",
"name": "{i18n:scada.symbol.close}",
"hint": "{i18n:scada.symbol.close-hint}",
"group": null,
"type": "action",
"valueType": "BOOLEAN",
"trueLabel": null,
"falseLabel": null,
"stateLabel": null,
"defaultGetValueSettings": null,
"defaultSetValueSettings": {
"action": "SET_ATTRIBUTE",
"executeRpc": {
"method": "setState",
"requestTimeout": 5000,
"requestPersistent": false,
"persistentPollingInterval": 1000
},
"setAttribute": {
"scope": "SHARED_SCOPE",
"key": "open"
},
"putTimeSeries": {
"key": "state"
},
"valueToData": {
"type": "CONSTANT",
"constantValue": true,
"valueToDataFunction": "/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;"
}
},
"defaultWidgetActionSettings": null
},
{
"id": "closeAnimation",
"name": "{i18n:scada.symbol.close-state-animation}",
"hint": "{i18n:scada.symbol.close-state-animation-hint}",
"group": null,
"type": "value",
"valueType": "BOOLEAN",
"trueLabel": null,
"falseLabel": null,
"stateLabel": "{i18n:scada.symbol.animation}",
"defaultGetValueSettings": {
"action": "DO_NOTHING",
"defaultValue": false,
"executeRpc": {
"method": "getState",
"requestTimeout": 5000,
"requestPersistent": false,
"persistentPollingInterval": 1000
},
"getAttribute": {
"key": "state",
"scope": null
},
"getTimeSeries": {
"key": "state"
},
"dataToValue": {
"type": "NONE",
"compareToValue": true,
"dataToValueFunction": "/* Should return boolean value */\nreturn data;"
}
},
"defaultSetValueSettings": null,
"defaultWidgetActionSettings": null
}
],
"properties": [
{
"id": "openedColor",
"name": "{i18n:scada.symbol.opened-color}",
"type": "color",
"default": "#1C943E",
"required": null,
"subLabel": null,
"divider": null,
"fieldSuffix": null,
"disableOnProperty": null,
"rowClass": "",
"fieldClass": "",
"min": null,
"max": null,
"step": null
},
{
"id": "closedColor",
"name": "{i18n:scada.symbol.closed-color}",
"type": "color",
"default": "#D12730",
"required": null,
"subLabel": null,
"divider": null,
"fieldSuffix": null,
"disableOnProperty": null,
"rowClass": "",
"fieldClass": "",
"min": null,
"max": null,
"step": null
},
{
"id": "pipeColor",
"name": "{i18n:scada.symbol.pipe-color}",
"type": "color",
"default": "#FFFFFF",
"required": null,
"subLabel": null,
"divider": null,
"fieldSuffix": null,
"disableOnProperty": null,
"rowClass": "",
"fieldClass": "",
"min": null,
"max": null,
"step": null
}
]
}]]></tb:metadata><path d="M14 64H186V136H14V64Z" fill="#fff" tb:tag="pipe-color"/><path d="M14 64H186V136H14V64Z" fill="url(#paint0_linear_2089_217683)"/><path d="m15.5 65.5h169v69h-169v-69z" stroke="#000" stroke-opacity=".12" stroke-width="3"/><rect x="187.5" y="51.5" width="11" height="97" rx="5.5" fill="#D9D9D9" stroke="#727171" stroke-width="3"/><rect x="1.5" y="51.5" width="11" height="97" rx="5.5" fill="#D9D9D9" stroke="#727171" stroke-width="3"/><path d="m70.5 46c0 1.933 1.567 3.5 3.5 3.5l52 1e-4c1.933 0 3.5-1.567 3.5-3.5v-37c0-4.6944-3.806-8.5-8.5-8.5h-42c-4.6944 0-8.5 3.8056-8.5 8.5v37z" fill="#1C943E" tb:tag="walve"/><path d="m70.5 46c0 1.933 1.567 3.5 3.5 3.5l52 1e-4c1.933 0 3.5-1.567 3.5-3.5v-37c0-4.6944-3.806-8.5-8.5-8.5h-42c-4.6944 0-8.5 3.8056-8.5 8.5v37z" fill="url(#paint1_linear_2089_217683)"/><path d="m70.5 46c0 1.933 1.567 3.5 3.5 3.5l52 1e-4c1.933 0 3.5-1.567 3.5-3.5v-37c0-4.6944-3.806-8.5-8.5-8.5h-42c-4.6944 0-8.5 3.8056-8.5 8.5v37z" stroke="#fff"/><path d="m110 136v-72h-21v72h21z" fill="#1C943E" tb:tag="walve"/><path d="m110 136v-72h-21v72h21z" fill="url(#paint2_linear_2089_217683)"/><path d="m110 136v-72h-21v72h21z" fill="url(#paint3_linear_2089_217683)"/><path d="m109.5 135.5v-71h-20v71h20z" stroke="#000" stroke-opacity=".12"/><path d="m126 64c2.209 0 4-1.7908 4-3.9999v-6c0-2.2092-1.791-4.0001-4-4.0001h-52c-2.2091 0-4 1.7909-4 4v6c0 2.2091 1.7909 4 4 4h52z" fill="#647484"/><path d="m126 64c2.209 0 4-1.7908 4-3.9999v-6c0-2.2092-1.791-4.0001-4-4.0001h-52c-2.2091 0-4 1.7909-4 4v6c0 2.2091 1.7909 4 4 4h52z" fill="url(#paint4_linear_2089_217683)"/><path d="m126 62.5c1.381 0 2.5-1.1193 2.5-2.5v-6c0-1.3807-1.119-2.5-2.5-2.5h-52c-1.3807 0-2.5 1.1193-2.5 2.5v6c0 1.3807 1.1193 2.5 2.5 2.5h52z" stroke="#000" stroke-opacity=".12" stroke-width="3"/><path d="m67.262 0s-67.262 0-67.262 33.5v164.18c0 1.3256 1.7909 2.3202 4 2.3202h192c2.209 0 4-0.9946 4-2.3202v-164.18c0-33.5-66.07-33.5-66.07-33.5h-33.93zm67.07 40.6c-1.2887 0-2.3332 0.6268-2.3332 1.4v150.2c0 0.7732 1.0447 1.4 2.3332 1.4h14.667c1.2887 0 2.3332-0.6268 2.3332-1.4v-150.2c0-0.7732-1.0447-1.4-2.3332-1.4z" fill="#000" fill-opacity="0" tb:tag="clickArea"/><defs>
<linearGradient id="paint0_linear_2089_217683" x1="58.72" x2="58.53" y1="64" y2="136" gradientUnits="userSpaceOnUse">
<stop stop-color="#727171" offset="0"/>
<stop stop-color="#727171" stop-opacity=".35" offset=".26388"/>
<stop stop-color="#727171" stop-opacity=".1" offset=".41759"/>
<stop stop-color="#fff" stop-opacity="0" offset=".49829"/>
<stop stop-color="#727171" stop-opacity=".1" offset=".58094"/>
<stop stop-color="#727171" stop-opacity=".35" offset=".71855"/>
<stop stop-color="#727171" offset="1"/>
</linearGradient>
<linearGradient id="paint1_linear_2089_217683" x1="134.3" x2="65.705" y1="8.0405" y2="9.0306" gradientUnits="userSpaceOnUse">
<stop stop-color="#020202" stop-opacity=".35" offset="0"/>
<stop stop-color="#020202" stop-opacity=".12" offset=".14093"/>
<stop stop-color="#fff" stop-opacity=".12" offset=".39937"/>
<stop stop-color="#fff" stop-opacity=".3" offset=".49829"/>
<stop stop-color="#fff" stop-opacity=".12" offset=".59721"/>
<stop stop-color="#020202" stop-opacity=".12" offset=".85946"/>
<stop stop-color="#020202" stop-opacity=".35" offset="1"/>
</linearGradient>
<linearGradient id="paint2_linear_2089_217683" x1="99.475" x2="100.06" y1="64" y2="134.92" gradientUnits="userSpaceOnUse">
<stop stop-color="#020202" stop-opacity=".35" offset="0"/>
<stop stop-color="#020202" stop-opacity=".15" offset=".15823"/>
<stop stop-color="#fff" stop-opacity=".12" offset=".28193"/>
<stop stop-color="#fff" stop-opacity=".2" offset=".50291"/>
<stop stop-color="#fff" stop-opacity=".12" offset=".72128"/>
<stop stop-color="#020202" stop-opacity=".15" offset=".8423"/>
<stop stop-color="#020202" stop-opacity=".35" offset=".99341"/>
</linearGradient>
<linearGradient id="paint3_linear_2089_217683" x1="110" x2="89" y1="100" y2="100" gradientUnits="userSpaceOnUse">
<stop stop-color="#020202" stop-opacity=".2" offset=".01"/>
<stop stop-color="#020202" stop-opacity=".12" offset=".05"/>
<stop stop-color="#fff" stop-opacity=".12" offset=".15343"/>
<stop stop-color="#fff" stop-opacity=".2" offset=".5"/>
<stop stop-color="#fff" stop-opacity=".12" offset=".85"/>
<stop stop-opacity=".12" offset=".95"/>
<stop stop-opacity=".2" offset="1"/>
</linearGradient>
<linearGradient id="paint4_linear_2089_217683" x1="72.287" x2="127.11" y1="55.78" y2="57.142" gradientUnits="userSpaceOnUse">
<stop stop-color="#020202" stop-opacity=".2" offset="0"/>
<stop stop-color="#020202" stop-opacity=".18" offset=".090959"/>
<stop stop-color="#020202" stop-opacity=".1" offset=".1"/>
<stop stop-color="#020202" stop-opacity=".08" offset=".20513"/>
<stop stop-color="#020202" stop-opacity=".05" offset=".21555"/>
<stop stop-color="#020202" stop-opacity=".02" offset=".36962"/>
<stop stop-color="#fff" stop-opacity=".01" offset=".37768"/>
<stop stop-color="#fff" stop-opacity=".01" offset=".62413"/>
<stop stop-color="#020202" stop-opacity=".02" offset=".6313"/>
<stop stop-color="#020202" stop-opacity=".05" offset=".77601"/>
<stop stop-color="#020202" stop-opacity=".08" offset=".7898"/>
<stop stop-color="#020202" stop-opacity=".1" offset=".9"/>
<stop stop-color="#020202" stop-opacity=".18" offset=".90846"/>
<stop stop-color="#020202" stop-opacity=".2" offset="1"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

4
application/src/main/data/json/system/widget_bundles/scada_water_system_symbols.json

@ -34,6 +34,9 @@
"left_flow_meter", "left_flow_meter",
"horizontal_inline_flow_meter", "horizontal_inline_flow_meter",
"vertical_inline_flow_meter", "vertical_inline_flow_meter",
"left_analog_water_level_meter",
"right_analog_water_level_meter",
"leak_sensor",
"centrifugal_pump", "centrifugal_pump",
"small_right_motor_pump", "small_right_motor_pump",
"small_left_motor_pump", "small_left_motor_pump",
@ -50,6 +53,7 @@
"vertical_wheel_valve", "vertical_wheel_valve",
"horizontal_ball_valve", "horizontal_ball_valve",
"vertical_ball_valve", "vertical_ball_valve",
"water_stop",
"vertical_tank", "vertical_tank",
"stand_vertical_tank", "stand_vertical_tank",
"cylindrical_tank", "cylindrical_tank",

2
application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java

@ -75,7 +75,7 @@ public class OAuth2Controller extends BaseController {
@ApiOperation(value = "Get OAuth2 clients (getOAuth2Clients)", notes = "Get the list of OAuth2 clients " + @ApiOperation(value = "Get OAuth2 clients (getOAuth2Clients)", notes = "Get the list of OAuth2 clients " +
"to log in with, available for such domain scheme (HTTP or HTTPS) (if x-forwarded-proto request header is present - " + "to log in with, available for such domain scheme (HTTP or HTTPS) (if x-forwarded-proto request header is present - " +
"the scheme is known from it) and domain name and port (port may be known from x-forwarded-port header)") "the scheme is known from it) and domain name and port (port may be known from x-forwarded-port header)")
@PostMapping(value = "/noauth/oauth2/client") @PostMapping(value = "/noauth/oauth2Clients")
public List<OAuth2ClientLoginInfo> getOAuth2Clients(HttpServletRequest request, public List<OAuth2ClientLoginInfo> getOAuth2Clients(HttpServletRequest request,
@Parameter(description = "Mobile application package name, to find OAuth2 clients " + @Parameter(description = "Mobile application package name, to find OAuth2 clients " +
"where there is configured mobile application with such package name") "where there is configured mobile application with such package name")

63
application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java

@ -157,7 +157,8 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService<Dev
private final DbTypeInfoComponent dbTypeInfoComponent; private final DbTypeInfoComponent dbTypeInfoComponent;
private final TbApiUsageReportClient apiUsageReportClient; private final TbApiUsageReportClient apiUsageReportClient;
private final NotificationRuleProcessor notificationRuleProcessor; private final NotificationRuleProcessor notificationRuleProcessor;
@Autowired @Lazy @Autowired
@Lazy
private TelemetrySubscriptionService tsSubService; private TelemetrySubscriptionService tsSubService;
@Value("${state.defaultInactivityTimeoutInSec}") @Value("${state.defaultInactivityTimeoutInSec}")
@ -362,14 +363,16 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService<Dev
if (proto.getAdded()) { if (proto.getAdded()) {
Futures.addCallback(fetchDeviceState(device), new FutureCallback<>() { Futures.addCallback(fetchDeviceState(device), new FutureCallback<>() {
@Override @Override
public void onSuccess(@Nullable DeviceStateData state) { public void onSuccess(DeviceStateData state) {
TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, device.getId()); TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, device.getId());
if (addDeviceUsingState(tpi, state)) { Set<DeviceId> deviceIds = partitionedEntities.get(tpi);
save(deviceId, ACTIVITY_STATE, false); boolean isMyPartition = deviceIds != null;
if (isMyPartition) {
deviceIds.add(state.getDeviceId());
initializeActivityState(deviceId, state);
callback.onSuccess(); callback.onSuccess();
} else { } else {
log.debug("[{}][{}] Device belongs to external partition. Probably rebalancing is in progress. Topic: {}" log.debug("[{}][{}] Device belongs to external partition. Probably rebalancing is in progress. Topic: {}", tenantId, deviceId, tpi.getFullTopicName());
, tenantId, deviceId, tpi.getFullTopicName());
callback.onFailure(new RuntimeException("Device belongs to external partition " + tpi.getFullTopicName() + "!")); callback.onFailure(new RuntimeException("Device belongs to external partition " + tpi.getFullTopicName() + "!"));
} }
} }
@ -400,6 +403,21 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService<Dev
} }
} }
private void onDeviceDeleted(TenantId tenantId, DeviceId deviceId) {
cleanupEntity(deviceId);
TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, deviceId);
Set<DeviceId> deviceIdSet = partitionedEntities.get(tpi);
if (deviceIdSet != null) {
deviceIdSet.remove(deviceId);
}
}
private void initializeActivityState(DeviceId deviceId, DeviceStateData fetchedState) {
DeviceStateData cachedState = deviceStates.putIfAbsent(fetchedState.getDeviceId(), fetchedState);
boolean activityState = Objects.requireNonNullElse(cachedState, fetchedState).getState().isActive();
save(deviceId, ACTIVITY_STATE, activityState);
}
@Override @Override
protected Map<TopicPartitionInfo, List<ListenableFuture<?>>> onAddedPartitions(Set<TopicPartitionInfo> addedPartitions) { protected Map<TopicPartitionInfo, List<ListenableFuture<?>>> onAddedPartitions(Set<TopicPartitionInfo> addedPartitions) {
var result = new HashMap<TopicPartitionInfo, List<ListenableFuture<?>>>(); var result = new HashMap<TopicPartitionInfo, List<ListenableFuture<?>>>();
@ -436,10 +454,16 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService<Dev
} }
if (devicePackFutureHolder.future == null || !devicePackFutureHolder.future.isCancelled()) { if (devicePackFutureHolder.future == null || !devicePackFutureHolder.future.isCancelled()) {
for (var state : states) { for (var state : states) {
if (!addDeviceUsingState(entry.getKey(), state)) { TopicPartitionInfo tpi = entry.getKey();
return; Set<DeviceId> deviceIds = partitionedEntities.get(tpi);
boolean isMyPartition = deviceIds != null;
if (isMyPartition) {
deviceIds.add(state.getDeviceId());
deviceStates.putIfAbsent(state.getDeviceId(), state);
checkAndUpdateState(state.getDeviceId(), state);
} else {
log.debug("[{}] Device belongs to external partition {}", state.getDeviceId(), tpi.getFullTopicName());
} }
checkAndUpdateState(state.getDeviceId(), state);
} }
log.info("[{}] Initialized {} out of {} device states", entry.getKey().getPartition().orElse(0), counter.addAndGet(states.size()), entry.getValue().size()); log.info("[{}] Initialized {} out of {} device states", entry.getKey().getPartition().orElse(0), counter.addAndGet(states.size()), entry.getValue().size());
} }
@ -475,18 +499,6 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService<Dev
} }
} }
private boolean addDeviceUsingState(TopicPartitionInfo tpi, DeviceStateData state) {
Set<DeviceId> deviceIds = partitionedEntities.get(tpi);
if (deviceIds != null) {
deviceIds.add(state.getDeviceId());
deviceStates.putIfAbsent(state.getDeviceId(), state);
return true;
} else {
log.debug("[{}] Device belongs to external partition {}", state.getDeviceId(), tpi.getFullTopicName());
return false;
}
}
void checkStates() { void checkStates() {
try { try {
final long ts = getCurrentTimeMillis(); final long ts = getCurrentTimeMillis();
@ -619,15 +631,6 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService<Dev
return cleanup; return cleanup;
} }
private void onDeviceDeleted(TenantId tenantId, DeviceId deviceId) {
cleanupEntity(deviceId);
TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, deviceId);
Set<DeviceId> deviceIdSet = partitionedEntities.get(tpi);
if (deviceIdSet != null) {
deviceIdSet.remove(deviceId);
}
}
@Override @Override
protected void cleanupEntityOnPartitionRemoval(DeviceId deviceId) { protected void cleanupEntityOnPartitionRemoval(DeviceId deviceId) {
cleanupEntity(deviceId); cleanupEntity(deviceId);

108
application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java

@ -15,6 +15,7 @@
*/ */
package org.thingsboard.server.service.state; package org.thingsboard.server.service.state;
import com.google.common.util.concurrent.Futures;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@ -28,8 +29,10 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceIdInfo; import org.thingsboard.server.common.data.DeviceIdInfo;
import org.thingsboard.server.common.data.id.DeviceId; 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.id.TenantId;
import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.notification.rule.trigger.DeviceActivityTrigger; import org.thingsboard.server.common.data.notification.rule.trigger.DeviceActivityTrigger;
@ -41,11 +44,13 @@ import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor;
import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.device.DeviceService;
import org.thingsboard.server.dao.sql.query.EntityQueryRepository; import org.thingsboard.server.dao.sql.query.EntityQueryRepository;
import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.discovery.PartitionService;
import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.queue.discovery.QueueKey;
import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent;
@ -66,6 +71,7 @@ import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.given;
@ -1070,4 +1076,106 @@ public class DefaultDeviceStateServiceTest {
then(service).should().fetchDeviceStateDataUsingSeparateRequests(deviceId); then(service).should().fetchDeviceStateDataUsingSeparateRequests(deviceId);
} }
@Test
public void givenDeviceAdded_whenOnQueueMsg_thenShouldCacheAndSaveActivityToFalse() throws InterruptedException {
// GIVEN
final long defaultTimeout = 1000;
initStateService(defaultTimeout);
given(deviceService.findDeviceById(any(TenantId.class), any(DeviceId.class))).willReturn(new Device(deviceId));
given(attributesService.find(any(TenantId.class), any(EntityId.class), any(AttributeScope.class), anyList())).willReturn(Futures.immediateFuture(Collections.emptyList()));
TransportProtos.DeviceStateServiceMsgProto proto = TransportProtos.DeviceStateServiceMsgProto.newBuilder()
.setTenantIdMSB(tenantId.getId().getMostSignificantBits())
.setTenantIdLSB(tenantId.getId().getLeastSignificantBits())
.setDeviceIdMSB(deviceId.getId().getMostSignificantBits())
.setDeviceIdLSB(deviceId.getId().getLeastSignificantBits())
.setAdded(true)
.setUpdated(false)
.setDeleted(false)
.build();
// WHEN
service.onQueueMsg(proto, TbCallback.EMPTY);
// THEN
await().atMost(1, TimeUnit.SECONDS).untilAsserted(() -> {
assertThat(service.deviceStates.get(deviceId).getState().isActive()).isEqualTo(false);
then(telemetrySubscriptionService).should().saveAttrAndNotify(eq(TenantId.SYS_TENANT_ID), eq(deviceId), eq(AttributeScope.SERVER_SCOPE), eq(ACTIVITY_STATE), eq(false), any());
});
}
@Test
public void givenDeviceActivityEventHappenedAfterAdded_whenOnDeviceActivity_thenShouldCacheAndSaveActivityToTrue() throws InterruptedException {
// GIVEN
final long defaultTimeout = 1000;
initStateService(defaultTimeout);
long currentTime = System.currentTimeMillis();
DeviceState deviceState = DeviceState.builder()
.active(false)
.inactivityTimeout(service.getDefaultInactivityTimeoutInSec())
.build();
DeviceStateData stateData = DeviceStateData.builder()
.tenantId(tenantId)
.deviceId(deviceId)
.deviceCreationTime(currentTime - 10000)
.state(deviceState)
.metaData(TbMsgMetaData.EMPTY)
.build();
service.deviceStates.put(deviceId, stateData);
// WHEN
service.onDeviceActivity(tenantId, deviceId, currentTime);
// THEN
await().atMost(1, TimeUnit.SECONDS).untilAsserted(() -> {
assertThat(service.deviceStates.get(deviceId).getState().isActive()).isEqualTo(true);
then(telemetrySubscriptionService).should().saveAttrAndNotify(eq(TenantId.SYS_TENANT_ID), eq(deviceId), eq(AttributeScope.SERVER_SCOPE), eq(LAST_ACTIVITY_TIME), eq(currentTime), any());
then(telemetrySubscriptionService).should().saveAttrAndNotify(eq(TenantId.SYS_TENANT_ID), eq(deviceId), eq(AttributeScope.SERVER_SCOPE), eq(ACTIVITY_STATE), eq(true), any());
});
}
@Test
public void givenDeviceActivityEventHappenedBeforeAdded_whenOnQueueMsg_thenShouldSaveActivityStateUsingValueFromCache() throws InterruptedException {
// GIVEN
final long defaultTimeout = 1000;
initStateService(defaultTimeout);
given(deviceService.findDeviceById(any(TenantId.class), any(DeviceId.class))).willReturn(new Device(deviceId));
given(attributesService.find(any(TenantId.class), any(EntityId.class), any(AttributeScope.class), anyList())).willReturn(Futures.immediateFuture(Collections.emptyList()));
long currentTime = System.currentTimeMillis();
DeviceState deviceState = DeviceState.builder()
.active(true)
.lastConnectTime(currentTime - 8000)
.lastActivityTime(currentTime - 4000)
.lastDisconnectTime(0)
.lastInactivityAlarmTime(0)
.inactivityTimeout(3000)
.build();
DeviceStateData stateData = DeviceStateData.builder()
.tenantId(tenantId)
.deviceId(deviceId)
.deviceCreationTime(currentTime - 10000)
.state(deviceState)
.build();
service.deviceStates.put(deviceId, stateData);
// WHEN
TransportProtos.DeviceStateServiceMsgProto proto = TransportProtos.DeviceStateServiceMsgProto.newBuilder()
.setTenantIdMSB(tenantId.getId().getMostSignificantBits())
.setTenantIdLSB(tenantId.getId().getLeastSignificantBits())
.setDeviceIdMSB(deviceId.getId().getMostSignificantBits())
.setDeviceIdLSB(deviceId.getId().getLeastSignificantBits())
.setAdded(true)
.setUpdated(false)
.setDeleted(false)
.build();
service.onQueueMsg(proto, TbCallback.EMPTY);
// THEN
await().atMost(1, TimeUnit.SECONDS).untilAsserted(() -> {
assertThat(service.deviceStates.get(deviceId).getState().isActive()).isEqualTo(true);
then(telemetrySubscriptionService).should().saveAttrAndNotify(eq(TenantId.SYS_TENANT_ID), eq(deviceId), eq(AttributeScope.SERVER_SCOPE), eq(ACTIVITY_STATE), eq(true), any());
});
}
} }

37
common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java

@ -273,13 +273,13 @@ public class TbUtils {
Long.class, boolean.class, boolean.class))); Long.class, boolean.class, boolean.class)));
parserConfig.addImport("longToHex", new MethodStub(TbUtils.class.getMethod("longToHex", parserConfig.addImport("longToHex", new MethodStub(TbUtils.class.getMethod("longToHex",
Long.class, boolean.class, boolean.class, int.class))); Long.class, boolean.class, boolean.class, int.class)));
parserConfig.addImport("intLongToString", new MethodStub(TbUtils.class.getMethod("intLongToString", parserConfig.addImport("intLongToRadixString", new MethodStub(TbUtils.class.getMethod("intLongToRadixString",
Long.class))); Long.class)));
parserConfig.addImport("intLongToString", new MethodStub(TbUtils.class.getMethod("intLongToString", parserConfig.addImport("intLongToRadixString", new MethodStub(TbUtils.class.getMethod("intLongToRadixString",
Long.class, int.class))); Long.class, int.class)));
parserConfig.addImport("intLongToString", new MethodStub(TbUtils.class.getMethod("intLongToString", parserConfig.addImport("intLongToRadixString", new MethodStub(TbUtils.class.getMethod("intLongToRadixString",
Long.class, int.class, boolean.class))); Long.class, int.class, boolean.class)));
parserConfig.addImport("intLongToString", new MethodStub(TbUtils.class.getMethod("intLongToString", parserConfig.addImport("intLongToRadixString", new MethodStub(TbUtils.class.getMethod("intLongToRadixString",
Long.class, int.class, boolean.class, boolean.class))); Long.class, int.class, boolean.class, boolean.class)));
parserConfig.addImport("floatToHex", new MethodStub(TbUtils.class.getMethod("floatToHex", parserConfig.addImport("floatToHex", new MethodStub(TbUtils.class.getMethod("floatToHex",
Float.class))); Float.class)));
@ -315,8 +315,6 @@ public class TbUtils {
String.class))); String.class)));
parserConfig.addImport("decodeURI", new MethodStub(TbUtils.class.getMethod("decodeURI", parserConfig.addImport("decodeURI", new MethodStub(TbUtils.class.getMethod("decodeURI",
String.class))); String.class)));
parserConfig.addImport("raiseError", new MethodStub(TbUtils.class.getMethod("raiseError",
String.class, Object.class)));
parserConfig.addImport("raiseError", new MethodStub(TbUtils.class.getMethod("raiseError", parserConfig.addImport("raiseError", new MethodStub(TbUtils.class.getMethod("raiseError",
String.class))); String.class)));
parserConfig.addImport("isBinary", new MethodStub(TbUtils.class.getMethod("isBinary", parserConfig.addImport("isBinary", new MethodStub(TbUtils.class.getMethod("isBinary",
@ -327,7 +325,7 @@ public class TbUtils {
String.class))); String.class)));
parserConfig.addImport("isHexadecimal", new MethodStub(TbUtils.class.getMethod("isHexadecimal", parserConfig.addImport("isHexadecimal", new MethodStub(TbUtils.class.getMethod("isHexadecimal",
String.class))); String.class)));
parserConfig.addImport("byteArrayToExecutionArrayList", new MethodStub(TbUtils.class.getMethod("bytesToExecutionArrayList", parserConfig.addImport("bytesToExecutionArrayList", new MethodStub(TbUtils.class.getMethod("bytesToExecutionArrayList",
ExecutionContext.class, byte[].class))); ExecutionContext.class, byte[].class)));
parserConfig.addImport("padStart", new MethodStub(TbUtils.class.getMethod("padStart", parserConfig.addImport("padStart", new MethodStub(TbUtils.class.getMethod("padStart",
String.class, int.class, char.class))); String.class, int.class, char.class)));
@ -725,19 +723,19 @@ public class TbUtils {
return prepareNumberHexString(l, bigEndian, pref, len, HEX_LEN_LONG_MAX); return prepareNumberHexString(l, bigEndian, pref, len, HEX_LEN_LONG_MAX);
} }
public static String intLongToString(Long number) { public static String intLongToRadixString(Long number) {
return intLongToString(number, DEC_RADIX); return intLongToRadixString(number, DEC_RADIX);
} }
public static String intLongToString(Long number, int radix) { public static String intLongToRadixString(Long number, int radix) {
return intLongToString(number, radix, true); return intLongToRadixString(number, radix, true);
} }
public static String intLongToString(Long number, int radix, boolean bigEndian) { public static String intLongToRadixString(Long number, int radix, boolean bigEndian) {
return intLongToString(number, radix, bigEndian, false); return intLongToRadixString(number, radix, bigEndian, false);
} }
public static String intLongToString(Long number, int radix, boolean bigEndian, boolean pref) { public static String intLongToRadixString(Long number, int radix, boolean bigEndian, boolean pref) {
if (radix >= 25 && radix <= MAX_RADIX) { if (radix >= 25 && radix <= MAX_RADIX) {
return Long.toString(number, radix); return Long.toString(number, radix);
} }
@ -1150,12 +1148,7 @@ public class TbUtils {
} }
public static void raiseError(String message) { public static void raiseError(String message) {
raiseError(message, null); throw new RuntimeException(message);
}
public static void raiseError(String message, Object value) {
String msg = value == null ? message : message + " for value " + value;
throw new RuntimeException(msg);
} }
private static void parseRecursive(Object json, Map<String, Object> map, List<String> excludeList, String path, boolean pathInKey) { private static void parseRecursive(Object json, Map<String, Object> map, List<String> excludeList, String path, boolean pathInKey) {
@ -1344,8 +1337,8 @@ public class TbUtils {
* Writes the bit value to the appropriate location in the bins array, starting at the end of the array, * Writes the bit value to the appropriate location in the bins array, starting at the end of the array,
* to ensure proper alignment (highest bit to low end). * to ensure proper alignment (highest bit to low end).
*/ */
public static byte[] parseLongToBinaryArray(long longValue, int binsLength) { public static byte[] parseLongToBinaryArray(long longValue, int binLength) {
int len = Math.min(binsLength, BYTES_LEN_LONG_MAX * BIN_LEN_MAX); int len = Math.min(binLength, BYTES_LEN_LONG_MAX * BIN_LEN_MAX);
byte[] bins = new byte[len]; byte[] bins = new byte[len];
for (int i = 0; i < len; i++) { for (int i = 0; i < len; i++) {
bins[len - 1 - i] = (byte) ((longValue >> i) & 1); bins[len - 1 - i] = (byte) ((longValue >> i) & 1);

94
common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java

@ -19,6 +19,7 @@ import com.google.common.collect.Lists;
import com.google.common.primitives.Bytes; import com.google.common.primitives.Bytes;
import com.google.common.primitives.Ints; import com.google.common.primitives.Ints;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.units.qual.A;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@ -330,8 +331,8 @@ public class TbUtilsTest {
@Test @Test
public void parseBytesIntToFloat() { public void parseBytesIntToFloat() {
byte[] intValByte = {0x00, 0x00, 0x00, 0x0A}; byte[] intValByte = {0x00, 0x00, 0x00, 0x0A};
Float valueExpected = 10.0f; float valueExpected = 10.0f;
Float valueActual = TbUtils.parseBytesIntToFloat(intValByte, 3, 1, true); float valueActual = TbUtils.parseBytesIntToFloat(intValByte, 3, 1, true);
Assertions.assertEquals(valueExpected, valueActual); Assertions.assertEquals(valueExpected, valueActual);
valueActual = TbUtils.parseBytesIntToFloat(intValByte, 3, 1, false); valueActual = TbUtils.parseBytesIntToFloat(intValByte, 3, 1, false);
Assertions.assertEquals(valueExpected, valueActual); Assertions.assertEquals(valueExpected, valueActual);
@ -345,20 +346,23 @@ public class TbUtilsTest {
valueExpected = 10.0f; valueExpected = 10.0f;
valueActual = TbUtils.parseBytesIntToFloat(intValByte, 0, 4, true); valueActual = TbUtils.parseBytesIntToFloat(intValByte, 0, 4, true);
Assertions.assertEquals(valueExpected, valueActual); Assertions.assertEquals(valueExpected, valueActual);
valueExpected = 1.6777216E8f; valueExpected = 167772.16f;
valueActual = TbUtils.parseBytesIntToFloat(intValByte, 0, 4, false); double factor = 1e3;
valueActual = (float) (TbUtils.parseBytesIntToFloat(intValByte, 0, 4, false) / factor);
Assertions.assertEquals(valueExpected, valueActual); Assertions.assertEquals(valueExpected, valueActual);
String dataAT101 = "0x01756403671B01048836BF7701F000090722050000"; String dataAT101 = "0x01756403671B01048836BF7701F000090722050000";
List<Byte> byteAT101 = TbUtils.hexToBytes(ctx, dataAT101); List<Byte> byteAT101 = TbUtils.hexToBytes(ctx, dataAT101);
float latitudeExpected = 24.62495f; float latitudeExpected = 24.62495f;
int offset = 9; int offset = 9;
valueActual = TbUtils.parseBytesIntToFloat(byteAT101, offset, 4, false); factor = 1e6;
Assertions.assertEquals(latitudeExpected, valueActual / 1000000); valueActual = (float) (TbUtils.parseBytesIntToFloat(byteAT101, offset, 4, false) / factor);
Assertions.assertEquals(latitudeExpected, valueActual);
float longitudeExpected = 118.030576f; float longitudeExpected = 118.030576f;
valueActual = TbUtils.parseBytesIntToFloat(byteAT101, offset + 4, 4, false); valueActual = (float) (TbUtils.parseBytesIntToFloat(byteAT101, offset + 4, 4, false) / factor);
Assertions.assertEquals(longitudeExpected, valueActual / 1000000); Assertions.assertEquals(longitudeExpected, valueActual);
valueExpected = 9.185175E8f; valueExpected = 9.185175E8f;
valueActual = TbUtils.parseBytesIntToFloat(byteAT101, offset); valueActual = TbUtils.parseBytesIntToFloat(byteAT101, offset);
@ -520,6 +524,18 @@ public class TbUtilsTest {
valueExpected = 7.2057594037927936E17d; valueExpected = 7.2057594037927936E17d;
valueActual = TbUtils.parseBytesLongToDouble(longValByte, 0, 8, false); valueActual = TbUtils.parseBytesLongToDouble(longValByte, 0, 8, false);
Assertions.assertEquals(valueExpected, valueActual); Assertions.assertEquals(valueExpected, valueActual);
String dataPalacKiyv = "0x32D009423F23B300B0106E08D96B6C00";
List<Byte> byteAT101 = TbUtils.hexToBytes(ctx, dataPalacKiyv);
double latitudeExpected = 50.422775429058610d;
int offset = 0;
double factor = 1e15;
valueActual = TbUtils.parseBytesLongToDouble(byteAT101, offset, 8, false);
Assertions.assertEquals(latitudeExpected, valueActual / factor);
double longitudeExpected = 30.517877378257072d;
valueActual = TbUtils.parseBytesLongToDouble(byteAT101, offset + 8, 8, false);
Assertions.assertEquals(longitudeExpected, valueActual / factor);
} }
@Test @Test
@ -720,27 +736,27 @@ public class TbUtilsTest {
@Test @Test
public void numberToString_Test() { public void numberToString_Test() {
Assertions.assertEquals("00111010", TbUtils.intLongToString(58L, MIN_RADIX)); Assertions.assertEquals("00111010", TbUtils.intLongToRadixString(58L, MIN_RADIX));
Assertions.assertEquals("0000000010011110", TbUtils.intLongToString(158L, MIN_RADIX)); Assertions.assertEquals("0000000010011110", TbUtils.intLongToRadixString(158L, MIN_RADIX));
Assertions.assertEquals("00000000000000100000001000000001", TbUtils.intLongToString(131585L, MIN_RADIX)); Assertions.assertEquals("00000000000000100000001000000001", TbUtils.intLongToRadixString(131585L, MIN_RADIX));
Assertions.assertEquals("0111111111111111111111111111111111111111111111111111111111111111", TbUtils.intLongToString(Long.MAX_VALUE, MIN_RADIX)); Assertions.assertEquals("0111111111111111111111111111111111111111111111111111111111111111", TbUtils.intLongToRadixString(Long.MAX_VALUE, MIN_RADIX));
Assertions.assertEquals("1000000000000000000000000000000000000000000000000000000000000001", TbUtils.intLongToString(-Long.MAX_VALUE, MIN_RADIX)); Assertions.assertEquals("1000000000000000000000000000000000000000000000000000000000000001", TbUtils.intLongToRadixString(-Long.MAX_VALUE, MIN_RADIX));
Assertions.assertEquals("1111111111111111111111111111111111111111111111111111111110011010", TbUtils.intLongToString(-102L, MIN_RADIX)); Assertions.assertEquals("1111111111111111111111111111111111111111111111111111111110011010", TbUtils.intLongToRadixString(-102L, MIN_RADIX));
Assertions.assertEquals("1111111111111111111111111111111111111111111111111100110010011010", TbUtils.intLongToString(-13158L, MIN_RADIX)); Assertions.assertEquals("1111111111111111111111111111111111111111111111111100110010011010", TbUtils.intLongToRadixString(-13158L, MIN_RADIX));
Assertions.assertEquals("777777777777777777777", TbUtils.intLongToString(Long.MAX_VALUE, 8)); Assertions.assertEquals("777777777777777777777", TbUtils.intLongToRadixString(Long.MAX_VALUE, 8));
Assertions.assertEquals("1000000000000000000000", TbUtils.intLongToString(Long.MIN_VALUE, 8)); Assertions.assertEquals("1000000000000000000000", TbUtils.intLongToRadixString(Long.MIN_VALUE, 8));
Assertions.assertEquals("9223372036854775807", TbUtils.intLongToString(Long.MAX_VALUE)); Assertions.assertEquals("9223372036854775807", TbUtils.intLongToRadixString(Long.MAX_VALUE));
Assertions.assertEquals("-9223372036854775808", TbUtils.intLongToString(Long.MIN_VALUE)); Assertions.assertEquals("-9223372036854775808", TbUtils.intLongToRadixString(Long.MIN_VALUE));
Assertions.assertEquals("3366", TbUtils.intLongToString(13158L, 16)); Assertions.assertEquals("3366", TbUtils.intLongToRadixString(13158L, 16));
Assertions.assertEquals("FFCC9A", TbUtils.intLongToString(-13158L, 16)); Assertions.assertEquals("FFCC9A", TbUtils.intLongToRadixString(-13158L, 16));
Assertions.assertEquals("0xFFCC9A", TbUtils.intLongToString(-13158L, 16, true, true)); Assertions.assertEquals("0xFFCC9A", TbUtils.intLongToRadixString(-13158L, 16, true, true));
Assertions.assertEquals("0x0400", TbUtils.intLongToString(1024L, 16, true, true)); Assertions.assertEquals("0x0400", TbUtils.intLongToRadixString(1024L, 16, true, true));
Assertions.assertNotEquals("400", TbUtils.intLongToString(1024L, 16)); Assertions.assertNotEquals("400", TbUtils.intLongToRadixString(1024L, 16));
Assertions.assertEquals("0xFFFC00", TbUtils.intLongToString(-1024L, 16, true, true)); Assertions.assertEquals("0xFFFC00", TbUtils.intLongToRadixString(-1024L, 16, true, true));
Assertions.assertNotEquals("0xFC00", TbUtils.intLongToString(-1024L, 16, true, true)); Assertions.assertNotEquals("0xFC00", TbUtils.intLongToRadixString(-1024L, 16, true, true));
Assertions.assertEquals("hazelnut", TbUtils.intLongToString(1356099454469L, MAX_RADIX)); Assertions.assertEquals("hazelnut", TbUtils.intLongToRadixString(1356099454469L, MAX_RADIX));
} }
@Test @Test
@ -870,14 +886,16 @@ public class TbUtilsTest {
@Test @Test
public void raiseError_Test() { public void raiseError_Test() {
String message = "frequency_weighting_type must be 0, 1 or 2.";
Object value = 4; Object value = 4;
String message = "frequency_weighting_type must be 0, 1 or 2. A value of " + value.toString() + " is invalid.";
try { try {
TbUtils.raiseError(message, value); TbUtils.raiseError(message);
Assertions.fail("Should throw NumberFormatException"); Assertions.fail("Should throw NumberFormatException");
} catch (RuntimeException e) { } catch (RuntimeException e) {
Assertions.assertTrue(e.getMessage().contains("frequency_weighting_type must be 0, 1 or 2. for value 4")); Assertions.assertTrue(e.getMessage().contains("frequency_weighting_type must be 0, 1 or 2. A value of 4 is invalid."));
} }
message = "frequency_weighting_type must be 0, 1 or 2.";
try { try {
TbUtils.raiseError(message); TbUtils.raiseError(message);
Assertions.fail("Should throw NumberFormatException"); Assertions.fail("Should throw NumberFormatException");
@ -1067,6 +1085,18 @@ public class TbUtilsTest {
String actual = TbUtils.hexToBase64(hex); String actual = TbUtils.hexToBase64(hex);
Assertions.assertEquals(expected, actual); Assertions.assertEquals(expected, actual);
} }
@Test
public void bytesToHex_Test() {
byte[] bb = {(byte) 0xBB, (byte) 0xAA};
String expected = "BBAA";
String actual = TbUtils.bytesToHex(bb);
Assertions.assertEquals(expected, actual);
ExecutionArrayList<Integer> expectedList = new ExecutionArrayList<>(ctx);
expectedList.addAll(List.of(-69, 83));
expected = "BB53";
actual = TbUtils.bytesToHex(expectedList);
Assertions.assertEquals(expected, actual);
}
private static List<Byte> toList(byte[] data) { private static List<Byte> toList(byte[] data) {
List<Byte> result = new ArrayList<>(data.length); List<Byte> result = new ArrayList<>(data.length);

2
pom.xml

@ -83,7 +83,7 @@
<zookeeper.version>3.9.2</zookeeper.version> <zookeeper.version>3.9.2</zookeeper.version>
<protobuf.version>3.25.3</protobuf.version> <!-- A Major v4 does not support by the pubsub yet--> <protobuf.version>3.25.3</protobuf.version> <!-- A Major v4 does not support by the pubsub yet-->
<grpc.version>1.63.0</grpc.version> <grpc.version>1.63.0</grpc.version>
<tbel.version>1.2.2</tbel.version> <tbel.version>1.2.3</tbel.version>
<lombok.version>1.18.32</lombok.version> <lombok.version>1.18.32</lombok.version>
<paho.client.version>1.2.5</paho.client.version> <paho.client.version>1.2.5</paho.client.version>
<paho.mqttv5.client.version>1.2.5</paho.mqttv5.client.version> <paho.mqttv5.client.version>1.2.5</paho.mqttv5.client.version>

10
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNode.java

@ -35,10 +35,13 @@ import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.dao.exception.DataValidationException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields;
@Slf4j @Slf4j
@RuleNode( @RuleNode(
type = ComponentType.EXTERNAL, type = ComponentType.EXTERNAL,
@ -62,10 +65,9 @@ public class TbAwsLambdaNode extends TbAbstractExternalNode {
@Override @Override
public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException {
config = TbNodeUtils.convert(configuration, TbAwsLambdaNodeConfiguration.class); config = TbNodeUtils.convert(configuration, TbAwsLambdaNodeConfiguration.class);
if (StringUtils.isBlank(config.getFunctionName())) { String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: ";
throw new TbNodeException("Function name must be set!", true);
}
try { try {
validateFields(config, errorPrefix);
AWSCredentials awsCredentials = new BasicAWSCredentials(config.getAccessKey(), config.getSecretKey()); AWSCredentials awsCredentials = new BasicAWSCredentials(config.getAccessKey(), config.getSecretKey());
client = AWSLambdaAsyncClientBuilder.standard() client = AWSLambdaAsyncClientBuilder.standard()
.withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) .withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
@ -74,6 +76,8 @@ public class TbAwsLambdaNode extends TbAbstractExternalNode {
.withConnectionTimeout((int) TimeUnit.SECONDS.toMillis(config.getConnectionTimeout())) .withConnectionTimeout((int) TimeUnit.SECONDS.toMillis(config.getConnectionTimeout()))
.withRequestTimeout((int) TimeUnit.SECONDS.toMillis(config.getRequestTimeout()))) .withRequestTimeout((int) TimeUnit.SECONDS.toMillis(config.getRequestTimeout())))
.build(); .build();
} catch (DataValidationException e) {
throw new TbNodeException(e, true);
} catch (Exception e) { } catch (Exception e) {
throw new TbNodeException(e); throw new TbNodeException(e);
} }

8
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNodeConfiguration.java

@ -15,6 +15,8 @@
*/ */
package org.thingsboard.rule.engine.aws.lambda; package org.thingsboard.rule.engine.aws.lambda;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.Data; import lombok.Data;
import org.thingsboard.rule.engine.api.NodeConfiguration; import org.thingsboard.rule.engine.api.NodeConfiguration;
@ -23,12 +25,18 @@ public class TbAwsLambdaNodeConfiguration implements NodeConfiguration<TbAwsLamb
public static final String DEFAULT_QUALIFIER = "$LATEST"; public static final String DEFAULT_QUALIFIER = "$LATEST";
@NotBlank
private String accessKey; private String accessKey;
@NotBlank
private String secretKey; private String secretKey;
@NotBlank
private String region; private String region;
@NotBlank
private String functionName; private String functionName;
private String qualifier; private String qualifier;
@Min(0)
private int connectionTimeout; private int connectionTimeout;
@Min(0)
private int requestTimeout; private int requestTimeout;
private boolean tellFailureIfFuncThrowsExc; private boolean tellFailureIfFuncThrowsExc;

16
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java

@ -77,6 +77,8 @@ public class TbMqttNode extends TbAbstractExternalNode {
this.mqttNodeConfiguration = TbNodeUtils.convert(configuration, TbMqttNodeConfiguration.class); this.mqttNodeConfiguration = TbNodeUtils.convert(configuration, TbMqttNodeConfiguration.class);
try { try {
this.mqttClient = initClient(ctx); this.mqttClient = initClient(ctx);
} catch (TbNodeException e) {
throw e;
} catch (Exception e) { } catch (Exception e) {
throw new TbNodeException(e); throw new TbNodeException(e);
} }
@ -119,8 +121,7 @@ public class TbMqttNode extends TbAbstractExternalNode {
MqttClientConfig config = new MqttClientConfig(getSslContext()); MqttClientConfig config = new MqttClientConfig(getSslContext());
config.setOwnerId(getOwnerId(ctx)); config.setOwnerId(getOwnerId(ctx));
if (!StringUtils.isEmpty(this.mqttNodeConfiguration.getClientId())) { if (!StringUtils.isEmpty(this.mqttNodeConfiguration.getClientId())) {
config.setClientId(this.mqttNodeConfiguration.isAppendClientIdSuffix() ? config.setClientId(getClientId(ctx));
this.mqttNodeConfiguration.getClientId() + "_" + ctx.getServiceId() : this.mqttNodeConfiguration.getClientId());
} }
config.setCleanSession(this.mqttNodeConfiguration.isCleanSession()); config.setCleanSession(this.mqttNodeConfiguration.isCleanSession());
@ -146,6 +147,17 @@ public class TbMqttNode extends TbAbstractExternalNode {
return client; return client;
} }
private String getClientId(TbContext ctx) throws TbNodeException {
String clientId = this.mqttNodeConfiguration.isAppendClientIdSuffix() ?
this.mqttNodeConfiguration.getClientId() + "_" + ctx.getServiceId() :
this.mqttNodeConfiguration.getClientId();
if (clientId.length() > 23) {
throw new TbNodeException("Client ID is too long '" + clientId + "'. " +
"The length of Client ID cannot be longer than 23, but current length is " + clientId.length() + ".", true);
}
return clientId;
}
MqttClient getMqttClient(TbContext ctx, MqttClientConfig config) { MqttClient getMqttClient(TbContext ctx, MqttClientConfig config) {
return MqttClient.create(config, null, ctx.getExternalCallExecutor()); return MqttClient.create(config, null, ctx.getExternalCallExecutor());
} }

41
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java

@ -80,6 +80,8 @@ public class TbHttpClient {
public static final String PROXY_USER = "tb.proxy.user"; public static final String PROXY_USER = "tb.proxy.user";
public static final String PROXY_PASSWORD = "tb.proxy.password"; public static final String PROXY_PASSWORD = "tb.proxy.password";
public static final String MAX_IN_MEMORY_BUFFER_SIZE_IN_KB = "tb.http.maxInMemoryBufferSizeInKb";
private final TbRestApiCallNodeConfiguration config; private final TbRestApiCallNodeConfiguration config;
private EventLoopGroup eventLoopGroup; private EventLoopGroup eventLoopGroup;
@ -129,15 +131,34 @@ public class TbHttpClient {
httpClient = httpClient.secure(t -> t.sslContext(sslContext)); httpClient = httpClient.secure(t -> t.sslContext(sslContext));
} }
validateMaxInMemoryBufferSize(config);
this.webClient = WebClient.builder() this.webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient)) .clientConnector(new ReactorClientHttpConnector(httpClient))
.defaultHeader(HttpHeaders.CONNECTION, "close") //In previous realization this header was present! (Added for hotfix "Connection reset") .defaultHeader(HttpHeaders.CONNECTION, "close") //In previous realization this header was present! (Added for hotfix "Connection reset")
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(
(config.getMaxInMemoryBufferSizeInKb() > 0 ? config.getMaxInMemoryBufferSizeInKb() : 256) * 1024))
.build(); .build();
} catch (SSLException e) { } catch (SSLException e) {
throw new TbNodeException(e); throw new TbNodeException(e);
} }
} }
private void validateMaxInMemoryBufferSize(TbRestApiCallNodeConfiguration config) throws TbNodeException {
int systemMaxInMemoryBufferSizeInKb = 25000;
try {
Properties properties = System.getProperties();
if (properties.containsKey(MAX_IN_MEMORY_BUFFER_SIZE_IN_KB)) {
systemMaxInMemoryBufferSizeInKb = Integer.parseInt(properties.getProperty(MAX_IN_MEMORY_BUFFER_SIZE_IN_KB));
}
} catch (Exception ignored) {}
if (config.getMaxInMemoryBufferSizeInKb() > systemMaxInMemoryBufferSizeInKb) {
throw new TbNodeException("The configured maximum in-memory buffer size (in KB) exceeds the system limit for this parameter.\n" +
"The system limit is " + systemMaxInMemoryBufferSizeInKb + " KB.\n" +
"Please use the system variable '" + MAX_IN_MEMORY_BUFFER_SIZE_IN_KB + "' to override the system limit.");
}
}
EventLoopGroup getSharedOrCreateEventLoopGroup(EventLoopGroup eventLoopGroupShared) { EventLoopGroup getSharedOrCreateEventLoopGroup(EventLoopGroup eventLoopGroupShared) {
if (eventLoopGroupShared != null) { if (eventLoopGroupShared != null) {
return eventLoopGroupShared; return eventLoopGroupShared;
@ -207,13 +228,23 @@ public class TbHttpClient {
semaphore.release(); semaphore.release();
} }
onFailure.accept(processException(msg, throwable), throwable); onFailure.accept(processException(msg, throwable), processThrowable(throwable));
}); });
} catch (InterruptedException e) { } catch (InterruptedException e) {
log.warn("Timeout during waiting for reply!", e); log.warn("Timeout during waiting for reply!", e);
} }
} }
private Throwable processThrowable(Throwable origin) {
if (origin instanceof WebClientResponseException restClientResponseException
&& restClientResponseException.getStatusCode().is2xxSuccessful()) {
// return cause instead of original exception in case 2xx status code
// this will provide meaningful error message to the user
return new RuntimeException(restClientResponseException.getCause());
}
return origin;
}
public URI buildEncodedUri(String endpointUrl) { public URI buildEncodedUri(String endpointUrl) {
if (endpointUrl == null) { if (endpointUrl == null) {
throw new RuntimeException("Url string cannot be null!"); throw new RuntimeException("Url string cannot be null!");
@ -336,8 +367,8 @@ public class TbHttpClient {
String hostname = properties.getProperty(hostProperty); String hostname = properties.getProperty(hostProperty);
int port = Integer.parseInt(properties.getProperty(portProperty)); int port = Integer.parseInt(properties.getProperty(portProperty));
checkProxyHost(config.getProxyHost()); checkProxyHost(hostname);
checkProxyPort(config.getProxyPort()); checkProxyPort(port);
var proxy = option var proxy = option
.type(ProxyProvider.Proxy.HTTP) .type(ProxyProvider.Proxy.HTTP)
@ -362,8 +393,8 @@ public class TbHttpClient {
ProxyProvider.Proxy type = SOCKS_VERSION_5.equals(version) ? ProxyProvider.Proxy.SOCKS5 : ProxyProvider.Proxy.SOCKS4; ProxyProvider.Proxy type = SOCKS_VERSION_5.equals(version) ? ProxyProvider.Proxy.SOCKS5 : ProxyProvider.Proxy.SOCKS4;
int port = Integer.parseInt(properties.getProperty(SOCKS_PROXY_PORT)); int port = Integer.parseInt(properties.getProperty(SOCKS_PROXY_PORT));
checkProxyHost(config.getProxyHost()); checkProxyHost(hostname);
checkProxyPort(config.getProxyPort()); checkProxyPort(port);
ProxyProvider.Builder proxy = option ProxyProvider.Builder proxy = option
.type(type) .type(type)

2
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java

@ -46,6 +46,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestA
private String proxyScheme; private String proxyScheme;
private ClientCredentials credentials; private ClientCredentials credentials;
private boolean ignoreRequestBody; private boolean ignoreRequestBody;
private int maxInMemoryBufferSizeInKb;
@Override @Override
public TbRestApiCallNodeConfiguration defaultConfiguration() { public TbRestApiCallNodeConfiguration defaultConfiguration() {
@ -60,6 +61,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestA
configuration.setEnableProxy(false); configuration.setEnableProxy(false);
configuration.setCredentials(new AnonymousCredentials()); configuration.setCredentials(new AnonymousCredentials());
configuration.setIgnoreRequestBody(false); configuration.setIgnoreRequestBody(false);
configuration.setMaxInMemoryBufferSizeInKb(256);
return configuration; return configuration;
} }

65
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNodeTest.java

@ -41,6 +41,7 @@ import org.thingsboard.rule.engine.api.util.TbNodeUtils;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgMetaData;
@ -50,8 +51,8 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Stream; import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@ -72,13 +73,17 @@ public class TbAwsLambdaNodeTest {
private AWSLambdaAsync clientMock; private AWSLambdaAsync clientMock;
@BeforeEach @BeforeEach
void setUp() { public void setUp() {
node = new TbAwsLambdaNode(); node = new TbAwsLambdaNode();
config = new TbAwsLambdaNodeConfiguration().defaultConfiguration(); config = new TbAwsLambdaNodeConfiguration().defaultConfiguration();
config.setAccessKey("accessKey");
config.setSecretKey("secretKey");
config.setFunctionName("new-function");
} }
@Test @Test
public void verifyDefaultConfig() { public void verifyDefaultConfig() {
config = new TbAwsLambdaNodeConfiguration().defaultConfiguration();
assertThat(config.getAccessKey()).isNull(); assertThat(config.getAccessKey()).isNull();
assertThat(config.getSecretKey()).isNull(); assertThat(config.getSecretKey()).isNull();
assertThat(config.getRegion()).isEqualTo(("us-east-1")); assertThat(config.getRegion()).isEqualTo(("us-east-1"));
@ -94,10 +99,43 @@ public class TbAwsLambdaNodeTest {
@ValueSource(strings = " ") @ValueSource(strings = " ")
public void givenInvalidFunctionName_whenInit_thenThrowsException(String funcName) { public void givenInvalidFunctionName_whenInit_thenThrowsException(String funcName) {
config.setFunctionName(funcName); config.setFunctionName(funcName);
var configuration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); verifyValidationExceptionOnInit();
assertThatThrownBy(() -> node.init(ctx, configuration)) }
.isInstanceOf(TbNodeException.class)
.hasMessage("Function name must be set!"); @ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = " ")
public void givenInvalidAccessKey_whenInit_thenThrowsException(String accessKey) {
config.setAccessKey(accessKey);
verifyValidationExceptionOnInit();
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = " ")
public void givenInvalidSecretAccessKey_whenInit_thenThrowsException(String secretAccessKey) {
config.setSecretKey(secretAccessKey);
verifyValidationExceptionOnInit();
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = " ")
public void givenInvalidRegion_whenInit_thenThrowsException(String region) {
config.setRegion(region);
verifyValidationExceptionOnInit();
}
@Test
public void givenInvalidConnectionTimeout_whenInit_thenThrowsException() {
config.setConnectionTimeout(-100);
verifyValidationExceptionOnInit();
}
@Test
public void givenInvalidRequestTimeout_whenInit_thenThrowsException() {
config.setRequestTimeout(-100);
verifyValidationExceptionOnInit();
} }
@ParameterizedTest @ParameterizedTest
@ -280,10 +318,19 @@ public class TbAwsLambdaNodeTest {
assertThat(throwableCaptor.getValue()).isInstanceOf(AWSLambdaException.class).hasMessageStartingWith(errorMsg); assertThat(throwableCaptor.getValue()).isInstanceOf(AWSLambdaException.class).hasMessageStartingWith(errorMsg);
} }
private void verifyValidationExceptionOnInit() {
RuleNode ruleNode = new RuleNode();
ruleNode.setName("test");
when(ctx.getSelf()).thenReturn(ruleNode);
String errorPrefix = "'test' node configuration is invalid: ";
assertThatThrownBy(() -> node.init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
.isInstanceOf(TbNodeException.class)
.hasMessageContaining(errorPrefix)
.extracting(e -> ((TbNodeException) e).isUnrecoverable())
.isEqualTo(true);
}
private void init() { private void init() {
config.setAccessKey("accessKey");
config.setSecretKey("secretKey");
config.setFunctionName("new-function");
ReflectionTestUtils.setField(node, "client", clientMock); ReflectionTestUtils.setField(node, "client", clientMock);
ReflectionTestUtils.setField(node, "config", config); ReflectionTestUtils.setField(node, "config", config);
} }

36
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java

@ -191,6 +191,42 @@ public class TbMqttNodeTest extends AbstractRuleNodeUpgradeTest {
assertThatNoException().isThrownBy(() -> mqttNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(mqttNodeConfig)))); assertThatNoException().isThrownBy(() -> mqttNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(mqttNodeConfig))));
} }
@Test
public void givenClientIdIsTooLong_whenInit_thenThrowsException() {
String invalidClientId = "vhfrbeb38ygwfwrgfwefgterhytjytj";
mqttNodeConfig.setClientId(invalidClientId);
given(ctxMock.getTenantId()).willReturn(TENANT_ID);
given(ctxMock.getSelf()).willReturn(new RuleNode(RULE_NODE_ID));
assertThatThrownBy(() -> mqttNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(mqttNodeConfig))))
.isInstanceOf(TbNodeException.class)
.hasMessage("Client ID is too long '" + invalidClientId + "'. " +
"The length of Client ID cannot be longer than 23, but current length is " + invalidClientId.length() + ".")
.extracting(e -> ((TbNodeException) e).isUnrecoverable())
.isEqualTo(true);
}
@Test
public void givenClientIdIsOkAndAppendClientIdSuffixIsTrue_whenInit_thenClientIdBecomesInvalidAndThrowsException() {
String validClientId = "fertjnhnjj4ge";
mqttNodeConfig.setClientId("fertjnhnjj4ge");
mqttNodeConfig.setAppendClientIdSuffix(true);
given(ctxMock.getTenantId()).willReturn(TENANT_ID);
given(ctxMock.getSelf()).willReturn(new RuleNode(RULE_NODE_ID));
String serviceId = "test-service";
given(ctxMock.getServiceId()).willReturn(serviceId);
String resultedClientId = validClientId + "_" + serviceId;
assertThatThrownBy(() -> mqttNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(mqttNodeConfig))))
.isInstanceOf(TbNodeException.class)
.hasMessage("Client ID is too long '" + resultedClientId + "'. " +
"The length of Client ID cannot be longer than 23, but current length is " + resultedClientId.length() + ".")
.extracting(e -> ((TbNodeException) e).isUnrecoverable())
.isEqualTo(true);
}
@Test @Test
public void givenFailedByTimeoutConnectResult_whenInit_thenThrowsException() throws ExecutionException, InterruptedException, TimeoutException { public void givenFailedByTimeoutConnectResult_whenInit_thenThrowsException() throws ExecutionException, InterruptedException, TimeoutException {
mqttNodeConfig.setHost("localhost"); mqttNodeConfig.setHost("localhost");

5
ui-ngx/angular.json

@ -39,6 +39,11 @@
"input": "./node_modules/ace-builds/src-noconflict/", "input": "./node_modules/ace-builds/src-noconflict/",
"output": "/" "output": "/"
}, },
{
"glob": "worker-xml.js",
"input": "./node_modules/ace-builds/src-noconflict/",
"output": "/"
},
{ {
"glob": "worker-css.js", "glob": "worker-css.js",
"input": "./node_modules/ace-builds/src-noconflict/", "input": "./node_modules/ace-builds/src-noconflict/",

2
ui-ngx/src/app/core/auth/auth.service.ts

@ -224,7 +224,7 @@ export class AuthService {
} }
public loadOAuth2Clients(): Observable<Array<OAuth2ClientLoginInfo>> { public loadOAuth2Clients(): Observable<Array<OAuth2ClientLoginInfo>> {
const url = '/api/noauth/oauth2/client?platform=' + PlatformType.WEB; const url = '/api/noauth/oauth2Clients?platform=' + PlatformType.WEB;
return this.http.post<Array<OAuth2ClientLoginInfo>>(url, return this.http.post<Array<OAuth2ClientLoginInfo>>(url,
null, defaultHttpOptions()).pipe( null, defaultHttpOptions()).pipe(
catchError(err => of([])), catchError(err => of([])),

8
ui-ngx/src/app/core/services/dashboard-utils.service.ts

@ -345,10 +345,12 @@ export class DashboardUtilsService {
return widgetConfig; return widgetConfig;
} }
public prepareWidgetForScadaLayout(widget: Widget): Widget { public prepareWidgetForScadaLayout(widget: Widget, isScada: boolean): Widget {
const config = widget.config; const config = widget.config;
config.showTitle = false; config.showTitle = false;
config.dropShadow = false; config.dropShadow = false;
config.resizable = !isScada;
config.preserveAspectRatio = isScada;
config.padding = '0'; config.padding = '0';
config.margin = '0'; config.margin = '0';
config.backgroundColor = 'rgba(0,0,0,0)'; config.backgroundColor = 'rgba(0,0,0,0)';
@ -654,7 +656,9 @@ export class DashboardUtilsService {
mobileOrder: widget.config.mobileOrder, mobileOrder: widget.config.mobileOrder,
mobileHeight: widget.config.mobileHeight, mobileHeight: widget.config.mobileHeight,
mobileHide: widget.config.mobileHide, mobileHide: widget.config.mobileHide,
desktopHide: widget.config.desktopHide desktopHide: widget.config.desktopHide,
preserveAspectRatio: widget.config.preserveAspectRatio,
resizable: widget.config.resizable
}; };
if (isUndefined(originalColumns)) { if (isUndefined(originalColumns)) {
originalColumns = 24; originalColumns = 24;

11
ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts

@ -130,7 +130,14 @@ export class AddWidgetDialogComponent extends DialogComponent<AddWidgetDialogCom
this.widgetConfig = { this.widgetConfig = {
widgetName: widgetInfo.widgetName, widgetName: widgetInfo.widgetName,
config: this.widget.config, config: this.widget.config,
layout: {}, layout: {
resizable: this.widget.config.resizable,
preserveAspectRatio: this.widget.config.preserveAspectRatio,
mobileHide: this.widget.config.mobileHide,
desktopHide: this.widget.config.desktopHide,
mobileOrder: this.widget.config.mobileOrder,
mobileHeight: this.widget.config.mobileHeight
},
widgetType: this.widget.type, widgetType: this.widget.type,
typeParameters, typeParameters,
actionSources, actionSources,
@ -187,6 +194,8 @@ export class AddWidgetDialogComponent extends DialogComponent<AddWidgetDialogCom
this.widget.config.mobileHeight = widgetConfig.layout.mobileHeight; this.widget.config.mobileHeight = widgetConfig.layout.mobileHeight;
this.widget.config.mobileHide = widgetConfig.layout.mobileHide; this.widget.config.mobileHide = widgetConfig.layout.mobileHide;
this.widget.config.desktopHide = widgetConfig.layout.desktopHide; this.widget.config.desktopHide = widgetConfig.layout.desktopHide;
this.widget.config.preserveAspectRatio = widgetConfig.layout.preserveAspectRatio;
this.widget.config.resizable = widgetConfig.layout.resizable;
this.dialogRef.close(this.widget); this.dialogRef.close(this.widget);
} }
} }

2
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts

@ -1351,7 +1351,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
} }
const scada = this.isAddingToScadaLayout(); const scada = this.isAddingToScadaLayout();
if (scada) { if (scada) {
newWidget = this.dashboardUtils.prepareWidgetForScadaLayout(newWidget); newWidget = this.dashboardUtils.prepareWidgetForScadaLayout(newWidget, widgetTypeInfo.scada);
} }
let showLayoutConfig = true; let showLayoutConfig = true;
if (scada || this.layouts.right.show || !this.showLayoutConfigInEdit(this.layouts.main.layoutCtx)) { if (scada || this.layouts.right.show || !this.showLayoutConfigInEdit(this.layouts.main.layoutCtx)) {

23
ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts

@ -289,13 +289,24 @@ const updateScadaSymbolMetadataInDom = (svgDoc: Document, metadata: ScadaSymbolM
metadataElement.appendChild(cdata); metadataElement.appendChild(cdata);
}; };
const tbMetadataRegex = /<tb:metadata>.*<\/tb:metadata>/gs; const tbMetadataRegex = /<tb:metadata[^>]*>.*<\/tb:metadata>/gs;
export interface ScadaSymbolContentData { export interface ScadaSymbolContentData {
svgRootNode: string; svgRootNode: string;
innerSvg: string; innerSvg: string;
} }
export const removeScadaSymbolMetadata = (svgContent: string): string => {
let result = svgContent;
tbMetadataRegex.lastIndex = 0;
const metadataMatch = tbMetadataRegex.exec(svgContent);
if (metadataMatch !== null && metadataMatch.length) {
const metadata = metadataMatch[0];
result = result.replace(metadata, '');
}
return result;
};
export const scadaSymbolContentData = (svgContent: string): ScadaSymbolContentData => { export const scadaSymbolContentData = (svgContent: string): ScadaSymbolContentData => {
const result: ScadaSymbolContentData = { const result: ScadaSymbolContentData = {
svgRootNode: '', svgRootNode: '',
@ -517,10 +528,12 @@ export class ScadaSymbolObject {
if (this.context) { if (this.context) {
for (const tag of this.metadata.tags) { for (const tag of this.metadata.tags) {
const elements = this.context.tags[tag.tag]; const elements = this.context.tags[tag.tag];
elements.forEach(element => { if (elements) {
element.timeline().stop(); elements.forEach(element => {
element.timeline(null); element.timeline().stop();
}); element.timeline(null);
});
}
} }
} }
if (this.svgShape) { if (this.svgShape) {

36
ui-ngx/src/app/modules/home/components/widget/widget-config.component.html

@ -168,18 +168,32 @@
</tb-manage-widget-actions> </tb-manage-widget-actions>
</div> </div>
</div> </div>
<div [fxShow]="selectedOption === 'mobile'" [formGroup]="layoutSettings" class="mat-content"> <div [fxShow]="selectedOption === 'layout'" [formGroup]="layoutSettings" class="mat-content">
<div class="tb-form-panel" *ngIf="isDefaultBreakpoint"> <div class="tb-form-panel" *ngIf="!showLayoutConfig || isDefaultBreakpoint">
<mat-slide-toggle class="mat-slide" formControlName="mobileHide"> <div class="tb-form-panel-title" translate>widget-config.resize-options</div>
{{ 'widget-config.mobile-hide' | translate }} <div class="tb-form-row">
</mat-slide-toggle> <mat-slide-toggle class="mat-slide" formControlName="resizable">
</div> {{ 'widget-config.resizable' | translate }}
<div class="tb-form-panel" *ngIf="isDefaultBreakpoint"> </mat-slide-toggle>
<mat-slide-toggle class="mat-slide" formControlName="desktopHide"> </div>
{{ 'widget-config.desktop-hide' | translate }} <div class="tb-form-row">
</mat-slide-toggle> <mat-slide-toggle class="mat-slide" formControlName="preserveAspectRatio">
{{ 'widget-config.preserve-aspect-ratio' | translate }}
</mat-slide-toggle>
</div>
</div> </div>
<div class="tb-form-panel"> <div class="tb-form-panel" *ngIf="showLayoutConfig">
<div class="tb-form-panel-title">{{ (isDefaultBreakpoint ? 'widget-config.mobile' : 'widget-config.list-layout') | translate }}</div>
<div class="tb-form-row" *ngIf="isDefaultBreakpoint">
<mat-slide-toggle class="mat-slide" formControlName="mobileHide">
{{ 'widget-config.mobile-hide' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row" *ngIf="isDefaultBreakpoint">
<mat-slide-toggle class="mat-slide" formControlName="desktopHide">
{{ 'widget-config.desktop-hide' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row space-between"> <div class="tb-form-row space-between">
<div translate>widget-config.order</div> <div translate>widget-config.order</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic"> <mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">

45
ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts

@ -63,7 +63,7 @@ import { UtilsService } from '@core/services/utils.service';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { EntityType } from '@shared/models/entity-type.models'; import { EntityType } from '@shared/models/entity-type.models';
import { Observable, of, Subject, Subscription } from 'rxjs'; import { merge, Observable, of, Subject, Subscription } from 'rxjs';
import { import {
IBasicWidgetConfigComponent, IBasicWidgetConfigComponent,
WidgetConfigCallbacks WidgetConfigCallbacks
@ -248,19 +248,24 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe
noDataDisplayMessage: [null, []] noDataDisplayMessage: [null, []]
}); });
this.widgetSettings.get('showTitle').valueChanges.subscribe(() => { merge(this.widgetSettings.get('showTitle').valueChanges,
this.updateWidgetSettingsEnabledState(); this.widgetSettings.get('showTitleIcon').valueChanges).subscribe(() => {
});
this.widgetSettings.get('showTitleIcon').valueChanges.subscribe(() => {
this.updateWidgetSettingsEnabledState(); this.updateWidgetSettingsEnabledState();
}); });
this.layoutSettings = this.fb.group({ this.layoutSettings = this.fb.group({
resizable: [true],
preserveAspectRatio: [false],
mobileOrder: [null, [Validators.pattern(/^-?[0-9]+$/)]], mobileOrder: [null, [Validators.pattern(/^-?[0-9]+$/)]],
mobileHeight: [null, [Validators.min(1), Validators.pattern(/^\d*$/)]], mobileHeight: [null, [Validators.min(1), Validators.pattern(/^\d*$/)]],
mobileHide: [false], mobileHide: [false],
desktopHide: [false] desktopHide: [false]
}); });
this.layoutSettings.get('resizable').valueChanges.subscribe(() => {
this.updateLayoutEnabledState();
});
this.actionsSettings = this.fb.group({ this.actionsSettings = this.fb.group({
actions: [null, []] actions: [null, []]
}); });
@ -349,16 +354,12 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe
value: 'actions' value: 'actions'
} }
); );
if (this.showLayoutConfig) { this.headerOptions.push(
this.headerOptions.push( {
{ name: this.translate.instant('widget-config.layout'),
name: this.isDefaultBreakpoint value: 'layout'
? this.translate.instant('widget-config.mobile') }
: this.translate.instant('widget-config.list-layout'), );
value: 'mobile'
}
);
}
if (!this.selectedOption || !this.headerOptions.find(o => o.value === this.selectedOption)) { if (!this.selectedOption || !this.headerOptions.find(o => o.value === this.selectedOption)) {
this.selectedOption = this.headerOptions[0].value; this.selectedOption = this.headerOptions[0].value;
} }
@ -569,6 +570,8 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe
if (layout) { if (layout) {
this.layoutSettings.patchValue( this.layoutSettings.patchValue(
{ {
resizable: isDefined(layout.resizable) ? layout.resizable : true,
preserveAspectRatio: layout.preserveAspectRatio,
mobileOrder: layout.mobileOrder, mobileOrder: layout.mobileOrder,
mobileHeight: layout.mobileHeight, mobileHeight: layout.mobileHeight,
mobileHide: layout.mobileHide, mobileHide: layout.mobileHide,
@ -579,6 +582,8 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe
} else { } else {
this.layoutSettings.patchValue( this.layoutSettings.patchValue(
{ {
resizable: true,
preserveAspectRatio: false,
mobileOrder: null, mobileOrder: null,
mobileHeight: null, mobileHeight: null,
mobileHide: false, mobileHide: false,
@ -587,6 +592,7 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe
{emitEvent: false} {emitEvent: false}
); );
} }
this.updateLayoutEnabledState();
} }
this.createChangeSubscriptions(); this.createChangeSubscriptions();
} }
@ -620,6 +626,15 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe
} }
} }
private updateLayoutEnabledState() {
const resizable: boolean = this.layoutSettings.get('resizable').value;
if (resizable) {
this.layoutSettings.get('preserveAspectRatio').enable({emitEvent: false});
} else {
this.layoutSettings.get('preserveAspectRatio').disable({emitEvent: false});
}
}
private updateSchemaForm(settings?: any) { private updateSchemaForm(settings?: any) {
const widgetSettingsFormData: JsonFormComponentData = {}; const widgetSettingsFormData: JsonFormComponentData = {};
if (this.modelValue.settingsSchema && this.modelValue.settingsSchema.schema) { if (this.modelValue.settingsSchema && this.modelValue.settingsSchema.schema) {

176
ui-ngx/src/app/modules/home/models/dashboard-component.models.ts

@ -343,6 +343,10 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget {
private selectedValue = false; private selectedValue = false;
private selectedCallback: (selected: boolean) => void = () => {}; private selectedCallback: (selected: boolean) => void = () => {};
resizableHandles = {} as any;
resizeEnabled = true;
isFullscreen = false; isFullscreen = false;
isReference = false; isReference = false;
@ -387,6 +391,15 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget {
private gridsterItemComponentSubject = new Subject<GridsterItemComponentInterface>(); private gridsterItemComponentSubject = new Subject<GridsterItemComponentInterface>();
private gridsterItemComponentValue: GridsterItemComponentInterface; private gridsterItemComponentValue: GridsterItemComponentInterface;
private readonly aspectRatio: number;
private heightValue: number;
private widthValue: number;
private rowsValue: number;
private colsValue: number;
get mobileHide(): boolean { get mobileHide(): boolean {
return this.widgetLayout ? this.widgetLayout.mobileHide === true : false; return this.widgetLayout ? this.widgetLayout.mobileHide === true : false;
} }
@ -397,10 +410,96 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget {
set gridsterItemComponent(item: GridsterItemComponentInterface) { set gridsterItemComponent(item: GridsterItemComponentInterface) {
this.gridsterItemComponentValue = item; this.gridsterItemComponentValue = item;
if (this.widgetLayout?.preserveAspectRatio) {
this.applyPreserveAspectRatio(item);
}
this.gridsterItemComponentSubject.next(this.gridsterItemComponentValue); this.gridsterItemComponentSubject.next(this.gridsterItemComponentValue);
this.gridsterItemComponentSubject.complete(); this.gridsterItemComponentSubject.complete();
} }
private applyPreserveAspectRatio(item: GridsterItemComponentInterface) {
this.resizableHandles.ne = false;
this.resizableHandles.sw = false;
this.resizableHandles.nw = false;
const $item = item.$item;
this.rowsValue = $item.rows;
this.colsValue = $item.cols;
Object.defineProperty($item, 'rows', {
get: () => this.rowsValue,
set: v => {
if (this.rowsValue !== v) {
if (this.preserveAspectRatio) {
this.colsValue = v * this.aspectRatio;
}
this.rowsValue = v;
}
}
});
Object.defineProperty($item, 'cols', {
get: () => this.colsValue,
set: v => {
if (this.colsValue !== v) {
if (this.preserveAspectRatio) {
this.rowsValue = v / this.aspectRatio;
}
this.colsValue = v;
}
}
});
const resizable = item.resize;
this.heightValue = resizable.height;
this.widthValue = resizable.width;
const setItemHeight = resizable.setItemHeight.bind(resizable);
const setItemWidth = resizable.setItemWidth.bind(resizable);
resizable.setItemHeight = (height) => {
setItemHeight(height);
this.heightValue = height;
if (this.preserveAspectRatio) {
setItemWidth(height * this.aspectRatio);
}
};
resizable.setItemWidth = (width) => {
setItemWidth(width);
this.widthValue = width;
if (this.preserveAspectRatio) {
setItemHeight(width / this.aspectRatio);
}
};
Object.defineProperty(resizable, 'height', {
get: () => this.heightValue,
set: v => {
if (this.heightValue !== v) {
if (this.preserveAspectRatio) {
this.widthValue = v * this.aspectRatio;
}
this.heightValue = v;
}
}
});
Object.defineProperty(resizable, 'width', {
get: () => this.widthValue,
set: v => {
if (this.widthValue !== v) {
if (this.preserveAspectRatio) {
this.heightValue = v / this.aspectRatio;
}
this.widthValue = v;
}
}
});
}
get highlighted() { get highlighted() {
return this.highlightedValue; return this.highlightedValue;
} }
@ -434,6 +533,13 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget {
public widgetLayout?: WidgetLayout, public widgetLayout?: WidgetLayout,
private parentDashboard?: IDashboardComponent, private parentDashboard?: IDashboardComponent,
private popoverComponent?: TbPopoverComponent) { private popoverComponent?: TbPopoverComponent) {
if (isDefined(widgetLayout?.resizable)) {
this.resizeEnabled = widgetLayout.resizable;
}
if (widgetLayout?.preserveAspectRatio) {
this.aspectRatio = this.widgetLayout.sizeX / this.widgetLayout.sizeY;
}
if (!widget.id) { if (!widget.id) {
widget.id = guid(); widget.id = guid();
} }
@ -603,30 +709,28 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget {
} }
} }
@enumerable(true) get preserveAspectRatio(): boolean {
get cols(): number { if (!this.dashboard.isMobileSize && this.widgetLayout) {
let res; return this.widgetLayout.preserveAspectRatio;
if (this.widgetLayout) {
res = this.widgetLayout.sizeX;
} else { } else {
res = this.widget.sizeX; return false;
} }
return Math.floor(res); }
@enumerable(true)
get cols(): number {
return Math.floor(this.sizeX);
} }
set cols(cols: number) { set cols(cols: number) {
if (!this.dashboard.isMobileSize) { if (!this.dashboard.isMobileSize) {
if (this.widgetLayout) { this.sizeX = cols;
this.widgetLayout.sizeX = cols;
} else {
this.widget.sizeX = cols;
}
} }
} }
@enumerable(true) @enumerable(true)
get rows(): number { get rows(): number {
let res; let res: number;
if (this.dashboard.isMobileSize) { if (this.dashboard.isMobileSize) {
let mobileHeight; let mobileHeight;
if (this.widgetLayout) { if (this.widgetLayout) {
@ -638,26 +742,50 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget {
if (mobileHeight) { if (mobileHeight) {
res = mobileHeight; res = mobileHeight;
} else { } else {
const sizeY = this.widgetLayout ? this.widgetLayout.sizeY : this.widget.sizeY; const sizeY = this.sizeY;
res = sizeY * 24 / this.dashboard.gridsterOpts.minCols; res = sizeY * 24 / this.dashboard.gridsterOpts.minCols;
} }
} else { } else {
if (this.widgetLayout) { res = this.sizeY;
res = this.widgetLayout.sizeY;
} else {
res = this.widget.sizeY;
}
} }
return Math.floor(res); return Math.floor(res);
} }
set rows(rows: number) { set rows(rows: number) {
if (!this.dashboard.isMobileSize && !this.dashboard.autofillHeight) { if (!this.dashboard.isMobileSize && !this.dashboard.autofillHeight) {
if (this.widgetLayout) { this.sizeY = rows;
this.widgetLayout.sizeY = rows; }
} else { }
this.widget.sizeY = rows;
} get sizeX(): number {
if (this.widgetLayout) {
return this.widgetLayout.sizeX;
} else {
return this.widget.sizeX;
}
}
set sizeX(sizeX: number) {
if (this.widgetLayout) {
this.widgetLayout.sizeX = sizeX;
} else {
this.widget.sizeX = sizeX;
}
}
get sizeY(): number {
if (this.widgetLayout) {
return this.widgetLayout.sizeY;
} else {
return this.widget.sizeY;
}
}
set sizeY(sizeY: number) {
if (this.widgetLayout) {
this.widgetLayout.sizeY = sizeY;
} else {
this.widget.sizeY = sizeY;
} }
} }

2
ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.html

@ -35,6 +35,6 @@
</div> </div>
</div> </div>
<ng-template #noTags> <ng-template #noTags>
<span fxLayoutAlign="start center" <span fxLayoutAlign="center center"
class="tb-prompt">{{ 'scada.tag.no-tags' | translate }}</span> class="tb-prompt">{{ 'scada.tag.no-tags' | translate }}</span>
</ng-template> </ng-template>

25
ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html

@ -15,13 +15,19 @@
limitations under the License. limitations under the License.
--> -->
<div class="tb-scada-symbol-editor-shape" #scadaSymbolShape></div> <div class="tb-scada-symbol-editor-shape" [fxShow]="editorMode === 'svg'" #scadaSymbolShape></div>
<div class="tb-scada-symbol-editor-tooltips" #tooltipsContainer> <div class="tb-scada-symbol-editor-tooltips" [fxShow]="editorMode === 'svg'" #tooltipsContainer>
<tb-anchor #tooltipsContainerComponent></tb-anchor> <tb-anchor #tooltipsContainerComponent></tb-anchor>
</div> </div>
<div class="tb-scada-symbol-editor-svg-xml" [fxShow]="editorMode === 'xml'">
<tb-svg-xml [formControl]="svgContentFormControl"
fillHeight
noLabel>
</tb-svg-xml>
</div>
<div class="tb-scada-symbol-editor-buttons"> <div class="tb-scada-symbol-editor-buttons">
<div class="tb-scada-symbol-editor-view-buttons"> <div class="tb-scada-symbol-editor-view-buttons">
<div class="tb-scada-symbol-editor-zoom-buttons tb-primary-fill"> <div class="tb-scada-symbol-editor-zoom-buttons tb-primary-fill" [fxShow]="editorMode === 'svg'">
<button mat-icon-button <button mat-icon-button
[disabled]="zoomInDisabled" [disabled]="zoomInDisabled"
(click)="zoomIn()" (click)="zoomIn()"
@ -37,7 +43,7 @@
<mat-icon>remove</mat-icon> <mat-icon>remove</mat-icon>
</button> </button>
</div> </div>
<div *ngIf="displayShowHidden" class="tb-primary-fill"> <div *ngIf="displayShowHidden" [fxShow]="editorMode === 'svg'" class="tb-primary-fill">
<button mat-icon-button <button mat-icon-button
(click)="toggleShowHidden()" (click)="toggleShowHidden()"
matTooltip="{{ (showHiddenElements ? 'scada.hide-hidden-elements' : 'scada.show-hidden-elements') | translate }}" matTooltip="{{ (showHiddenElements ? 'scada.hide-hidden-elements' : 'scada.show-hidden-elements') | translate }}"
@ -46,7 +52,7 @@
</button> </button>
</div> </div>
</div> </div>
<div class="tb-scada-symbol-editor-upload-buttons"> <div class="tb-scada-symbol-editor-right-buttons">
<div class="tb-primary-fill"> <div class="tb-primary-fill">
<button mat-icon-button <button mat-icon-button
(click)="updateScadaSymbol.emit()" (click)="updateScadaSymbol.emit()"
@ -63,5 +69,14 @@
<mat-icon>download</mat-icon> <mat-icon>download</mat-icon>
</button> </button>
</div> </div>
<tb-toggle-select appearance="fill"
[disabled]="!svgContentFormControl.valid"
fillHeight
extraPadding
primaryBackground
[(ngModel)]="editorMode">
<tb-toggle-option value="svg">{{ 'scada.mode-svg' | translate }}</tb-toggle-option>
<tb-toggle-option value="xml">{{ 'scada.mode-xml' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div> </div>
</div> </div>

10
ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.scss

@ -25,6 +25,11 @@
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
} }
.tb-scada-symbol-editor-svg-xml {
width: 100%;
height: 100%;
padding-top: 88px;
}
.tb-scada-symbol-editor-buttons { .tb-scada-symbol-editor-buttons {
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
@ -64,11 +69,14 @@
} }
} }
.tb-scada-symbol-editor-upload-buttons { .tb-scada-symbol-editor-right-buttons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 8px; gap: 8px;
z-index: 101; z-index: 101;
tb-toggle-select {
pointer-events: auto;
}
} }
} }

75
ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts

@ -34,11 +34,15 @@ import {
ScadaSymbolEditObjectCallbacks ScadaSymbolEditObjectCallbacks
} from '@home/pages/scada-symbol/scada-symbol-editor.models'; } from '@home/pages/scada-symbol/scada-symbol-editor.models';
import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; import { TbAnchorComponent } from '@shared/components/tb-anchor.component';
import { FormControl } from '@angular/forms';
import { removeScadaSymbolMetadata } from '@home/components/widget/lib/scada/scada-symbol.models';
export interface ScadaSymbolEditorData { export interface ScadaSymbolEditorData {
scadaSymbolContent: string; scadaSymbolContent: string;
} }
type editorModeType = 'svg' | 'xml';
@Component({ @Component({
selector: 'tb-scada-symbol-editor', selector: 'tb-scada-symbol-editor',
templateUrl: './scada-symbol-editor.component.html', templateUrl: './scada-symbol-editor.component.html',
@ -84,10 +88,31 @@ export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewI
displayShowHidden = false; displayShowHidden = false;
svgContentFormControl = new FormControl();
svgContent: string;
private editorModeValue: editorModeType = 'svg';
get editorMode(): editorModeType {
return this.editorModeValue;
}
set editorMode(value: editorModeType) {
this.updateEditorMode(value);
}
constructor(private cd: ChangeDetectorRef) { constructor(private cd: ChangeDetectorRef) {
} }
ngOnInit(): void { ngOnInit(): void {
this.svgContentFormControl.valueChanges.subscribe((svgContent) => {
if (this.svgContent !== svgContent) {
this.svgContent = svgContent;
this.editObjectCallbacks.onSymbolEditObjectDirty(true);
}
this.editObjectCallbacks.onSymbolEditObjectValid(this.svgContentFormControl.valid);
});
} }
ngAfterViewInit() { ngAfterViewInit() {
@ -114,13 +139,16 @@ export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewI
const change = changes[propName]; const change = changes[propName];
if (!change.firstChange && change.currentValue !== change.previousValue) { if (!change.firstChange && change.currentValue !== change.previousValue) {
if (propName === 'data') { if (propName === 'data') {
if (this.scadaSymbolEditObject) { setTimeout(() => {
setTimeout(() => { this.updateContent(this.data.scadaSymbolContent);
this.updateContent(this.data.scadaSymbolContent); });
});
}
} else if (propName === 'readonly') { } else if (propName === 'readonly') {
this.scadaSymbolEditObject.setReadOnly(this.readonly); this.scadaSymbolEditObject.setReadOnly(this.readonly);
if (this.readonly) {
this.svgContentFormControl.disable({emitEvent: false});
} else {
this.svgContentFormControl.enable({emitEvent: false});
}
} }
} }
} }
@ -131,7 +159,11 @@ export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewI
} }
getContent(): string { getContent(): string {
return this.scadaSymbolEditObject?.getContent(); if (this.editorMode === 'svg') {
return this.scadaSymbolEditObject?.getContent();
} else {
return this.svgContent;
}
} }
zoomIn() { zoomIn() {
@ -148,12 +180,33 @@ export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewI
this.scadaSymbolEditObject.showHiddenElements(this.showHiddenElements); this.scadaSymbolEditObject.showHiddenElements(this.showHiddenElements);
} }
private updateEditorMode(mode: editorModeType) {
this.editorModeValue = mode;
if (mode === 'xml') {
this.svgContent = this.scadaSymbolEditObject.getContent();
this.svgContentFormControl.setValue(this.svgContent, {emitEvent: false});
} else {
this.updateEditObjectContent(this.svgContent);
}
}
private updateContent(content: string) { private updateContent(content: string) {
this.displayShowHidden = false; this.svgContent = removeScadaSymbolMetadata(content);
this.scadaSymbolEditObject.setContent(content); if (this.editorMode === 'xml') {
setTimeout(() => { this.svgContentFormControl.setValue(this.svgContent, {emitEvent: false});
this.updateZoomButtonsState(); } else {
}); this.updateEditObjectContent(this.svgContent);
}
}
private updateEditObjectContent(content: string) {
if (this.scadaSymbolEditObject) {
this.displayShowHidden = false;
this.scadaSymbolEditObject.setContent(content);
setTimeout(() => {
this.updateZoomButtonsState();
});
}
} }
private updateZoomButtonsState() { private updateZoomButtonsState() {

1
ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts

@ -54,6 +54,7 @@ export interface ScadaSymbolEditObjectCallbacks {
tagsUpdated: (tags: string[]) => void; tagsUpdated: (tags: string[]) => void;
hasHiddenElements?: (hasHidden: boolean) => void; hasHiddenElements?: (hasHidden: boolean) => void;
onSymbolEditObjectDirty: (dirty: boolean) => void; onSymbolEditObjectDirty: (dirty: boolean) => void;
onSymbolEditObjectValid: (valid: boolean) => void;
onZoom?: () => void; onZoom?: () => void;
} }

6
ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html

@ -85,7 +85,7 @@
formControlName="metadata"> formControlName="metadata">
<div class="tb-scada-symbol-metadata-header-prefix"> <div class="tb-scada-symbol-metadata-header-prefix">
<button fxHide.gt-sm <button fxHide.gt-sm
[disabled]="scadaSymbolFormGroup.invalid" [disabled]="scadaSymbolFormGroup.invalid || !symbolEditorValid"
mat-button color="primary" mat-button color="primary"
(click)="enterPreviewMode()"> (click)="enterPreviewMode()">
<mat-icon>visibility</mat-icon> <mat-icon>visibility</mat-icon>
@ -94,7 +94,7 @@
</div> </div>
<div fxFlex.lt-md class="tb-scada-symbol-metadata-header-suffix" fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="12px"> <div fxFlex.lt-md class="tb-scada-symbol-metadata-header-suffix" fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="12px">
<button fxHide.lt-md <button fxHide.lt-md
[disabled]="scadaSymbolFormGroup.invalid" [disabled]="scadaSymbolFormGroup.invalid || !symbolEditorValid"
mat-button color="primary" mat-button color="primary"
(click)="enterPreviewMode()"> (click)="enterPreviewMode()">
<mat-icon>visibility</mat-icon> <mat-icon>visibility</mat-icon>
@ -107,7 +107,7 @@
<mat-icon>close</mat-icon> <mat-icon>close</mat-icon>
{{ 'action.decline' | translate }} {{ 'action.decline' | translate }}
</button> </button>
<button [disabled]="!scadaSymbolFormGroup.valid || !(scadaSymbolFormGroup.dirty || symbolEditorDirty)" <button [disabled]="!scadaSymbolFormGroup.valid || !symbolEditorValid || !(scadaSymbolFormGroup.dirty || symbolEditorDirty)"
mat-flat-button color="primary" mat-flat-button color="primary"
(click)="onApplyScadaSymbolConfig()"> (click)="onApplyScadaSymbolConfig()">
<mat-icon>done</mat-icon> <mat-icon>done</mat-icon>

8
ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts

@ -135,6 +135,8 @@ export class ScadaSymbolComponent extends PageComponent
symbolEditorDirty = false; symbolEditorDirty = false;
symbolEditorValid = true;
private previewScadaSymbolObjectSettings: ScadaSymbolObjectSettings; private previewScadaSymbolObjectSettings: ScadaSymbolObjectSettings;
private forcePristine = false; private forcePristine = false;
@ -338,6 +340,10 @@ export class ScadaSymbolComponent extends PageComponent
this.symbolEditorDirty = dirty; this.symbolEditorDirty = dirty;
} }
onSymbolEditObjectValid(valid: boolean) {
this.symbolEditorValid = valid;
}
updateScadaSymbol() { updateScadaSymbol() {
this.dialog.open<UploadImageDialogComponent, UploadImageDialogData, this.dialog.open<UploadImageDialogComponent, UploadImageDialogData,
UploadImageDialogResult>(UploadImageDialogComponent, { UploadImageDialogResult>(UploadImageDialogComponent, {
@ -354,6 +360,7 @@ export class ScadaSymbolComponent extends PageComponent
scadaSymbolContent: this.symbolData.scadaSymbolContent scadaSymbolContent: this.symbolData.scadaSymbolContent
}; };
this.symbolEditorDirty = true; this.symbolEditorDirty = true;
this.symbolEditorValid = true;
} }
}); });
} }
@ -471,6 +478,7 @@ export class ScadaSymbolComponent extends PageComponent
} }
this.scadaSymbolFormGroup.markAsPristine(); this.scadaSymbolFormGroup.markAsPristine();
this.symbolEditorDirty = false; this.symbolEditorDirty = false;
this.symbolEditorValid = true;
this.cd.markForCheck(); this.cd.markForCheck();
} }
} }

38
ui-ngx/src/app/shared/components/svg-xml.component.html

@ -0,0 +1,38 @@
<!--
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.
-->
<div class="tb-svg-xml" style="background: #fff;" [ngClass]="{'tb-disabled': disabled, 'fill-height': fillHeight, 'no-label': noLabel}"
tb-fullscreen
[fullscreen]="fullscreen" fxLayout="column">
<div fxLayout="row" fxLayoutAlign="start center" style="min-height: 40px;" class="tb-svg-xml-toolbar">
<label *ngIf="!noLabel" class="tb-title no-padding" [ngClass]="{'tb-error': !disabled && (hasErrors || required && !modelValue), 'tb-required': !disabled && required}">{{ label }}</label>
<span fxFlex></span>
<fieldset style="width: initial">
<div matTooltip="{{(fullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"
matTooltipPosition="above"
style="border-radius: 50%"
(click)="fullscreen = !fullscreen">
<button type='button' mat-icon-button class="tb-mat-32">
<mat-icon class="material-icons">{{ fullscreen ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
</button>
</div>
</fieldset>
</div>
<div id="tb-svg-xml-panel" class="tb-svg-xml-content-panel" fxLayout="column">
<div #svgXmlEditor id="tb-svg-xml-input" [ngStyle]="fillHeight ? {} : {minHeight: minHeight}" [ngClass]="{'fill-height': fillHeight}"></div>
</div>
</div>

85
ui-ngx/src/app/shared/components/svg-xml.component.scss

@ -0,0 +1,85 @@
/**
* 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.
*/
.tb-svg-xml {
position: relative;
&.tb-disabled {
color: rgba(0, 0, 0, .38);
}
&.fill-height {
height: 100%;
}
&.no-label {
.tb-svg-xml-content-panel {
height: 100%;
}
.tb-svg-xml-toolbar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
pointer-events: none;
button {
pointer-events: auto;
}
}
}
.tb-svg-xml-content-panel {
height: calc(100% - 40px);
border: 1px solid #c0c0c0;
#tb-svg-xml-input {
width: 100%;
min-width: 200px;
height: 100%;
}
}
&:not(.tb-fullscreen) {
padding-bottom: 15px;
}
.tb-svg-xml-toolbar {
& > * {
&:not(:last-child) {
margin-right: 4px;
}
}
button.mat-mdc-button-base, button.mat-mdc-button-base.tb-mat-32 {
background: rgba(220, 220, 220, .35);
align-items: center;
vertical-align: middle;
min-width: 32px;
min-height: 15px;
padding: 4px;
font-size: .8rem;
line-height: 15px;
&:not(.tb-help-popup-button) {
color: #7b7b7b;
}
}
button.mat-mdc-button-base:not(.mat-mdc-icon-button) {
height: 23px;
}
.tb-help-popup-button-loading {
background: #f3f3f3;
}
}
}

211
ui-ngx/src/app/shared/components/svg-xml.component.ts

@ -0,0 +1,211 @@
///
/// 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,
ElementRef,
forwardRef,
Input,
OnDestroy,
OnInit,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { ControlValueAccessor, UntypedFormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms';
import { Ace } from 'ace-builds';
import { getAce } from '@shared/models/ace/ace.models';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core';
import { CancelAnimationFrame, RafService } from '@core/services/raf.service';
import { ResizeObserver } from '@juggle/resize-observer';
import { coerceBoolean } from '@shared/decorators/coercion';
@Component({
selector: 'tb-svg-xml',
templateUrl: './svg-xml.component.html',
styleUrls: ['./svg-xml.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SvgXmlComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => SvgXmlComponent),
multi: true,
}
],
encapsulation: ViewEncapsulation.None
})
export class SvgXmlComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator {
@ViewChild('svgXmlEditor', {static: true})
svgXmlEditorElmRef: ElementRef;
private svgXmlEditor: Ace.Editor;
private editorsResizeCaf: CancelAnimationFrame;
private editorResize$: ResizeObserver;
private ignoreChange = false;
@Input() label: string;
@Input() disabled: boolean;
@Input()
@coerceBoolean()
fillHeight = false;
@Input()
@coerceBoolean()
noLabel = false;
@Input() minHeight = '200px';
private requiredValue: boolean;
get required(): boolean {
return this.requiredValue;
}
@Input()
set required(value: boolean) {
this.requiredValue = coerceBooleanProperty(value);
}
fullscreen = false;
modelValue: string;
hasErrors = false;
private propagateChange = null;
constructor(public elementRef: ElementRef,
private utils: UtilsService,
private translate: TranslateService,
protected store: Store<AppState>,
private raf: RafService,
private cd: ChangeDetectorRef) {
}
ngOnInit(): void {
const editorElement = this.svgXmlEditorElmRef.nativeElement;
let editorOptions: Partial<Ace.EditorOptions> = {
mode: 'ace/mode/svg',
showGutter: true,
showPrintMargin: true,
readOnly: this.disabled
};
const advancedOptions = {
enableSnippets: true,
enableBasicAutocompletion: true,
enableLiveAutocompletion: true
};
editorOptions = {...editorOptions, ...advancedOptions};
getAce().subscribe(
(ace) => {
this.svgXmlEditor = ace.edit(editorElement, editorOptions);
this.svgXmlEditor.session.setUseWrapMode(true);
this.svgXmlEditor.setValue(this.modelValue ? this.modelValue : '', -1);
this.svgXmlEditor.setReadOnly(this.disabled);
this.svgXmlEditor.on('change', () => {
if (!this.ignoreChange) {
this.updateView();
}
});
// @ts-ignore
this.svgXmlEditor.session.on('changeAnnotation', () => {
const annotations = this.svgXmlEditor.session.getAnnotations();
const hasErrors = annotations.filter(annotation => annotation.type === 'error').length > 0;
if (this.hasErrors !== hasErrors) {
this.hasErrors = hasErrors;
this.propagateChange(this.modelValue);
this.cd.markForCheck();
}
});
this.editorResize$ = new ResizeObserver(() => {
this.onAceEditorResize();
});
this.editorResize$.observe(editorElement);
}
);
}
ngOnDestroy(): void {
if (this.editorResize$) {
this.editorResize$.disconnect();
}
if (this.svgXmlEditor) {
this.svgXmlEditor.destroy();
}
}
private onAceEditorResize() {
if (this.editorsResizeCaf) {
this.editorsResizeCaf();
this.editorsResizeCaf = null;
}
this.editorsResizeCaf = this.raf.raf(() => {
this.svgXmlEditor.resize();
this.svgXmlEditor.renderer.updateFull();
});
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.svgXmlEditor) {
this.svgXmlEditor.setReadOnly(this.disabled);
}
}
public validate(c: UntypedFormControl) {
return (!this.hasErrors) ? null : {
svgXml: {
valid: false,
},
};
}
writeValue(value: string): void {
this.modelValue = value;
if (this.svgXmlEditor) {
this.ignoreChange = true;
this.svgXmlEditor.setValue(this.modelValue ? this.modelValue : '', -1);
this.ignoreChange = false;
}
}
updateView() {
const editorValue = this.svgXmlEditor.getValue();
if (this.modelValue !== editorValue) {
this.modelValue = editorValue;
this.propagateChange(this.modelValue);
this.cd.markForCheck();
}
}
}

31
ui-ngx/src/app/shared/components/toggle-header.component.html

@ -20,16 +20,26 @@
[disabled]="!leftPaginationEnabled" [disabled]="!leftPaginationEnabled"
(click)="handlePaginatorClick('before', $event)" (click)="handlePaginatorClick('before', $event)"
(touchstart)="handlePaginatorTouchStart('before', $event)" (touchstart)="handlePaginatorTouchStart('before', $event)"
class="tb-toggle-header-pagination-button" [class]="{'tb-mat-32': !isMdLg, 'tb-mat-24': isMdLg}"> class="tb-toggle-header-pagination-button"
[class.fill-height]="fillHeight"
[class.tb-mat-32]="!isMdLg"
[class.tb-mat-24]="isMdLg">
<mat-icon>chevron_left</mat-icon> <mat-icon>chevron_left</mat-icon>
</button> </button>
<div #toggleGroupContainer class="tb-toggle-container" [class]="{'tb-disable-pagination': disablePagination}" *ngIf="(useSelect$ | async) === false; else select" > <div #toggleGroupContainer class="tb-toggle-container"
[class.tb-disable-pagination]="disablePagination"
[class.fill-height]="fillHeight"
[class.extra-padding]="extraPadding"
*ngIf="(useSelect$ | async) === false; else select" >
<mat-button-toggle-group #toggleGroup <mat-button-toggle-group #toggleGroup
class="tb-toggle-header" class="tb-toggle-header"
[ngClass]="{'tb-fill': (appearance === 'fill' || appearance === 'fill-invert'), [class.tb-fill]="(appearance === 'fill' || appearance === 'fill-invert')"
'tb-invert': appearance === 'fill-invert', [class.tb-invert]="appearance === 'fill-invert'"
'tb-ignore-md-lg': ignoreMdLgSize, [class.tb-primary-fill]="primaryBackground"
'tb-disabled': disabled }" [name]="name" [(ngModel)]="value" [class.tb-ignore-md-lg]="ignoreMdLgSize"
[class.tb-disabled]="disabled"
[name]="name"
[(ngModel)]="value"
(ngModelChange)="valueChange.emit(value)"> (ngModelChange)="valueChange.emit(value)">
<mat-button-toggle *ngFor="let option of options; trackBy: trackByHeaderOption" [value]="option.value" [disabled]="disabled">{{ option.name }}</mat-button-toggle> <mat-button-toggle *ngFor="let option of options; trackBy: trackByHeaderOption" [value]="option.value" [disabled]="disabled">{{ option.name }}</mat-button-toggle>
</mat-button-toggle-group> </mat-button-toggle-group>
@ -39,11 +49,16 @@
[disabled]="!rightPaginationEnabled" [disabled]="!rightPaginationEnabled"
(click)="handlePaginatorClick('after', $event)" (click)="handlePaginatorClick('after', $event)"
(touchstart)="handlePaginatorTouchStart('after', $event)" (touchstart)="handlePaginatorTouchStart('after', $event)"
class="tb-toggle-header-pagination-button" [class]="{'tb-mat-32': !isMdLg, 'tb-mat-24': isMdLg}"> class="tb-toggle-header-pagination-button"
[class.fill-height]="fillHeight"
[class.tb-mat-32]="!isMdLg"
[class.tb-mat-24]="isMdLg">
<mat-icon>chevron_right</mat-icon> <mat-icon>chevron_right</mat-icon>
</button> </button>
<ng-template #select> <ng-template #select>
<mat-form-field appearance="outline" class="tb-toggle-header-select" subscriptSizing="dynamic"> <mat-form-field appearance="outline" class="tb-toggle-header-select"
[class.fill-height]="fillHeight"
subscriptSizing="dynamic">
<mat-select [(ngModel)]="value" (ngModelChange)="valueChange.emit($event)" [disabled]="disabled"> <mat-select [(ngModel)]="value" (ngModelChange)="valueChange.emit($event)" [disabled]="disabled">
<mat-option *ngFor="let option of options" [value]="option.value"> {{ option.name }}</mat-option> <mat-option *ngFor="let option of options" [value]="option.value"> {{ option.name }}</mat-option>
</mat-select> </mat-select>

38
ui-ngx/src/app/shared/components/toggle-header.component.scss

@ -21,6 +21,9 @@
grid-template-columns: min-content minmax(auto, 1fr) min-content; grid-template-columns: min-content minmax(auto, 1fr) min-content;
.tb-toggle-header-pagination-button { .tb-toggle-header-pagination-button {
display: none; display: none;
&.fill-height {
height: 100%;
}
} }
&.tb-toggle-header-pagination-controls-enabled { &.tb-toggle-header-pagination-controls-enabled {
.tb-toggle-header-pagination-button { .tb-toggle-header-pagination-button {
@ -43,6 +46,25 @@
} }
:host ::ng-deep { :host ::ng-deep {
.tb-toggle-container {
&.fill-height {
.mat-button-toggle-group.mat-button-toggle-group-appearance-standard.tb-toggle-header {
height: 100%;
.mat-button-toggle.mat-button-toggle-appearance-standard {
.mat-button-toggle-button {
height: 100%;
}
}
}
}
&.extra-padding {
.mat-button-toggle.mat-button-toggle-appearance-standard {
.mat-button-toggle-label-content {
padding: 0 20px;
}
}
}
}
.mat-button-toggle-group.mat-button-toggle-group-appearance-standard.tb-toggle-header { .mat-button-toggle-group.mat-button-toggle-group-appearance-standard.tb-toggle-header {
overflow: visible; overflow: visible;
width: 100%; width: 100%;
@ -51,6 +73,12 @@
padding: 2px; padding: 2px;
border: none; border: none;
background: rgba(0, 0, 0, 0.06); background: rgba(0, 0, 0, 0.06);
&.tb-primary-fill {
background: none;
&:before {
border-radius: 100px;
}
}
.mat-button-toggle + .mat-button-toggle { .mat-button-toggle + .mat-button-toggle {
border-left: none; border-left: none;
} }
@ -161,6 +189,16 @@
&.mat-mdc-form-field { &.mat-mdc-form-field {
line-height: 16px; line-height: 16px;
font-size: 12px; font-size: 12px;
&.fill-height {
.mat-mdc-text-field-wrapper {
.mat-mdc-form-field-flex {
margin: auto;
}
}
.mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
} }
.mat-mdc-text-field-wrapper.mdc-text-field--outlined .mat-mdc-form-field-infix { .mat-mdc-text-field-wrapper.mdc-text-field--outlined .mat-mdc-form-field-infix {
min-height: 0; min-height: 0;

12
ui-ngx/src/app/shared/components/toggle-header.component.ts

@ -187,6 +187,18 @@ export class ToggleHeaderComponent extends _ToggleBase implements OnInit, AfterV
@coerceBoolean() @coerceBoolean()
disabled = false; disabled = false;
@Input()
@coerceBoolean()
fillHeight = false;
@Input()
@coerceBoolean()
extraPadding = false;
@Input()
@coerceBoolean()
primaryBackground = false;
get isMdLg(): boolean { get isMdLg(): boolean {
return !this.ignoreMdLgSize && this.isMdLgValue; return !this.ignoreMdLgSize && this.isMdLgValue;
} }

4
ui-ngx/src/app/shared/components/toggle-select.component.html

@ -16,10 +16,14 @@
--> -->
<tb-toggle-header <tb-toggle-header
[class.fill-height]="fillHeight"
ignoreMdLgSize="true" ignoreMdLgSize="true"
useSelectOnMdLg="false" useSelectOnMdLg="false"
[disabled]="disabled" [disabled]="disabled"
[appearance]="appearance" [appearance]="appearance"
[fillHeight]="fillHeight"
[extraPadding]="extraPadding"
[primaryBackground]="primaryBackground"
[disablePagination]="disablePagination" [disablePagination]="disablePagination"
[selectMediaBreakpoint]="selectMediaBreakpoint" [selectMediaBreakpoint]="selectMediaBreakpoint"
[options]="options" [options]="options"

22
ui-ngx/src/app/shared/components/toggle-select.component.scss

@ -0,0 +1,22 @@
/**
* 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.
*/
:host {
tb-toggle-header {
&.fill-height {
height: 100%;
}
}
}

14
ui-ngx/src/app/shared/components/toggle-select.component.ts

@ -24,7 +24,7 @@ import { coerceBoolean } from '@shared/decorators/coercion';
@Component({ @Component({
selector: 'tb-toggle-select', selector: 'tb-toggle-select',
templateUrl: './toggle-select.component.html', templateUrl: './toggle-select.component.html',
styleUrls: [], styleUrls: ['./toggle-select.component.scss'],
providers: [ providers: [
{ {
provide: NG_VALUE_ACCESSOR, provide: NG_VALUE_ACCESSOR,
@ -52,6 +52,18 @@ export class ToggleSelectComponent extends _ToggleBase implements ControlValueAc
@coerceBoolean() @coerceBoolean()
disablePagination = false; disablePagination = false;
@Input()
@coerceBoolean()
fillHeight = false;
@Input()
@coerceBoolean()
extraPadding = false;
@Input()
@coerceBoolean()
primaryBackground = false;
modelValue: any; modelValue: any;
private propagateChange = null; private propagateChange = null;

4
ui-ngx/src/app/shared/models/ace/ace.models.ts

@ -37,6 +37,8 @@ function loadAceDependencies(): Observable<any> {
aceObservables.push(from(import('ace-builds/src-noconflict/mode-text'))); aceObservables.push(from(import('ace-builds/src-noconflict/mode-text')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-markdown'))); aceObservables.push(from(import('ace-builds/src-noconflict/mode-markdown')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-html'))); aceObservables.push(from(import('ace-builds/src-noconflict/mode-html')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-xml')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-svg')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-c_cpp'))); aceObservables.push(from(import('ace-builds/src-noconflict/mode-c_cpp')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-protobuf'))); aceObservables.push(from(import('ace-builds/src-noconflict/mode-protobuf')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/java'))); aceObservables.push(from(import('ace-builds/src-noconflict/snippets/java')));
@ -47,6 +49,8 @@ function loadAceDependencies(): Observable<any> {
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/text'))); aceObservables.push(from(import('ace-builds/src-noconflict/snippets/text')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/markdown'))); aceObservables.push(from(import('ace-builds/src-noconflict/snippets/markdown')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/html'))); aceObservables.push(from(import('ace-builds/src-noconflict/snippets/html')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/xml')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/svg')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/c_cpp'))); aceObservables.push(from(import('ace-builds/src-noconflict/snippets/c_cpp')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/protobuf'))); aceObservables.push(from(import('ace-builds/src-noconflict/snippets/protobuf')));
aceObservables.push(from(import('ace-builds/src-noconflict/theme-textmate'))); aceObservables.push(from(import('ace-builds/src-noconflict/theme-textmate')));

2
ui-ngx/src/app/shared/models/dashboard.models.ts

@ -43,6 +43,8 @@ export interface WidgetLayout {
mobileOrder?: number; mobileOrder?: number;
col?: number; col?: number;
row?: number; row?: number;
resizable?: boolean;
preserveAspectRatio?: boolean;
} }
export interface WidgetLayouts { export interface WidgetLayouts {

2
ui-ngx/src/app/shared/models/widget.models.ts

@ -761,6 +761,8 @@ export interface WidgetConfig {
displayTimewindow?: boolean; displayTimewindow?: boolean;
timewindow?: Timewindow; timewindow?: Timewindow;
timewindowStyle?: TimewindowStyle; timewindowStyle?: TimewindowStyle;
resizable?: boolean;
preserveAspectRatio?: boolean;
desktopHide?: boolean; desktopHide?: boolean;
mobileHide?: boolean; mobileHide?: boolean;
mobileHeight?: number; mobileHeight?: number;

7
ui-ngx/src/app/shared/shared.module.ts

@ -223,6 +223,8 @@ import { HexInputComponent } from '@shared/components/color-picker/hex-input.com
import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe';
import { ScadaSymbolInputComponent } from '@shared/components/image/scada-symbol-input.component'; import { ScadaSymbolInputComponent } from '@shared/components/image/scada-symbol-input.component';
import { CountryAutocompleteComponent } from '@shared/components/country-autocomplete.component'; import { CountryAutocompleteComponent } from '@shared/components/country-autocomplete.component';
import { CountryData } from '@shared/models/country.models';
import { SvgXmlComponent } from '@shared/components/svg-xml.component';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService; return markedOptionsService;
@ -279,7 +281,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
disableTooltipInteractivity: true disableTooltipInteractivity: true
} }
}, },
TbBreakPointsProvider TbBreakPointsProvider,
CountryData
], ],
declarations: [ declarations: [
FooterComponent, FooterComponent,
@ -336,6 +339,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
JsFuncComponent, JsFuncComponent,
CssComponent, CssComponent,
HtmlComponent, HtmlComponent,
SvgXmlComponent,
FabTriggerDirective, FabTriggerDirective,
FabActionsDirective, FabActionsDirective,
FabToolbarComponent, FabToolbarComponent,
@ -542,6 +546,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
JsFuncComponent, JsFuncComponent,
CssComponent, CssComponent,
HtmlComponent, HtmlComponent,
SvgXmlComponent,
FabTriggerDirective, FabTriggerDirective,
FabActionsDirective, FabActionsDirective,
TbJsonToStringDirective, TbJsonToStringDirective,

19
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -3661,6 +3661,8 @@
"update-symbol": "Update SCADA symbol", "update-symbol": "Update SCADA symbol",
"edit-symbol": "Edit SCADA symbol", "edit-symbol": "Edit SCADA symbol",
"symbol-details": "SCADA symbol details", "symbol-details": "SCADA symbol details",
"mode-svg": "SVG",
"mode-xml": "XML",
"no-symbols": "No symbols found", "no-symbols": "No symbols found",
"show-hidden-elements": "Show hidden elements", "show-hidden-elements": "Show hidden elements",
"hide-hidden-elements": "Hide hidden elements", "hide-hidden-elements": "Hide hidden elements",
@ -3835,6 +3837,8 @@
"open-hint": "Action invoked when user clicks to open component.", "open-hint": "Action invoked when user clicks to open component.",
"close": "Close", "close": "Close",
"close-hint": "Action invoked when user clicks to close component.", "close-hint": "Action invoked when user clicks to close component.",
"close-state-animation": "Closed state animation",
"close-state-animation-hint": "Whether to enable blinking animation when component is in closed state.",
"opened-color": "Opened color", "opened-color": "Opened color",
"closed-color": "Closed color", "closed-color": "Closed color",
"opened-rotation-angle": "Opened rotation angle", "opened-rotation-angle": "Opened rotation angle",
@ -3880,7 +3884,14 @@
"stand-filter-color": "Stand filter color", "stand-filter-color": "Stand filter color",
"mode-box-background": "Mode box background", "mode-box-background": "Mode box background",
"border-color": "Border color", "border-color": "Border color",
"label-color": "Label color" "label-color": "Label color",
"water-leak-hint": "Indicates whether a leak.",
"default-color": "Default color",
"leak-color": "Leak color",
"full-value": "Full value",
"full-value-hint": "Double value indicating full value.",
"label": "Label",
"icon": "Icon"
} }
}, },
"item": { "item": {
@ -6045,7 +6056,11 @@
"color": "Color", "color": "Color",
"tooltip": "Tooltip", "tooltip": "Tooltip",
"units-required": "Unit is required.", "units-required": "Unit is required.",
"list-layout": "List layout" "list-layout": "List layout",
"layout": "Layout",
"resize-options": "Resize options",
"resizable": "Resizable",
"preserve-aspect-ratio": "Preserve aspect ratio"
}, },
"widget-type": { "widget-type": {
"import": "Import widget type", "import": "Import widget type",

Loading…
Cancel
Save