diff --git a/application/src/main/data/json/system/scada_symbols/bottom-flow-meter.svg b/application/src/main/data/json/system/scada_symbols/bottom-flow-meter.svg new file mode 100644 index 0000000000..93c496f2e3 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/bottom-flow-meter.svg @@ -0,0 +1,970 @@ + +{ + "title": "Bottom flow meter", + "description": "Bottom flow meter component used to display flow related value and render various states. Includes pipe fluid and leak visualizations.", + "searchTags": [ + "meter", + "flow meter" + ], + "widgetSizeX": 2, + "widgetSizeY": 2, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var value = ctx.values.value;\nvar colorProcessor = ctx.properties.backgroundColor;\ncolorProcessor.update(value);\nvar fill = colorProcessor.color;\nelement.attr({fill: fill});", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "border", + "stateRenderFunction": "var value = ctx.values.value;\nvar colorProcessor = ctx.properties.defaultBorderColor;\nif (ctx.values.critical) {\n colorProcessor = ctx.properties.criticalBorderColor;\n} else if (ctx.values.warning) {\n colorProcessor = ctx.properties.warningBorderColor;\n} else if (value) {\n colorProcessor = ctx.properties.activeBorderColor;\n}\ncolorProcessor.update(value);\nvar fill = colorProcessor.color;\nelement.attr({fill: fill});\n\nvar elementCriticalAnimation = element.remember('criticalAnimation');\nvar criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\nif (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n}", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "broken", + "stateRenderFunction": "var broken = ctx.values.broken;\nif (broken) {\n element.show();\n} else {\n element.hide();\n}", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nvar flow = ctx.values.flow;\nvar flowDirection = ctx.values.flowDirection;\n\nvar elementFluid = element.remember('fluid');\nvar elementFlow = null;\nvar elementFlowDirection = null;\n\nif (fluid !== elementFluid) {\n element.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n} else {\n elementFlow = element.remember('flow');\n elementFlowDirection = element.remember('flowDirection');\n}\n\nvar liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\nif (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n} else {\n liquidPattern = element.reference('fill');\n}\n\nif (fluid) {\n element.show();\n if (flow !== elementFlow) {\n element.remember('flow', flow);\n if (flow) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n } else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(ctx.values.flowAnimationSpeed);\n }\n} else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n element.hide();\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse) {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n }\n}", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "value", + "stateRenderFunction": "var value = ctx.values.value;\nctx.api.text(element, value.toFixed(0));\n", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "valueUnits", + "stateRenderFunction": "var units = ctx.properties.valueUnits;\nctx.api.text(element, units || '');\n", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + } + ], + "behavior": [ + { + "id": "value", + "name": "{i18n:scada.symbol.value}", + "hint": "{i18n:scada.symbol.flow-meter-value-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": 0, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "flowRate" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-state-animation-hint}", + "group": "{i18n:scada.symbol.display}", + "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": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "broken", + "name": "{i18n:scada.symbol.broken}", + "hint": "{i18n:scada.symbol.broken-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.broken}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "displayClick", + "name": "{i18n:scada.symbol.on-display-click}", + "hint": "{i18n:scada.symbol.on-display-click-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "widgetAction", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": null, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": { + "type": "doNothing", + "openInSeparateDialog": false, + "openInPopover": false + } + }, + { + "id": "fluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.units}", + "type": "units", + "default": "m³/hr", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "medium-width", + "min": null, + "max": null, + "step": null + }, + { + "id": "defaultBorderColor", + "name": "{i18n:scada.symbol.default-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#4A4848", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "activeBorderColor", + "name": "{i18n:scada.symbol.active-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#1C943E", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "warningBorderColor", + "name": "{i18n:scada.symbol.warning-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#FAA405", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "criticalBorderColor", + "name": "{i18n:scada.symbol.critical-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#D12730", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "backgroundColor", + "name": "{i18n:scada.symbol.background-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#FFFFFF", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0m³/hr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-pipe.svg b/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-pipe.svg new file mode 100644 index 0000000000..b3f6488f3e --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-pipe.svg @@ -0,0 +1,558 @@ + +{ + "title": "Bottom right elbow pipe", + "description": "Bottom right elbow pipe with fluid and leak visualizations.", + "searchTags": [ + "pipe", + "elbow" + ], + "widgetSizeX": 1, + "widgetSizeY": 1, + "stateRenderFunction": "var centerLiquidPattern = prepareLiquidPattern('center-liquid', 'center-fluid-background');\nvar horizontalLiquidPattern = prepareLiquidPattern('horizontal-liquid', 'horizontal-fluid');\n\ncenterLiquidPattern.attr({'xlink:href': '#'+horizontalLiquidPattern.id()});\n\nvar verticalLiquidPattern = prepareLiquidPattern('vertical-liquid', 'vertical-fluid');\n\nvar fluid = ctx.values.fluid && !ctx.values.leak;\nvar flow = ctx.values.flow;\nvar flowDirection = ctx.values.flowDirection;\nvar flowAnimationSpeed = ctx.values.flowAnimationSpeed;\n \nif (horizontalLiquidPattern) {\n updateLiquidPatternAnimation(horizontalLiquidPattern, fluid, \n flow, flowDirection, flowAnimationSpeed, false);\n}\n\nif (verticalLiquidPattern) {\n updateLiquidPatternAnimation(verticalLiquidPattern, fluid, \n flow, flowDirection, flowAnimationSpeed, false);\n}\n\nif (centerLiquidPattern) {\n updateLiquidPatternAnimation(centerLiquidPattern, fluid, \n flow, flowDirection, flowAnimationSpeed, true);\n}\n\nfunction prepareLiquidPattern(patternId, fluidElementTag) {\n var liquidPattern = ctx.svg.defs().findOne('pattern#'+patternId);\n var fluidElements = ctx.tags[fluidElementTag];\n\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n for (var i=0;i<fluidElements.length;i++) {\n fluidElements[i].fill(liquidPattern);\n }\n } else {\n liquidPattern = fluidElements[0].reference('fill');\n }\n return liquidPattern;\n}\n\nfunction updateLiquidPatternAnimation(liquidPattern, fluid, flow, flowDirection, flowAnimationSpeed, center) {\n var elementFluid = liquidPattern.remember('fluid');\n var elementFlow = null;\n var elementFlowDirection = null;\n \n if (fluid !== elementFluid) {\n liquidPattern.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n } else {\n elementFlow = liquidPattern.remember('flow');\n elementFlowDirection = liquidPattern.remember('flowDirection');\n }\n \n if (fluid) {\n if (flow !== elementFlow) {\n liquidPattern.remember('flow', flow);\n if (flow) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection, center);\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection, center);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(flowAnimationSpeed);\n }\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse, center) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n if (center) {\n var delta = deltaX * 1.17 * Math.cos(45*Math.PI/180);\n liquidPattern.animate(1000).ease('-').relative(-delta, delta).loop();\n } else {\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n }\n}\n\n", + "tags": [ + { + "tag": "center-fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "horizontal-fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}\n", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "vertical-fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}\n", + "actions": null + } + ], + "behavior": [ + { + "id": "fluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/bottom-tee-pipe.svg b/application/src/main/data/json/system/scada_symbols/bottom-tee-pipe.svg new file mode 100644 index 0000000000..9e44380509 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/bottom-tee-pipe.svg @@ -0,0 +1,854 @@ + +{ + "title": "Bottom tee pipe", + "description": "Bottom tee pipe with configurable left/right/bottom fluid and leak visualizations.", + "searchTags": [ + "pipe", + "tee" + ], + "widgetSizeX": 1, + "widgetSizeY": 1, + "stateRenderFunction": "var leftLiquidPattern = prepareLiquidPattern('left-liquid', 'left-fluid');\nvar rightLiquidPattern = prepareLiquidPattern('right-liquid', 'right-fluid');\nvar bottomLiquidPattern = prepareLiquidPattern('bottom-liquid', 'bottom-fluid');\n\nvar genericLiquidPattern = ctx.svg.defs().findOne('pattern#liquid');\nif (genericLiquidPattern) {\n genericLiquidPattern.id(ctx.api.generateElementId());\n leftLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n rightLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n bottomLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n}\n\nupdateLiquidPatternAnimation(leftLiquidPattern, 'left');\nupdateLiquidPatternAnimation(rightLiquidPattern, 'right');\nupdateLiquidPatternAnimation(bottomLiquidPattern, 'bottom');\n\n\nfunction prepareLiquidPattern(patternId, fluidElementTag) {\n var liquidPattern = ctx.svg.defs().findOne('pattern#'+patternId);\n var fluidElements = ctx.tags[fluidElementTag];\n\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n for (var i=0;i<fluidElements.length;i++) {\n fluidElements[i].fill(liquidPattern);\n }\n } else {\n liquidPattern = fluidElements[0].reference('fill');\n }\n return liquidPattern;\n}\n\nfunction updateLiquidPatternAnimation(liquidPattern, prefix) {\n if (liquidPattern) {\n var fluid = ctx.values[prefix + 'Fluid'] && !ctx.values.leak;\n var flow = ctx.values[prefix + 'Flow'];\n var flowDirection = ctx.values[prefix + 'FlowDirection'];\n var flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n var elementFluid = liquidPattern.remember('fluid');\n var elementFlow = null;\n var elementFlowDirection = null;\n \n if (fluid !== elementFluid) {\n liquidPattern.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n } else {\n elementFlow = liquidPattern.remember('flow');\n elementFlowDirection = liquidPattern.remember('flowDirection');\n }\n \n if (fluid) {\n if (flow !== elementFlow) {\n liquidPattern.remember('flow', flow);\n if (flow) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(flowAnimationSpeed);\n }\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n }\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n}\n\n", + "tags": [ + { + "tag": "bottom-fluid", + "stateRenderFunction": "var fluid = ctx.values.bottomFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "bottom-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.bottomFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.bottomFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "left-fluid", + "stateRenderFunction": "var fluid = ctx.values.leftFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "left-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.leftFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.leftFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "overlay", + "stateRenderFunction": "var fluid = (ctx.values.leftFluid || ctx.values.rightFluid ||\n ctx.values.bottomFluid) && !ctx.values.leak;\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "right-fluid", + "stateRenderFunction": "var fluid = ctx.values.rightFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "right-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.rightFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.rightFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "leftFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "leftFluidColor", + "name": "{i18n:scada.symbol.left-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "rightFluidColor", + "name": "{i18n:scada.symbol.right-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "bottomFluidColor", + "name": "{i18n:scada.symbol.bottom-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/centrifugal-pump.svg b/application/src/main/data/json/system/scada_symbols/centrifugal-pump.svg new file mode 100644 index 0000000000..c1bef78aa5 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/centrifugal-pump.svg @@ -0,0 +1,611 @@ + +{ + "title": "Centrifugal pump", + "description": "Centrifugal pump with configurable connectors, running animation and various states.", + "searchTags": [ + "pump", + "centrifugal" + ], + "widgetSizeX": 2, + "widgetSizeY": 2, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.stoppedColor;\nif (ctx.values.critical) {\n color = ctx.properties.criticalColor;\n} else if (ctx.values.warning) {\n color = ctx.properties.warningColor;\n} else if (ctx.values.running) {\n color = ctx.properties.runningColor;\n}\nelement.attr({fill: color});\n\nvar elementCriticalAnimation = element.remember('criticalAnimation');\nvar criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\nif (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n}", + "actions": null + }, + { + "tag": "center", + "stateRenderFunction": "var running = ctx.values.running;\nvar t = element.timeline();\nif (running) {\n if (!t.active()) {\n if (t.time()) {\n t.play();\n } else {\n ctx.api.animate(element, 2000).ease('-').rotate(360).loop();\n t = element.timeline();\n }\n }\n var speed = ctx.values.rotationAnimationSpeed;\n t.speed(speed);\n} else {\n t.pause();\n}\n", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "leftBottomConnector", + "stateRenderFunction": "if (ctx.properties.leftBottomConnector) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "leftTopConnector", + "stateRenderFunction": "if (ctx.properties.leftTopConnector) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "rightBottomConnector", + "stateRenderFunction": "if (ctx.properties.rightBottomConnector) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "rightTopConnector", + "stateRenderFunction": "if (ctx.properties.rightTopConnector) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "topLeftConnector", + "stateRenderFunction": "if (ctx.properties.topLeftConnector) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "topRightConnector", + "stateRenderFunction": "if (ctx.properties.topRightConnector) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "running", + "name": "{i18n:scada.symbol.running}", + "hint": "{i18n:scada.symbol.running-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.running}", + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SHARED_SCOPE", + "key": "running" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rotationAnimationSpeed", + "name": "{i18n:scada.symbol.rotation-animation-speed}", + "hint": "{i18n:scada.symbol.rotation-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "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", + "openInSeparateDialog": false, + "openInPopover": false + } + } + ], + "properties": [ + { + "id": "rightTopConnector", + "name": "{i18n:scada.symbol.right-top-connector}", + "type": "switch", + "default": true, + "required": null, + "subLabel": "", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "rightBottomConnector", + "name": "{i18n:scada.symbol.right-bottom-connector}", + "type": "switch", + "default": false, + "required": null, + "subLabel": "", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "leftTopConnector", + "name": "{i18n:scada.symbol.left-top-connector}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "leftBottomConnector", + "name": "{i18n:scada.symbol.left-bottom-connector}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "topLeftConnector", + "name": "{i18n:scada.symbol.top-left-connector}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "topRightConnector", + "name": "{i18n:scada.symbol.top-right-connector}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "runningColor", + "name": "{i18n:scada.symbol.running-color}", + "type": "color", + "default": "#1C943E", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "stoppedColor", + "name": "{i18n:scada.symbol.stopped-color}", + "type": "color", + "default": "#696969", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "warningColor", + "name": "{i18n:scada.symbol.warning-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "criticalColor", + "name": "{i18n:scada.symbol.critical-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/cross-pipe.svg b/application/src/main/data/json/system/scada_symbols/cross-pipe.svg new file mode 100644 index 0000000000..bfed29df3a --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/cross-pipe.svg @@ -0,0 +1,1047 @@ + +{ + "title": "Cross pipe", + "description": "Cross pipe with configurable left/right/top/bottom fluid and leak visualizations.", + "searchTags": [ + "pipe", + "cross" + ], + "widgetSizeX": 1, + "widgetSizeY": 1, + "stateRenderFunction": "var leftLiquidPattern = prepareLiquidPattern('left-liquid', 'left-fluid');\nvar rightLiquidPattern = prepareLiquidPattern('right-liquid', 'right-fluid');\nvar topLiquidPattern = prepareLiquidPattern('top-liquid', 'top-fluid');\nvar bottomLiquidPattern = prepareLiquidPattern('bottom-liquid', 'bottom-fluid');\n\nvar genericLiquidPattern = ctx.svg.defs().findOne('pattern#liquid');\nif (genericLiquidPattern) {\n genericLiquidPattern.id(ctx.api.generateElementId());\n leftLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n rightLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n topLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n bottomLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n}\n\nupdateLiquidPatternAnimation(leftLiquidPattern, 'left');\nupdateLiquidPatternAnimation(rightLiquidPattern, 'right');\nupdateLiquidPatternAnimation(topLiquidPattern, 'top');\nupdateLiquidPatternAnimation(bottomLiquidPattern, 'bottom');\n\n\nfunction prepareLiquidPattern(patternId, fluidElementTag) {\n var liquidPattern = ctx.svg.defs().findOne('pattern#'+patternId);\n var fluidElements = ctx.tags[fluidElementTag];\n\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n for (var i=0;i<fluidElements.length;i++) {\n fluidElements[i].fill(liquidPattern);\n }\n } else {\n liquidPattern = fluidElements[0].reference('fill');\n }\n return liquidPattern;\n}\n\nfunction updateLiquidPatternAnimation(liquidPattern, prefix) {\n if (liquidPattern) {\n var fluid = ctx.values[prefix + 'Fluid'] && !ctx.values.leak;\n var flow = ctx.values[prefix + 'Flow'];\n var flowDirection = ctx.values[prefix + 'FlowDirection'];\n var flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n var elementFluid = liquidPattern.remember('fluid');\n var elementFlow = null;\n var elementFlowDirection = null;\n \n if (fluid !== elementFluid) {\n liquidPattern.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n } else {\n elementFlow = liquidPattern.remember('flow');\n elementFlowDirection = liquidPattern.remember('flowDirection');\n }\n \n if (fluid) {\n if (flow !== elementFlow) {\n liquidPattern.remember('flow', flow);\n if (flow) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(flowAnimationSpeed);\n }\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n }\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n}\n\n", + "tags": [ + { + "tag": "bottom-fluid", + "stateRenderFunction": "var fluid = ctx.values.bottomFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "bottom-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.bottomFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.bottomFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "left-fluid", + "stateRenderFunction": "var fluid = ctx.values.leftFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "left-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.leftFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.leftFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "overlay", + "stateRenderFunction": "var fluid = (ctx.values.leftFluid || ctx.values.rightFluid ||\n ctx.values.topFluid || ctx.values.bottomFluid) && !ctx.values.leak;\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "right-fluid", + "stateRenderFunction": "var fluid = ctx.values.rightFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "right-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.rightFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.rightFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-fluid", + "stateRenderFunction": "var fluid = ctx.values.topFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.topFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.topFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "leftFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "leftFluidColor", + "name": "{i18n:scada.symbol.left-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "rightFluidColor", + "name": "{i18n:scada.symbol.right-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "topFluidColor", + "name": "{i18n:scada.symbol.top-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "bottomFluidColor", + "name": "{i18n:scada.symbol.bottom-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/cylindrical-tank.svg b/application/src/main/data/json/system/scada_symbols/cylindrical-tank.svg new file mode 100644 index 0000000000..293de230cc --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/cylindrical-tank.svg @@ -0,0 +1,1401 @@ + +{ + "title": "Cylindrical tank", + "description": "Cylindrical tank with current volume value and level visualizations.", + "searchTags": [ + "tank" + ], + "widgetSizeX": 3, + "widgetSizeY": 5, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 590});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*900; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 590 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*900; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "scale", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 205;\n var majorIntervalLength = 760 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(340, y, 372, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', 'dominant-baseline': 'middle', class: 'majorTickText'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(352, minorY, 372, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "actions": null + }, + { + "tag": "scale-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent || !ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "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 + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "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 + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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 + }, + { + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "##F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "scale", + "name": "{i18n:scada.symbol.scale}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorIntervals", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "number", + "default": 10, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": 1 + }, + { + "id": "majorFont", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "font", + "default": { + "size": 24, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#00000061", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorWarningColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorCriticalColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorIntervals", + "name": "{i18n:scada.symbol.minor-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": null + }, + { + "id": "minorColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#0000001F", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorWarningColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorCriticalColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/elevated-tank.svg b/application/src/main/data/json/system/scada_symbols/elevated-tank.svg new file mode 100644 index 0000000000..120604ae95 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/elevated-tank.svg @@ -0,0 +1,1633 @@ + +{ + "title": "Elevated tank", + "description": "Elevated tank with current volume value and level visualizations.", + "searchTags": [ + "tank", + "elevator" + ], + "widgetSizeX": 6, + "widgetSizeY": 10, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.transparent) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 590});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*895; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 590 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.transparent) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*895; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "scale", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 265;\n var majorIntervalLength = 895 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(825, y, 857, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 815, y: y + 2, 'text-anchor': 'end', 'dominant-baseline': 'middle', class: 'majorTickText'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(837, minorY, 857, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "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 + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "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 + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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 + }, + { + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "#F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "scale", + "name": "{i18n:scada.symbol.scale}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "transparent", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorIntervals", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "number", + "default": 10, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": 1 + }, + { + "id": "majorFont", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "font", + "default": { + "size": 24, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#00000061", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorWarningColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorCriticalColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorIntervals", + "name": "{i18n:scada.symbol.minor-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": null + }, + { + "id": "minorColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#0000001F", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorWarningColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorCriticalColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/horizontal-ball-valve.svg b/application/src/main/data/json/system/scada_symbols/horizontal-ball-valve.svg new file mode 100644 index 0000000000..da140b4383 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/horizontal-ball-valve.svg @@ -0,0 +1,271 @@ + +{ + "title": "Horizontal ball valve", + "description": "Horizontal ball valve with open/close animation and state colors.", + "searchTags": [ + "valve", + "ball" + ], + "widgetSizeX": 1, + "widgetSizeY": 1, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var opened = ctx.values.opened;\nvar openAnimate = element.remember('openAnimate');\nvar color = opened ? ctx.properties.openedColor : ctx.properties.closedColor;\nif (!openAnimate) {\n element.attr({fill: color});\n} else {\n ctx.api.animate(element, 500).attr({fill: color});\n element.remember('openAnimate', false);\n}\n", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": " ctx.tags.wheel.forEach(e => {\n e.remember('openAnimate', true);\n });\n ctx.tags.background.forEach(e => {\n e.remember('openAnimate', true);\n });\n\n\nvar 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": "wheel", + "stateRenderFunction": "var opened = ctx.values.opened;\nvar openAnimate = element.remember('openAnimate');\nvar angle = opened ? ctx.properties.openedRotationAngle : ctx.properties.closedRotationAngle;\nif (!openAnimate) {\n element.transform({rotate: angle, originX: 100});\n} else {\n ctx.api.animate(element, 500).transform({rotate: angle, originX: 100});\n element.remember('openAnimate', false);\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": true, + "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": false, + "valueToDataFunction": "/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;" + } + }, + "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": "#696969", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "openedRotationAngle", + "name": "{i18n:scada.symbol.opened-rotation-angle}", + "type": "number", + "default": 0, + "required": true, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": -179, + "max": 179, + "step": null + }, + { + "id": "closedRotationAngle", + "name": "{i18n:scada.symbol.closed-rotation-angle}", + "type": "number", + "default": 90, + "required": true, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": -179, + "max": 179, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/horizontal-inline-flow-meter.svg b/application/src/main/data/json/system/scada_symbols/horizontal-inline-flow-meter.svg new file mode 100644 index 0000000000..8b34deaffd --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/horizontal-inline-flow-meter.svg @@ -0,0 +1,948 @@ + +{ + "title": "Horizontal inline flow meter", + "description": "Horizontal inline flow meter component used to display flow related value and render various states. Includes pipe fluid and leak visualizations.", + "searchTags": [ + "meter", + "flow meter" + ], + "widgetSizeX": 2, + "widgetSizeY": 1, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var value = ctx.values.value;\nvar colorProcessor = ctx.properties.backgroundColor;\ncolorProcessor.update(value);\nvar fill = colorProcessor.color;\nelement.attr({fill: fill});", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "border", + "stateRenderFunction": "var value = ctx.values.value;\nvar colorProcessor = ctx.properties.defaultBorderColor;\nif (ctx.values.critical) {\n colorProcessor = ctx.properties.criticalBorderColor;\n} else if (ctx.values.warning) {\n colorProcessor = ctx.properties.warningBorderColor;\n} else if (value) {\n colorProcessor = ctx.properties.activeBorderColor;\n}\ncolorProcessor.update(value);\nvar fill = colorProcessor.color;\nelement.attr({fill: fill});\n\nvar elementCriticalAnimation = element.remember('criticalAnimation');\nvar criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\nif (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n}", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "broken", + "stateRenderFunction": "var broken = ctx.values.broken;\nif (broken) {\n element.show();\n} else {\n element.hide();\n}", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nvar flow = ctx.values.flow;\nvar flowDirection = ctx.values.flowDirection;\n\nvar elementFluid = element.remember('fluid');\nvar elementFlow = null;\nvar elementFlowDirection = null;\n\nif (fluid !== elementFluid) {\n element.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n} else {\n elementFlow = element.remember('flow');\n elementFlowDirection = element.remember('flowDirection');\n}\n\nvar liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\nif (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n} else {\n liquidPattern = element.reference('fill');\n}\n\nif (fluid) {\n element.show();\n if (flow !== elementFlow) {\n element.remember('flow', flow);\n if (flow) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n } else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(ctx.values.flowAnimationSpeed);\n }\n} else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n element.hide();\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse) {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n }\n}", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "value", + "stateRenderFunction": "var value = ctx.values.value;\nctx.api.text(element, value.toFixed(0));\n", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "valueUnits", + "stateRenderFunction": "var units = ctx.properties.valueUnits;\nctx.api.text(element, units || '');\n", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + } + ], + "behavior": [ + { + "id": "value", + "name": "{i18n:scada.symbol.value}", + "hint": "{i18n:scada.symbol.flow-meter-value-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": 0, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "flowRate" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-state-animation-hint}", + "group": "{i18n:scada.symbol.display}", + "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": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "broken", + "name": "{i18n:scada.symbol.broken}", + "hint": "{i18n:scada.symbol.broken-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.broken}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "displayClick", + "name": "{i18n:scada.symbol.on-display-click}", + "hint": "{i18n:scada.symbol.on-display-click-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "widgetAction", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": null, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": { + "type": "doNothing", + "openInSeparateDialog": false, + "openInPopover": false + } + }, + { + "id": "fluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.units}", + "type": "units", + "default": "m³/hr", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "medium-width", + "min": null, + "max": null, + "step": null + }, + { + "id": "defaultBorderColor", + "name": "{i18n:scada.symbol.default-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#4A4848", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "activeBorderColor", + "name": "{i18n:scada.symbol.active-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#1C943E", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "warningBorderColor", + "name": "{i18n:scada.symbol.warning-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#FAA405", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "criticalBorderColor", + "name": "{i18n:scada.symbol.critical-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#D12730", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "backgroundColor", + "name": "{i18n:scada.symbol.background-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#FFFFFF", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0m³/hr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/horizontal-pipe.svg b/application/src/main/data/json/system/scada_symbols/horizontal-pipe.svg new file mode 100644 index 0000000000..d2ddc67657 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/horizontal-pipe.svg @@ -0,0 +1,422 @@ + +{ + "title": "Horizontal pipe", + "description": "Horizontal pipe with fluid and leak visualizations.", + "searchTags": [ + "pipe", + "horizontal pipe" + ], + "widgetSizeX": 1, + "widgetSizeY": 1, + "tags": [ + { + "tag": "fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nvar flow = ctx.values.flow;\nvar flowDirection = ctx.values.flowDirection;\n\nvar elementFluid = element.remember('fluid');\nvar elementFlow = null;\nvar elementFlowDirection = null;\n\nif (fluid !== elementFluid) {\n element.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n} else {\n elementFlow = element.remember('flow');\n elementFlowDirection = element.remember('flowDirection');\n}\n\nvar liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\nif (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n} else {\n liquidPattern = element.reference('fill');\n}\n\nif (fluid) {\n element.show();\n if (flow !== elementFlow) {\n element.remember('flow', flow);\n if (flow) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n } else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(ctx.values.flowAnimationSpeed);\n }\n} else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n element.hide();\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse) {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n }\n}", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + } + ], + "behavior": [ + { + "id": "fluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/horizontal-tank.svg b/application/src/main/data/json/system/scada_symbols/horizontal-tank.svg new file mode 100644 index 0000000000..c504f0303b --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/horizontal-tank.svg @@ -0,0 +1,1407 @@ + +{ + "title": "Horizontal tank", + "description": "Horizontal tank with current volume value and level visualizations.", + "searchTags": [ + "tank" + ], + "widgetSizeX": 5, + "widgetSizeY": 3, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 590});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*568; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 590 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*568; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "scale", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 17;\n var majorIntervalLength = 568 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(715, y, 747, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 705, y: y + 2, 'text-anchor': 'end', 'dominant-baseline': 'middle', class: 'majorTickText'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(727, minorY, 747, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent || !ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "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 + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "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 + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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 + }, + { + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "#F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "scale", + "name": "{i18n:scada.symbol.scale}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorIntervals", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "number", + "default": 10, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": 1 + }, + { + "id": "majorFont", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "font", + "default": { + "size": 24, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#00000061", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorWarningColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorCriticalColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorIntervals", + "name": "{i18n:scada.symbol.minor-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": null + }, + { + "id": "minorColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#0000001F", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorWarningColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorCriticalColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/horizontal-wheel-valve.svg b/application/src/main/data/json/system/scada_symbols/horizontal-wheel-valve.svg new file mode 100644 index 0000000000..a96f61781d --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/horizontal-wheel-valve.svg @@ -0,0 +1,300 @@ + +{ + "title": "Horizontal wheel valve", + "description": "Horizontal wheel valve with open/close animation and state colors.", + "searchTags": [ + "valve", + "wheel" + ], + "widgetSizeX": 2, + "widgetSizeY": 1, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var opened = ctx.values.opened;\nvar openAnimate = element.remember('openAnimate');\nvar color = opened ? ctx.properties.openedColor : ctx.properties.closedColor;\nif (!openAnimate) {\n element.attr({fill: color});\n} else {\n ctx.api.animate(element, 500).attr({fill: color});\n element.remember('openAnimate', false);\n}\n", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": " ctx.tags.wheel.forEach(e => {\n e.remember('openAnimate', true);\n });\n ctx.tags.background.forEach(e => {\n e.remember('openAnimate', true);\n });\n\n\nvar 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": "wheel", + "stateRenderFunction": "var opened = ctx.values.opened;\nvar openAnimate = element.remember('openAnimate');\nvar angle = opened ? ctx.properties.openedRotationAngle : ctx.properties.closedRotationAngle;\nif (!openAnimate) {\n element.transform({rotate: angle});\n} else {\n ctx.api.animate(element, 500).transform({rotate: angle});\n element.remember('openAnimate', false);\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": true, + "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": false, + "valueToDataFunction": "/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;" + } + }, + "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": "#696969", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "openedRotationAngle", + "name": "{i18n:scada.symbol.opened-rotation-angle}", + "type": "number", + "default": 0, + "required": true, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": -179, + "max": 179, + "step": null + }, + { + "id": "closedRotationAngle", + "name": "{i18n:scada.symbol.closed-rotation-angle}", + "type": "number", + "default": 90, + "required": true, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": -179, + "max": 179, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/large-cylindrical-tank.svg b/application/src/main/data/json/system/scada_symbols/large-cylindrical-tank.svg new file mode 100644 index 0000000000..4773147dff --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/large-cylindrical-tank.svg @@ -0,0 +1,1392 @@ + +{ + "title": "Large cylindrical tank", + "description": " Large cylindrical tank with current volume value and level visualizations.", + "searchTags": [ + "tank" + ], + "widgetSizeX": 5, + "widgetSizeY": 5, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 590});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*910; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 590 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*910; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "scale", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 60;\n var majorIntervalLength = 910 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(656, y, 688, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 646, y: y + 2, 'text-anchor': 'end', 'dominant-baseline': 'middle', class: 'majorTickText'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(668, minorY, 688, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "actions": null + }, + { + "tag": "scale-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent || !ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "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 + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "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 + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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 + }, + { + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "#F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "scale", + "name": "{i18n:scada.symbol.scale}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorIntervals", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "number", + "default": 10, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": 1 + }, + { + "id": "majorFont", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "font", + "default": { + "size": 24, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#00000061", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorWarningColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorCriticalColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorIntervals", + "name": "{i18n:scada.symbol.minor-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": null + }, + { + "id": "minorColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#0000001F", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorWarningColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorCriticalColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/large-stand-cylindrical-tank.svg b/application/src/main/data/json/system/scada_symbols/large-stand-cylindrical-tank.svg new file mode 100644 index 0000000000..ef365ab465 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/large-stand-cylindrical-tank.svg @@ -0,0 +1,1453 @@ + +{ + "title": "Large stand cylindrical tank", + "description": "Large stand cylindrical tank with current volume value and level visualizations.", + "searchTags": [ + "tank", + "stand tank" + ], + "widgetSizeX": 5, + "widgetSizeY": 6, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 590});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*910; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 590 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*910; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "scale", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 60;\n var majorIntervalLength = 910 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(656, y, 688, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 646, y: y + 2, 'text-anchor': 'end', 'dominant-baseline': 'middle', class: 'majorTickText'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(668, minorY, 688, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "actions": null + }, + { + "tag": "scale-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent || !ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "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 + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "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 + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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 + }, + { + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "#F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "scale", + "name": "{i18n:scada.symbol.scale}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorIntervals", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "number", + "default": 10, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": 1 + }, + { + "id": "majorFont", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "font", + "default": { + "size": 24, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#00000061", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorWarningColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorCriticalColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorIntervals", + "name": "{i18n:scada.symbol.minor-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": null + }, + { + "id": "minorColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#0000001F", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorWarningColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorCriticalColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/large-stand-vertical-tank.svg b/application/src/main/data/json/system/scada_symbols/large-stand-vertical-tank.svg new file mode 100644 index 0000000000..d38164a814 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/large-stand-vertical-tank.svg @@ -0,0 +1,1455 @@ + +{ + "title": "Large stand vertical tank", + "description": "Large stand tank with current volume value and level visualizations.", + "searchTags": [ + "tank", + "stand tand" + ], + "widgetSizeX": 5, + "widgetSizeY": 6, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 590});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*763; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 590 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*763; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "scale", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 203;\n var majorIntervalLength = 763 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(676, y, 708, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 666, y: y + 2, 'text-anchor': 'end', 'dominant-baseline': 'middle', class: 'majorTickText'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(688, minorY, 708, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "actions": null + }, + { + "tag": "scale-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent || !ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "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 + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "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 + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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 + }, + { + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "#F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "scale", + "name": "{i18n:scada.symbol.scale}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorIntervals", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "number", + "default": 10, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": 1 + }, + { + "id": "majorFont", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "font", + "default": { + "size": 24, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#00000061", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorWarningColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorCriticalColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorIntervals", + "name": "{i18n:scada.symbol.minor-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": null + }, + { + "id": "minorColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#0000001F", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorWarningColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorCriticalColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/large-vertical-tank.svg b/application/src/main/data/json/system/scada_symbols/large-vertical-tank.svg new file mode 100644 index 0000000000..0387e160ad --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/large-vertical-tank.svg @@ -0,0 +1,1394 @@ + +{ + "title": "Large vertical tank", + "description": "Large vertical tank with current volume value and level visualizations.", + "searchTags": [ + "tank" + ], + "widgetSizeX": 5, + "widgetSizeY": 5, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 590});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*763; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 590 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*763; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "scale", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 203;\n var majorIntervalLength = 763 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(676, y, 708, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 666, y: y + 2, 'text-anchor': 'end', 'dominant-baseline': 'middle', class: 'majorTickText'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(688, minorY, 708, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "actions": null + }, + { + "tag": "scale-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent || !ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "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 + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "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 + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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 + }, + { + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "#F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "scale", + "name": "{i18n:scada.symbol.scale}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorIntervals", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "number", + "default": 10, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": 1 + }, + { + "id": "majorFont", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "font", + "default": { + "size": 24, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#00000061", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorWarningColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorCriticalColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorIntervals", + "name": "{i18n:scada.symbol.minor-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": null + }, + { + "id": "minorColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#0000001F", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorWarningColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorCriticalColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-pipe.svg b/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-pipe.svg new file mode 100644 index 0000000000..cd9b5f1b7a --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-pipe.svg @@ -0,0 +1,558 @@ +{ + "title": "Left bottom elbow pipe", + "description": "Left bottom elbow pipe with fluid and leak visualizations.", + "searchTags": [ + "pipe", + "elbow" + ], + "widgetSizeX": 1, + "widgetSizeY": 1, + "stateRenderFunction": "var centerLiquidPattern = prepareLiquidPattern('center-liquid', 'center-fluid-background');\nvar horizontalLiquidPattern = prepareLiquidPattern('horizontal-liquid', 'horizontal-fluid');\n\ncenterLiquidPattern.attr({'xlink:href': '#'+horizontalLiquidPattern.id()});\n\nvar verticalLiquidPattern = prepareLiquidPattern('vertical-liquid', 'vertical-fluid');\n\nvar fluid = ctx.values.fluid && !ctx.values.leak;\nvar flow = ctx.values.flow;\nvar flowDirection = ctx.values.flowDirection;\nvar flowAnimationSpeed = ctx.values.flowAnimationSpeed;\n \nif (horizontalLiquidPattern) {\n updateLiquidPatternAnimation(horizontalLiquidPattern, fluid, \n flow, flowDirection, flowAnimationSpeed, false);\n}\n\nif (verticalLiquidPattern) {\n updateLiquidPatternAnimation(verticalLiquidPattern, fluid, \n flow, flowDirection, flowAnimationSpeed, false);\n}\n\nif (centerLiquidPattern) {\n updateLiquidPatternAnimation(centerLiquidPattern, fluid, \n flow, flowDirection, flowAnimationSpeed, true);\n}\n\nfunction prepareLiquidPattern(patternId, fluidElementTag) {\n var liquidPattern = ctx.svg.defs().findOne('pattern#'+patternId);\n var fluidElements = ctx.tags[fluidElementTag];\n\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n for (var i=0;i<fluidElements.length;i++) {\n fluidElements[i].fill(liquidPattern);\n }\n } else {\n liquidPattern = fluidElements[0].reference('fill');\n }\n return liquidPattern;\n}\n\nfunction updateLiquidPatternAnimation(liquidPattern, fluid, flow, flowDirection, flowAnimationSpeed, center) {\n var elementFluid = liquidPattern.remember('fluid');\n var elementFlow = null;\n var elementFlowDirection = null;\n \n if (fluid !== elementFluid) {\n liquidPattern.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n } else {\n elementFlow = liquidPattern.remember('flow');\n elementFlowDirection = liquidPattern.remember('flowDirection');\n }\n \n if (fluid) {\n if (flow !== elementFlow) {\n liquidPattern.remember('flow', flow);\n if (flow) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection, center);\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection, center);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(flowAnimationSpeed);\n }\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse, center) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n if (center) {\n var delta = deltaX * 1.17 * Math.cos(45*Math.PI/180);\n liquidPattern.animate(1000).ease('-').relative(-delta, delta).loop();\n } else {\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n }\n}\n\n", + "tags": [ + { + "tag": "center-fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "horizontal-fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}\n", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "vertical-fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}\n", + "actions": null + } + ], + "behavior": [ + { + "id": "fluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/left-drain-pipe.svg b/application/src/main/data/json/system/scada_symbols/left-drain-pipe.svg new file mode 100644 index 0000000000..2a5c3ce47a --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/left-drain-pipe.svg @@ -0,0 +1,166 @@ + + { + "title": "Left drain pipe", + "description": "Left drain pipe", + "searchTags": [ + "pipe", + "drain" + ], + "widgetSizeX": 2, + "widgetSizeY": 1, + "tags": [ + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var color = ctx.properties.fluidColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + } + ], + "behavior": [ + { + "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", + "openInSeparateDialog": false, + "openInPopover": false + } + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "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": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#8CAA5C", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/left-elbow-drain-pipe.svg b/application/src/main/data/json/system/scada_symbols/left-elbow-drain-pipe.svg new file mode 100644 index 0000000000..2f91847b71 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/left-elbow-drain-pipe.svg @@ -0,0 +1,181 @@ + +{ + "title": "Left elbow drain pipe", + "description": "Left elbow drain pipe", + "searchTags": [ + "pipe", + "drain" + ], + "widgetSizeX": 2, + "widgetSizeY": 1, + "tags": [ + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var color = ctx.properties.fluidColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + } + ], + "behavior": [ + { + "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", + "openInSeparateDialog": false, + "openInPopover": false + } + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "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": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#8CAA5C", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/left-flow-meter.svg b/application/src/main/data/json/system/scada_symbols/left-flow-meter.svg new file mode 100644 index 0000000000..b1da702871 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/left-flow-meter.svg @@ -0,0 +1,964 @@ + +{ + "title": "Left flow meter", + "description": "Left flow meter component used to display flow related value and render various states. Includes pipe fluid and leak visualizations.", + "searchTags": [ + "meter", + "flow meter" + ], + "widgetSizeX": 2, + "widgetSizeY": 2, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var value = ctx.values.value;\nvar colorProcessor = ctx.properties.backgroundColor;\ncolorProcessor.update(value);\nvar fill = colorProcessor.color;\nelement.attr({fill: fill});", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "border", + "stateRenderFunction": "var value = ctx.values.value;\nvar colorProcessor = ctx.properties.defaultBorderColor;\nif (ctx.values.critical) {\n colorProcessor = ctx.properties.criticalBorderColor;\n} else if (ctx.values.warning) {\n colorProcessor = ctx.properties.warningBorderColor;\n} else if (value) {\n colorProcessor = ctx.properties.activeBorderColor;\n}\ncolorProcessor.update(value);\nvar fill = colorProcessor.color;\nelement.attr({fill: fill});\n\nvar elementCriticalAnimation = element.remember('criticalAnimation');\nvar criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\nif (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n}", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "broken", + "stateRenderFunction": "var broken = ctx.values.broken;\nif (broken) {\n element.show();\n} else {\n element.hide();\n}", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nvar flow = ctx.values.flow;\nvar flowDirection = ctx.values.flowDirection;\n\nvar elementFluid = element.remember('fluid');\nvar elementFlow = null;\nvar elementFlowDirection = null;\n\nif (fluid !== elementFluid) {\n element.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n} else {\n elementFlow = element.remember('flow');\n elementFlowDirection = element.remember('flowDirection');\n}\n\nvar liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\nif (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n} else {\n liquidPattern = element.reference('fill');\n}\n\nif (fluid) {\n element.show();\n if (flow !== elementFlow) {\n element.remember('flow', flow);\n if (flow) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n } else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(ctx.values.flowAnimationSpeed);\n }\n} else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n element.hide();\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse) {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n }\n}", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "value", + "stateRenderFunction": "var value = ctx.values.value;\nctx.api.text(element, value.toFixed(0));\n", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "valueUnits", + "stateRenderFunction": "var units = ctx.properties.valueUnits;\nctx.api.text(element, units || '');\n", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + } + ], + "behavior": [ + { + "id": "value", + "name": "{i18n:scada.symbol.value}", + "hint": "{i18n:scada.symbol.flow-meter-value-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": 0, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "flowRate" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-state-animation-hint}", + "group": "{i18n:scada.symbol.display}", + "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": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "broken", + "name": "{i18n:scada.symbol.broken}", + "hint": "{i18n:scada.symbol.broken-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.broken}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "displayClick", + "name": "{i18n:scada.symbol.on-display-click}", + "hint": "{i18n:scada.symbol.on-display-click-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "widgetAction", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": null, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": { + "type": "doNothing", + "openInSeparateDialog": false, + "openInPopover": false + } + }, + { + "id": "fluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.units}", + "type": "units", + "default": "m³/hr", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "medium-width", + "min": null, + "max": null, + "step": null + }, + { + "id": "defaultBorderColor", + "name": "{i18n:scada.symbol.default-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#4A4848", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "activeBorderColor", + "name": "{i18n:scada.symbol.active-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#1C943E", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "warningBorderColor", + "name": "{i18n:scada.symbol.warning-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#FAA405", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "criticalBorderColor", + "name": "{i18n:scada.symbol.critical-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#D12730", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "backgroundColor", + "name": "{i18n:scada.symbol.background-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#FFFFFF", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0m³/hr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/left-motor-pump.svg b/application/src/main/data/json/system/scada_symbols/left-motor-pump.svg new file mode 100644 index 0000000000..5be0a454e5 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/left-motor-pump.svg @@ -0,0 +1,526 @@ +{ + "title": "Left motor pump", + "description": "Left motor pump with configurable states.", + "searchTags": [ + "motor", + "pump" + ], + "widgetSizeX": 4, + "widgetSizeY": 3, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.stoppedColor;\nif (ctx.values.critical) {\n color = ctx.properties.criticalColor;\n} else if (ctx.values.warning) {\n color = ctx.properties.warningColor;\n} else if (ctx.values.running) {\n color = ctx.properties.runningColor;\n}\nelement.attr({fill: color});\n\nvar elementCriticalAnimation = element.remember('criticalAnimation');\nvar criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\nif (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n}", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + } + ], + "behavior": [ + { + "id": "running", + "name": "{i18n:scada.symbol.running}", + "hint": "{i18n:scada.symbol.running-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.running}", + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SHARED_SCOPE", + "key": "running" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "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", + "openInSeparateDialog": false, + "openInPopover": false + } + } + ], + "properties": [ + { + "id": "runningColor", + "name": "{i18n:scada.symbol.running-color}", + "type": "color", + "default": "#1C943E", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "stoppedColor", + "name": "{i18n:scada.symbol.stopped-color}", + "type": "color", + "default": "#696969", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "warningColor", + "name": "{i18n:scada.symbol.warning-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "criticalColor", + "name": "{i18n:scada.symbol.critical-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/left-tee-pipe.svg b/application/src/main/data/json/system/scada_symbols/left-tee-pipe.svg new file mode 100644 index 0000000000..a0e01f639b --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/left-tee-pipe.svg @@ -0,0 +1,866 @@ + +{ + "title": "Left tee pipe", + "description": "Left tee pipe with configurable left/top/bottom fluid and leak visualizations.", + "searchTags": [ + "pipe", + "tee" + ], + "widgetSizeX": 1, + "widgetSizeY": 1, + "stateRenderFunction": "var leftLiquidPattern = prepareLiquidPattern('left-liquid', 'left-fluid');\nvar topLiquidPattern = prepareLiquidPattern('top-liquid', 'top-fluid');\nvar bottomLiquidPattern = prepareLiquidPattern('bottom-liquid', 'bottom-fluid');\n\nvar genericLiquidPattern = ctx.svg.defs().findOne('pattern#liquid');\nif (genericLiquidPattern) {\n genericLiquidPattern.id(ctx.api.generateElementId());\n leftLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n topLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n bottomLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n}\n\nupdateLiquidPatternAnimation(leftLiquidPattern, 'left');\nupdateLiquidPatternAnimation(topLiquidPattern, 'top');\nupdateLiquidPatternAnimation(bottomLiquidPattern, 'bottom');\n\n\nfunction prepareLiquidPattern(patternId, fluidElementTag) {\n var liquidPattern = ctx.svg.defs().findOne('pattern#'+patternId);\n var fluidElements = ctx.tags[fluidElementTag];\n\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n for (var i=0;i<fluidElements.length;i++) {\n fluidElements[i].fill(liquidPattern);\n }\n } else {\n liquidPattern = fluidElements[0].reference('fill');\n }\n return liquidPattern;\n}\n\nfunction updateLiquidPatternAnimation(liquidPattern, prefix) {\n if (liquidPattern) {\n var fluid = ctx.values[prefix + 'Fluid'] && !ctx.values.leak;\n var flow = ctx.values[prefix + 'Flow'];\n var flowDirection = ctx.values[prefix + 'FlowDirection'];\n var flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n var elementFluid = liquidPattern.remember('fluid');\n var elementFlow = null;\n var elementFlowDirection = null;\n \n if (fluid !== elementFluid) {\n liquidPattern.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n } else {\n elementFlow = liquidPattern.remember('flow');\n elementFlowDirection = liquidPattern.remember('flowDirection');\n }\n \n if (fluid) {\n if (flow !== elementFlow) {\n liquidPattern.remember('flow', flow);\n if (flow) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(flowAnimationSpeed);\n }\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n }\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n}\n\n", + "tags": [ + { + "tag": "bottom-fluid", + "stateRenderFunction": "var fluid = ctx.values.bottomFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "bottom-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.bottomFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.bottomFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "left-fluid", + "stateRenderFunction": "var fluid = ctx.values.leftFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "left-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.leftFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.leftFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "overlay", + "stateRenderFunction": "var fluid = (ctx.values.leftFluid ||\n ctx.values.topFluid || ctx.values.bottomFluid) && !ctx.values.leak;\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "top-fluid", + "stateRenderFunction": "var fluid = ctx.values.topFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.topFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.topFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "leftFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "leftFluidColor", + "name": "{i18n:scada.symbol.left-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "topFluidColor", + "name": "{i18n:scada.symbol.top-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "bottomFluidColor", + "name": "{i18n:scada.symbol.bottom-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/left-top-elbow-pipe.svg b/application/src/main/data/json/system/scada_symbols/left-top-elbow-pipe.svg new file mode 100644 index 0000000000..276f3aa004 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/left-top-elbow-pipe.svg @@ -0,0 +1,558 @@ +{ + "title": "Left top elbow pipe", + "description": "Left top elbow pipe with fluid and leak visualizations.", + "searchTags": [ + "pipe", + "elbow" + ], + "widgetSizeX": 1, + "widgetSizeY": 1, + "stateRenderFunction": "var centerLiquidPattern = prepareLiquidPattern('center-liquid', 'center-fluid-background');\nvar horizontalLiquidPattern = prepareLiquidPattern('horizontal-liquid', 'horizontal-fluid');\n\ncenterLiquidPattern.attr({'xlink:href': '#'+horizontalLiquidPattern.id()});\n\nvar verticalLiquidPattern = prepareLiquidPattern('vertical-liquid', 'vertical-fluid');\n\nvar fluid = ctx.values.fluid && !ctx.values.leak;\nvar flow = ctx.values.flow;\nvar flowDirection = ctx.values.flowDirection;\nvar flowAnimationSpeed = ctx.values.flowAnimationSpeed;\n \nif (horizontalLiquidPattern) {\n updateLiquidPatternAnimation(horizontalLiquidPattern, fluid, \n flow, flowDirection, flowAnimationSpeed, false);\n}\n\nif (verticalLiquidPattern) {\n updateLiquidPatternAnimation(verticalLiquidPattern, fluid, \n flow, flowDirection, flowAnimationSpeed, false);\n}\n\nif (centerLiquidPattern) {\n updateLiquidPatternAnimation(centerLiquidPattern, fluid, \n flow, flowDirection, flowAnimationSpeed, true);\n}\n\nfunction prepareLiquidPattern(patternId, fluidElementTag) {\n var liquidPattern = ctx.svg.defs().findOne('pattern#'+patternId);\n var fluidElements = ctx.tags[fluidElementTag];\n\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n for (var i=0;i<fluidElements.length;i++) {\n fluidElements[i].fill(liquidPattern);\n }\n } else {\n liquidPattern = fluidElements[0].reference('fill');\n }\n return liquidPattern;\n}\n\nfunction updateLiquidPatternAnimation(liquidPattern, fluid, flow, flowDirection, flowAnimationSpeed, center) {\n var elementFluid = liquidPattern.remember('fluid');\n var elementFlow = null;\n var elementFlowDirection = null;\n \n if (fluid !== elementFluid) {\n liquidPattern.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n } else {\n elementFlow = liquidPattern.remember('flow');\n elementFlowDirection = liquidPattern.remember('flowDirection');\n }\n \n if (fluid) {\n if (flow !== elementFlow) {\n liquidPattern.remember('flow', flow);\n if (flow) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection, center);\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection, center);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(flowAnimationSpeed);\n }\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse, center) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n if (center) {\n var delta = deltaX * 1.17 * Math.cos(45*Math.PI/180);\n liquidPattern.animate(1000).ease('-').relative(delta, -delta).loop();\n } else {\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n }\n}\n\n", + "tags": [ + { + "tag": "center-fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "horizontal-fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}\n", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "vertical-fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}\n", + "actions": null + } + ], + "behavior": [ + { + "id": "fluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/long-bottom-filter.svg b/application/src/main/data/json/system/scada_symbols/long-bottom-filter.svg new file mode 100644 index 0000000000..d9defd33d9 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/long-bottom-filter.svg @@ -0,0 +1,88 @@ + +{ + "title": "Long bottom filter", + "description": "Long bottom filter", + "searchTags": [ + "filter" + ], + "widgetSizeX": 1, + "widgetSizeY": 3, + "tags": [ + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + } + ], + "behavior": [ + { + "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", + "openInSeparateDialog": false, + "openInPopover": false + } + } + ], + "properties": [] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/long-horizontal-pipe.svg b/application/src/main/data/json/system/scada_symbols/long-horizontal-pipe.svg new file mode 100644 index 0000000000..ffc77b0ac0 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/long-horizontal-pipe.svg @@ -0,0 +1,487 @@ + +{ + "title": "Long horizontal pipe", + "description": "Long horizontal pipe with fluid and leak visualizations.", + "searchTags": [ + "pipe", + "horizontal pipe" + ], + "widgetSizeX": 2, + "widgetSizeY": 1, + "tags": [ + { + "tag": "fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nvar flow = ctx.values.flow;\nvar flowDirection = ctx.values.flowDirection;\n\nvar elementFluid = element.remember('fluid');\nvar elementFlow = null;\nvar elementFlowDirection = null;\n\nif (fluid !== elementFluid) {\n element.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n} else {\n elementFlow = element.remember('flow');\n elementFlowDirection = element.remember('flowDirection');\n}\n\nvar liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\nif (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n} else {\n liquidPattern = element.reference('fill');\n}\n\nif (fluid) {\n element.show();\n if (flow !== elementFlow) {\n element.remember('flow', flow);\n if (flow) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n } else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(ctx.values.flowAnimationSpeed);\n }\n} else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n element.hide();\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse) {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n }\n}", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + } + ], + "behavior": [ + { + "id": "fluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/long-top-filter.svg b/application/src/main/data/json/system/scada_symbols/long-top-filter.svg new file mode 100644 index 0000000000..a63b679f0a --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/long-top-filter.svg @@ -0,0 +1,88 @@ + +{ + "title": "Long top filter", + "description": "Title\nLong top filter\n", + "searchTags": [ + "filter" + ], + "widgetSizeX": 1, + "widgetSizeY": 3, + "tags": [ + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + } + ], + "behavior": [ + { + "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", + "openInSeparateDialog": false, + "openInPopover": false + } + } + ], + "properties": [] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/long-vertical-pipe.svg b/application/src/main/data/json/system/scada_symbols/long-vertical-pipe.svg new file mode 100644 index 0000000000..7f4e30edad --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/long-vertical-pipe.svg @@ -0,0 +1,484 @@ + +{ + "title": "Long vertical pipe", + "description": "Long vertical pipe with fluid and leak visualizations.", + "searchTags": [ + "pipe", + "vertical pipe" + ], + "widgetSizeX": 1, + "widgetSizeY": 2, + "tags": [ + { + "tag": "fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nvar flow = ctx.values.flow;\nvar flowDirection = ctx.values.flowDirection;\n\nvar elementFluid = element.remember('fluid');\nvar elementFlow = null;\nvar elementFlowDirection = null;\n\nif (fluid !== elementFluid) {\n element.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n} else {\n elementFlow = element.remember('flow');\n elementFlowDirection = element.remember('flowDirection');\n}\n\nvar liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\nif (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n} else {\n liquidPattern = element.reference('fill');\n}\n\nif (fluid) {\n element.show();\n if (flow !== elementFlow) {\n element.remember('flow', flow);\n if (flow) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n } else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(ctx.values.flowAnimationSpeed);\n }\n} else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n element.hide();\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse) {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n }\n}", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + } + ], + "behavior": [ + { + "id": "fluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/pool.svg b/application/src/main/data/json/system/scada_symbols/pool.svg new file mode 100644 index 0000000000..a0eab2be0b --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/pool.svg @@ -0,0 +1,1056 @@ + +{ + "title": "Pool", + "description": "Pool with current volume value and level visualizations.", + "searchTags": [ + "pool" + ], + "widgetSizeX": 12, + "widgetSizeY": 4, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.transparent) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 590});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*740; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 590 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.transparent) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*740; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "#F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/right-drain-pipe.svg b/application/src/main/data/json/system/scada_symbols/right-drain-pipe.svg new file mode 100644 index 0000000000..3fe3501e16 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/right-drain-pipe.svg @@ -0,0 +1,167 @@ +{ + "title": "Right drain pipe", + "description": "Right drain pipe", + "searchTags": [ + "pipe", + "drain" + ], + "widgetSizeX": 2, + "widgetSizeY": 1, + "tags": [ + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var color = ctx.properties.fluidColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + } + ], + "behavior": [ + { + "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", + "openInSeparateDialog": false, + "openInPopover": false + } + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "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": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#8CAA5C", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/right-elbow-drain-pipe.svg b/application/src/main/data/json/system/scada_symbols/right-elbow-drain-pipe.svg new file mode 100644 index 0000000000..14ede98e81 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/right-elbow-drain-pipe.svg @@ -0,0 +1,178 @@ + +{ + "title": "Right elbow drain pipe", + "description": "Right elbow drain pipe", + "searchTags": [ + "pipe", + "drain" + ], + "widgetSizeX": 2, + "widgetSizeY": 1, + "tags": [ + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var color = ctx.properties.fluidColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + } + ], + "behavior": [ + { + "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", + "openInSeparateDialog": false, + "openInPopover": false + } + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "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": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#8CAA5C", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/right-flow-meter.svg b/application/src/main/data/json/system/scada_symbols/right-flow-meter.svg new file mode 100644 index 0000000000..e5df51e561 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/right-flow-meter.svg @@ -0,0 +1,964 @@ + +{ + "title": "Right flow meter", + "description": "Right flow meter component used to display flow related value and render various states. Includes pipe fluid and leak visualizations.", + "searchTags": [ + "meter", + "flow meter" + ], + "widgetSizeX": 2, + "widgetSizeY": 2, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var value = ctx.values.value;\nvar colorProcessor = ctx.properties.backgroundColor;\ncolorProcessor.update(value);\nvar fill = colorProcessor.color;\nelement.attr({fill: fill});", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "border", + "stateRenderFunction": "var value = ctx.values.value;\nvar colorProcessor = ctx.properties.defaultBorderColor;\nif (ctx.values.critical) {\n colorProcessor = ctx.properties.criticalBorderColor;\n} else if (ctx.values.warning) {\n colorProcessor = ctx.properties.warningBorderColor;\n} else if (value) {\n colorProcessor = ctx.properties.activeBorderColor;\n}\ncolorProcessor.update(value);\nvar fill = colorProcessor.color;\nelement.attr({fill: fill});\n\nvar elementCriticalAnimation = element.remember('criticalAnimation');\nvar criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\nif (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n}", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "broken", + "stateRenderFunction": "var broken = ctx.values.broken;\nif (broken) {\n element.show();\n} else {\n element.hide();\n}", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nvar flow = ctx.values.flow;\nvar flowDirection = ctx.values.flowDirection;\n\nvar elementFluid = element.remember('fluid');\nvar elementFlow = null;\nvar elementFlowDirection = null;\n\nif (fluid !== elementFluid) {\n element.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n} else {\n elementFlow = element.remember('flow');\n elementFlowDirection = element.remember('flowDirection');\n}\n\nvar liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\nif (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n} else {\n liquidPattern = element.reference('fill');\n}\n\nif (fluid) {\n element.show();\n if (flow !== elementFlow) {\n element.remember('flow', flow);\n if (flow) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n } else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(ctx.values.flowAnimationSpeed);\n }\n} else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n element.hide();\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse) {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n }\n}", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "value", + "stateRenderFunction": "var value = ctx.values.value;\nctx.api.text(element, value.toFixed(0));\n", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "valueUnits", + "stateRenderFunction": "var units = ctx.properties.valueUnits;\nctx.api.text(element, units || '');\n", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + } + ], + "behavior": [ + { + "id": "value", + "name": "{i18n:scada.symbol.value}", + "hint": "{i18n:scada.symbol.flow-meter-value-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": 0, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "flowRate" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-state-animation-hint}", + "group": "{i18n:scada.symbol.display}", + "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": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "broken", + "name": "{i18n:scada.symbol.broken}", + "hint": "{i18n:scada.symbol.broken-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.broken}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "displayClick", + "name": "{i18n:scada.symbol.on-display-click}", + "hint": "{i18n:scada.symbol.on-display-click-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "widgetAction", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": null, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": { + "type": "doNothing", + "openInSeparateDialog": false, + "openInPopover": false + } + }, + { + "id": "fluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.units}", + "type": "units", + "default": "m³/hr", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "medium-width", + "min": null, + "max": null, + "step": null + }, + { + "id": "defaultBorderColor", + "name": "{i18n:scada.symbol.default-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#4A4848", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "activeBorderColor", + "name": "{i18n:scada.symbol.active-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#1C943E", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "warningBorderColor", + "name": "{i18n:scada.symbol.warning-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#FAA405", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "criticalBorderColor", + "name": "{i18n:scada.symbol.critical-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#D12730", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "backgroundColor", + "name": "{i18n:scada.symbol.background-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#FFFFFF", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0m³/hr + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/right-motor-pump.svg b/application/src/main/data/json/system/scada_symbols/right-motor-pump.svg new file mode 100644 index 0000000000..7ada9a91e3 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/right-motor-pump.svg @@ -0,0 +1,526 @@ +{ + "title": "Right motor pump", + "description": "Right motor pump with configurable states.", + "searchTags": [ + "motor", + "pump" + ], + "widgetSizeX": 4, + "widgetSizeY": 3, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.stoppedColor;\nif (ctx.values.critical) {\n color = ctx.properties.criticalColor;\n} else if (ctx.values.warning) {\n color = ctx.properties.warningColor;\n} else if (ctx.values.running) {\n color = ctx.properties.runningColor;\n}\nelement.attr({fill: color});\n\nvar elementCriticalAnimation = element.remember('criticalAnimation');\nvar criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\nif (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n}", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + } + ], + "behavior": [ + { + "id": "running", + "name": "{i18n:scada.symbol.running}", + "hint": "{i18n:scada.symbol.running-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.running}", + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SHARED_SCOPE", + "key": "running" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "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", + "openInSeparateDialog": false, + "openInPopover": false + } + } + ], + "properties": [ + { + "id": "runningColor", + "name": "{i18n:scada.symbol.running-color}", + "type": "color", + "default": "#1C943E", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "stoppedColor", + "name": "{i18n:scada.symbol.stopped-color}", + "type": "color", + "default": "#696969", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "warningColor", + "name": "{i18n:scada.symbol.warning-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "criticalColor", + "name": "{i18n:scada.symbol.critical-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/right-tee-pipe.svg b/application/src/main/data/json/system/scada_symbols/right-tee-pipe.svg new file mode 100644 index 0000000000..4c84999b1f --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/right-tee-pipe.svg @@ -0,0 +1,854 @@ + +{ + "title": "Right tee pipe", + "description": "Right tee pipe with configurable right/top/bottom fluid and leak visualizations.", + "searchTags": [ + "pipe", + "tee" + ], + "widgetSizeX": 1, + "widgetSizeY": 1, + "stateRenderFunction": "var rightLiquidPattern = prepareLiquidPattern('right-liquid', 'right-fluid');\nvar topLiquidPattern = prepareLiquidPattern('top-liquid', 'top-fluid');\nvar bottomLiquidPattern = prepareLiquidPattern('bottom-liquid', 'bottom-fluid');\n\nvar genericLiquidPattern = ctx.svg.defs().findOne('pattern#liquid');\nif (genericLiquidPattern) {\n genericLiquidPattern.id(ctx.api.generateElementId());\n rightLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n topLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n bottomLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n}\n\nupdateLiquidPatternAnimation(rightLiquidPattern, 'right');\nupdateLiquidPatternAnimation(topLiquidPattern, 'top');\nupdateLiquidPatternAnimation(bottomLiquidPattern, 'bottom');\n\n\nfunction prepareLiquidPattern(patternId, fluidElementTag) {\n var liquidPattern = ctx.svg.defs().findOne('pattern#'+patternId);\n var fluidElements = ctx.tags[fluidElementTag];\n\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n for (var i=0;i<fluidElements.length;i++) {\n fluidElements[i].fill(liquidPattern);\n }\n } else {\n liquidPattern = fluidElements[0].reference('fill');\n }\n return liquidPattern;\n}\n\nfunction updateLiquidPatternAnimation(liquidPattern, prefix) {\n if (liquidPattern) {\n var fluid = ctx.values[prefix + 'Fluid'] && !ctx.values.leak;\n var flow = ctx.values[prefix + 'Flow'];\n var flowDirection = ctx.values[prefix + 'FlowDirection'];\n var flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n var elementFluid = liquidPattern.remember('fluid');\n var elementFlow = null;\n var elementFlowDirection = null;\n \n if (fluid !== elementFluid) {\n liquidPattern.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n } else {\n elementFlow = liquidPattern.remember('flow');\n elementFlowDirection = liquidPattern.remember('flowDirection');\n }\n \n if (fluid) {\n if (flow !== elementFlow) {\n liquidPattern.remember('flow', flow);\n if (flow) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(flowAnimationSpeed);\n }\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n }\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n}\n\n", + "tags": [ + { + "tag": "bottom-fluid", + "stateRenderFunction": "var fluid = ctx.values.bottomFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "bottom-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.bottomFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.bottomFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "overlay", + "stateRenderFunction": "var fluid = (ctx.values.rightFluid ||\n ctx.values.topFluid || ctx.values.bottomFluid) && !ctx.values.leak;\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "right-fluid", + "stateRenderFunction": "var fluid = ctx.values.rightFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "right-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.rightFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.rightFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-fluid", + "stateRenderFunction": "var fluid = ctx.values.topFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.topFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.topFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "rightFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.bottom-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "rightFluidColor", + "name": "{i18n:scada.symbol.right-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "topFluidColor", + "name": "{i18n:scada.symbol.top-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "bottomFluidColor", + "name": "{i18n:scada.symbol.bottom-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/short-bottom-filter.svg b/application/src/main/data/json/system/scada_symbols/short-bottom-filter.svg new file mode 100644 index 0000000000..b3235c5906 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/short-bottom-filter.svg @@ -0,0 +1,88 @@ + +{ + "title": "Short bottom filter", + "description": "Short bottom filter", + "searchTags": [ + "filter" + ], + "widgetSizeX": 1, + "widgetSizeY": 2, + "tags": [ + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + } + ], + "behavior": [ + { + "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", + "openInSeparateDialog": false, + "openInPopover": false + } + } + ], + "properties": [] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/short-left-drain-pipe.svg b/application/src/main/data/json/system/scada_symbols/short-left-drain-pipe.svg new file mode 100644 index 0000000000..ffe74a681e --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/short-left-drain-pipe.svg @@ -0,0 +1,160 @@ + + { + "title": "Short left drain pipe", + "description": "Short left drain pipe", + "searchTags": [ + "pipe", + "drain" + ], + "widgetSizeX": 1, + "widgetSizeY": 1, + "tags": [ + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var color = ctx.properties.fluidColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + } + ], + "behavior": [ + { + "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", + "openInSeparateDialog": false, + "openInPopover": false + } + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "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": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#8CAA5C", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/short-right-drain-pipe.svg b/application/src/main/data/json/system/scada_symbols/short-right-drain-pipe.svg new file mode 100644 index 0000000000..306898e3ae --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/short-right-drain-pipe.svg @@ -0,0 +1,160 @@ + + { + "title": "Short right drain pipe", + "description": "Short right drain pipe", + "searchTags": [ + "pipe", + "drain" + ], + "widgetSizeX": 1, + "widgetSizeY": 1, + "tags": [ + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var color = ctx.properties.fluidColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + } + ], + "behavior": [ + { + "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", + "openInSeparateDialog": false, + "openInPopover": false + } + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "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": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#8CAA5C", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/short-top-filter.svg b/application/src/main/data/json/system/scada_symbols/short-top-filter.svg new file mode 100644 index 0000000000..d50eada2aa --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/short-top-filter.svg @@ -0,0 +1,88 @@ + +{ + "title": "Short top filter", + "description": "Short top filter", + "searchTags": [ + "filter" + ], + "widgetSizeX": 1, + "widgetSizeY": 2, + "tags": [ + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + } + ], + "behavior": [ + { + "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", + "openInSeparateDialog": false, + "openInPopover": false + } + } + ], + "properties": [] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/small-left-motor-pump.svg b/application/src/main/data/json/system/scada_symbols/small-left-motor-pump.svg new file mode 100644 index 0000000000..85e617ff22 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/small-left-motor-pump.svg @@ -0,0 +1,428 @@ +{ + "title": "Small left motor pump", + "description": "Small left motor pump with configurable states.", + "searchTags": [ + "motor", + "pump" + ], + "widgetSizeX": 3, + "widgetSizeY": 2, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.stoppedColor;\nif (ctx.values.critical) {\n color = ctx.properties.criticalColor;\n} else if (ctx.values.warning) {\n color = ctx.properties.warningColor;\n} else if (ctx.values.running) {\n color = ctx.properties.runningColor;\n}\nelement.attr({fill: color});\n\nvar elementCriticalAnimation = element.remember('criticalAnimation');\nvar criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\nif (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n}", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + } + ], + "behavior": [ + { + "id": "running", + "name": "{i18n:scada.symbol.running}", + "hint": "{i18n:scada.symbol.running-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.running}", + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SHARED_SCOPE", + "key": "running" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "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", + "openInSeparateDialog": false, + "openInPopover": false + } + } + ], + "properties": [ + { + "id": "runningColor", + "name": "{i18n:scada.symbol.running-color}", + "type": "color", + "default": "#1C943E", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "stoppedColor", + "name": "{i18n:scada.symbol.stopped-color}", + "type": "color", + "default": "#696969", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "warningColor", + "name": "{i18n:scada.symbol.warning-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "criticalColor", + "name": "{i18n:scada.symbol.critical-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/small-right-motor-pump.svg b/application/src/main/data/json/system/scada_symbols/small-right-motor-pump.svg new file mode 100644 index 0000000000..7b9b1c260d --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/small-right-motor-pump.svg @@ -0,0 +1,428 @@ + +{ + "title": "Small right motor pump", + "description": "Small right motor pump with configurable states.", + "searchTags": [ + "motor", + "pump" + ], + "widgetSizeX": 3, + "widgetSizeY": 2, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.stoppedColor;\nif (ctx.values.critical) {\n color = ctx.properties.criticalColor;\n} else if (ctx.values.warning) {\n color = ctx.properties.warningColor;\n} else if (ctx.values.running) {\n color = ctx.properties.runningColor;\n}\nelement.attr({fill: color});\n\nvar elementCriticalAnimation = element.remember('criticalAnimation');\nvar criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\nif (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n}", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + } + ], + "behavior": [ + { + "id": "running", + "name": "{i18n:scada.symbol.running}", + "hint": "{i18n:scada.symbol.running-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.running}", + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SHARED_SCOPE", + "key": "running" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "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", + "openInSeparateDialog": false, + "openInPopover": false + } + } + ], + "properties": [ + { + "id": "runningColor", + "name": "{i18n:scada.symbol.running-color}", + "type": "color", + "default": "#1C943E", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "stoppedColor", + "name": "{i18n:scada.symbol.stopped-color}", + "type": "color", + "default": "#696969", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "warningColor", + "name": "{i18n:scada.symbol.warning-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "criticalColor", + "name": "{i18n:scada.symbol.critical-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/small-spherical-tank.svg b/application/src/main/data/json/system/scada_symbols/small-spherical-tank.svg new file mode 100644 index 0000000000..b42fd2ab5b --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/small-spherical-tank.svg @@ -0,0 +1,1426 @@ + +{ + "title": "Small spherical tank", + "description": "Small spherical tank with current volume value and level visualizations.", + "searchTags": [ + "tank", + "stand tank" + ], + "widgetSizeX": 3, + "widgetSizeY": 3, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 590});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*560; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 590 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*560; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "scale", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 23;\n var majorIntervalLength = 560 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(268, y, 300, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 258, y: y + 2, 'text-anchor': 'end', 'dominant-baseline': 'middle', class: 'majorTickText'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(280, minorY, 300, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent || !ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "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 + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "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 + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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 + }, + { + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "#F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "scale", + "name": "{i18n:scada.symbol.scale}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorIntervals", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": 1 + }, + { + "id": "majorFont", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "font", + "default": { + "size": 24, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#00000061", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorWarningColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorCriticalColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorIntervals", + "name": "{i18n:scada.symbol.minor-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": null + }, + { + "id": "minorColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#0000001F", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorWarningColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorCriticalColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/spherical-tank.svg b/application/src/main/data/json/system/scada_symbols/spherical-tank.svg new file mode 100644 index 0000000000..cf11b61bba --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/spherical-tank.svg @@ -0,0 +1,1456 @@ + +{ + "title": "Spherical tank", + "description": "Spherical tank with current volume value and level visualizations.", + "searchTags": [ + "tank", + "stand tank" + ], + "widgetSizeX": 5, + "widgetSizeY": 5, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 590});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*960; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 590 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*960; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "scale", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 23;\n var majorIntervalLength = 960 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(458, y, 490, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 448, y: y + 2, 'text-anchor': 'end', 'dominant-baseline': 'middle', class: 'majorTickText'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(470, minorY, 490, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent || !ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "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 + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "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 + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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 + }, + { + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "#F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "scale", + "name": "{i18n:scada.symbol.scale}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorIntervals", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "number", + "default": 10, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": 1 + }, + { + "id": "majorFont", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "font", + "default": { + "size": 24, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#00000061", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorWarningColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorCriticalColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorIntervals", + "name": "{i18n:scada.symbol.minor-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": null + }, + { + "id": "minorColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#0000001F", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorWarningColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorCriticalColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/stand-cylindrical-tank.svg b/application/src/main/data/json/system/scada_symbols/stand-cylindrical-tank.svg new file mode 100644 index 0000000000..f9948bc64b --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/stand-cylindrical-tank.svg @@ -0,0 +1,1462 @@ + +{ + "title": "Stand cylindrical tank", + "description": "Stand cylindrical tank with current volume value and level visualizations.", + "searchTags": [ + "tank", + "stand tank" + ], + "widgetSizeX": 3, + "widgetSizeY": 6, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 590});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*900; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 590 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*900; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "scale", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 205;\n var majorIntervalLength = 760 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(340, y, 372, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', 'dominant-baseline': 'middle', class: 'majorTickText'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(352, minorY, 372, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "actions": null + }, + { + "tag": "scale-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent || !ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "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 + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "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 + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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 + }, + { + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "#F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "scale", + "name": "{i18n:scada.symbol.scale}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorIntervals", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "number", + "default": 10, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": 1 + }, + { + "id": "majorFont", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "font", + "default": { + "size": 24, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#00000061", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorWarningColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorCriticalColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorIntervals", + "name": "{i18n:scada.symbol.minor-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": null + }, + { + "id": "minorColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#0000001F", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorWarningColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorCriticalColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/stand-horizontal-tank.svg b/application/src/main/data/json/system/scada_symbols/stand-horizontal-tank.svg new file mode 100644 index 0000000000..9adfa3fb82 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/stand-horizontal-tank.svg @@ -0,0 +1,1468 @@ + +{ + "title": "Stand horizontal tank", + "description": "Stand horizontal tank with current volume value and level visualizations.", + "searchTags": [ + "tank", + "stand tank" + ], + "widgetSizeX": 5, + "widgetSizeY": 4, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 590});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*568; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 590 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*568; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "scale", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 17;\n var majorIntervalLength = 568 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(715, y, 747, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 705, y: y + 2, 'text-anchor': 'end', 'dominant-baseline': 'middle', class: 'majorTickText'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(727, minorY, 747, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent || !ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "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 + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "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 + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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 + }, + { + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "#F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "scale", + "name": "{i18n:scada.symbol.scale}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorIntervals", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "number", + "default": 10, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": 1 + }, + { + "id": "majorFont", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "font", + "default": { + "size": 24, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#00000061", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorWarningColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorCriticalColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorIntervals", + "name": "{i18n:scada.symbol.minor-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": null + }, + { + "id": "minorColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#0000001F", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorWarningColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorCriticalColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/stand-vertical-short-tank.svg b/application/src/main/data/json/system/scada_symbols/stand-vertical-short-tank.svg new file mode 100644 index 0000000000..5c181e46d9 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/stand-vertical-short-tank.svg @@ -0,0 +1,1435 @@ + +{ + "title": "Stand vertical short tank", + "description": "Stand vertical short tank with current volume value and level visualizations.", + "searchTags": [ + "tank", + "short tank", + "stand tank" + ], + "widgetSizeX": 4, + "widgetSizeY": 4, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 245});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*442; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 245 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*442; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "scale", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 137;\n var majorIntervalLength = 442 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(523, y, 555, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 513, y: y + 2, 'text-anchor': 'end', 'dominant-baseline': 'middle', class: 'majorTickText'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(535, minorY, 555, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "actions": null + }, + { + "tag": "scale-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent || !ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "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 + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "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 + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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 + }, + { + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "#F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "scale", + "name": "{i18n:scada.symbol.scale}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorIntervals", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": 1 + }, + { + "id": "majorFont", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "font", + "default": { + "size": 24, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#00000061", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorWarningColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorCriticalColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorIntervals", + "name": "{i18n:scada.symbol.minor-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": null + }, + { + "id": "minorColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#0000001F", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorWarningColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorCriticalColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/stand-vertical-tank.svg b/application/src/main/data/json/system/scada_symbols/stand-vertical-tank.svg new file mode 100644 index 0000000000..e7d14a9f23 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/stand-vertical-tank.svg @@ -0,0 +1,1455 @@ + +{ + "title": "Stand vertical tank", + "description": "Stand vertical tank with current volume value and level visualizations.", + "searchTags": [ + "tank", + "stand tank" + ], + "widgetSizeX": 3, + "widgetSizeY": 6, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 590});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*765; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 590 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*765; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "scale", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 205;\n var majorIntervalLength = 760 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(340, y, 372, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', 'dominant-baseline': 'middle', class: 'majorTickText'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(352, minorY, 372, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "actions": null + }, + { + "tag": "scale-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent || !ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "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 + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "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 + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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 + }, + { + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "#F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "scale", + "name": "{i18n:scada.symbol.scale}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorIntervals", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "number", + "default": 10, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": 1 + }, + { + "id": "majorFont", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "font", + "default": { + "size": 24, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#00000061", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorWarningColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorCriticalColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorIntervals", + "name": "{i18n:scada.symbol.minor-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": null + }, + { + "id": "minorColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#0000001F", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorWarningColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorCriticalColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/top-flow-meter.svg b/application/src/main/data/json/system/scada_symbols/top-flow-meter.svg new file mode 100644 index 0000000000..bac39d98dc --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/top-flow-meter.svg @@ -0,0 +1,965 @@ + +{ + "title": "Top flow meter", + "description": "Top flow meter component used to display flow related value and render various states. Includes pipe fluid and leak visualizations.", + "searchTags": [ + "meter", + "flow meter" + ], + "widgetSizeX": 2, + "widgetSizeY": 2, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var value = ctx.values.value;\nvar colorProcessor = ctx.properties.backgroundColor;\ncolorProcessor.update(value);\nvar fill = colorProcessor.color;\nelement.attr({fill: fill});", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "border", + "stateRenderFunction": "var value = ctx.values.value;\nvar colorProcessor = ctx.properties.defaultBorderColor;\nif (ctx.values.critical) {\n colorProcessor = ctx.properties.criticalBorderColor;\n} else if (ctx.values.warning) {\n colorProcessor = ctx.properties.warningBorderColor;\n} else if (value) {\n colorProcessor = ctx.properties.activeBorderColor;\n}\ncolorProcessor.update(value);\nvar fill = colorProcessor.color;\nelement.attr({fill: fill});\n\nvar elementCriticalAnimation = element.remember('criticalAnimation');\nvar criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\nif (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n}", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "broken", + "stateRenderFunction": "var broken = ctx.values.broken;\nif (broken) {\n element.show();\n} else {\n element.hide();\n}", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nvar flow = ctx.values.flow;\nvar flowDirection = ctx.values.flowDirection;\n\nvar elementFluid = element.remember('fluid');\nvar elementFlow = null;\nvar elementFlowDirection = null;\n\nif (fluid !== elementFluid) {\n element.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n} else {\n elementFlow = element.remember('flow');\n elementFlowDirection = element.remember('flowDirection');\n}\n\nvar liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\nif (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n} else {\n liquidPattern = element.reference('fill');\n}\n\nif (fluid) {\n element.show();\n if (flow !== elementFlow) {\n element.remember('flow', flow);\n if (flow) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n } else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(ctx.values.flowAnimationSpeed);\n }\n} else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n element.hide();\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse) {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n }\n}", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "value", + "stateRenderFunction": "var value = ctx.values.value;\nctx.api.text(element, value.toFixed(0));\n", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "valueUnits", + "stateRenderFunction": "var units = ctx.properties.valueUnits;\nctx.api.text(element, units || '');\n", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + } + ], + "behavior": [ + { + "id": "value", + "name": "{i18n:scada.symbol.value}", + "hint": "{i18n:scada.symbol.flow-meter-value-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": 0, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "flowRate" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-state-animation-hint}", + "group": "{i18n:scada.symbol.display}", + "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": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "broken", + "name": "{i18n:scada.symbol.broken}", + "hint": "{i18n:scada.symbol.broken-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.broken}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "displayClick", + "name": "{i18n:scada.symbol.on-display-click}", + "hint": "{i18n:scada.symbol.on-display-click-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "widgetAction", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": null, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": { + "type": "doNothing", + "openInSeparateDialog": false, + "openInPopover": false + } + }, + { + "id": "fluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.units}", + "type": "units", + "default": "m³/hr", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "medium-width", + "min": null, + "max": null, + "step": null + }, + { + "id": "defaultBorderColor", + "name": "{i18n:scada.symbol.default-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#4A4848", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "activeBorderColor", + "name": "{i18n:scada.symbol.active-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#1C943E", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "warningBorderColor", + "name": "{i18n:scada.symbol.warning-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#FAA405", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "criticalBorderColor", + "name": "{i18n:scada.symbol.critical-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#D12730", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "backgroundColor", + "name": "{i18n:scada.symbol.background-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#FFFFFF", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0m³/hr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/top-right-elbow-pipe.svg b/application/src/main/data/json/system/scada_symbols/top-right-elbow-pipe.svg new file mode 100644 index 0000000000..5537c23bb6 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/top-right-elbow-pipe.svg @@ -0,0 +1,558 @@ +{ + "title": "Top right elbow pipe", + "description": "Top right elbow pipe with fluid and leak visualizations.", + "searchTags": [ + "pipe", + "elbow" + ], + "widgetSizeX": 1, + "widgetSizeY": 1, + "stateRenderFunction": "var centerLiquidPattern = prepareLiquidPattern('center-liquid', 'center-fluid-background');\nvar horizontalLiquidPattern = prepareLiquidPattern('horizontal-liquid', 'horizontal-fluid');\n\ncenterLiquidPattern.attr({'xlink:href': '#'+horizontalLiquidPattern.id()});\n\nvar verticalLiquidPattern = prepareLiquidPattern('vertical-liquid', 'vertical-fluid');\n\nvar fluid = ctx.values.fluid && !ctx.values.leak;\nvar flow = ctx.values.flow;\nvar flowDirection = ctx.values.flowDirection;\nvar flowAnimationSpeed = ctx.values.flowAnimationSpeed;\n \nif (horizontalLiquidPattern) {\n updateLiquidPatternAnimation(horizontalLiquidPattern, fluid, \n flow, flowDirection, flowAnimationSpeed, false);\n}\n\nif (verticalLiquidPattern) {\n updateLiquidPatternAnimation(verticalLiquidPattern, fluid, \n flow, flowDirection, flowAnimationSpeed, false);\n}\n\nif (centerLiquidPattern) {\n updateLiquidPatternAnimation(centerLiquidPattern, fluid, \n flow, flowDirection, flowAnimationSpeed, true);\n}\n\nfunction prepareLiquidPattern(patternId, fluidElementTag) {\n var liquidPattern = ctx.svg.defs().findOne('pattern#'+patternId);\n var fluidElements = ctx.tags[fluidElementTag];\n\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n for (var i=0;i<fluidElements.length;i++) {\n fluidElements[i].fill(liquidPattern);\n }\n } else {\n liquidPattern = fluidElements[0].reference('fill');\n }\n return liquidPattern;\n}\n\nfunction updateLiquidPatternAnimation(liquidPattern, fluid, flow, flowDirection, flowAnimationSpeed, center) {\n var elementFluid = liquidPattern.remember('fluid');\n var elementFlow = null;\n var elementFlowDirection = null;\n \n if (fluid !== elementFluid) {\n liquidPattern.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n } else {\n elementFlow = liquidPattern.remember('flow');\n elementFlowDirection = liquidPattern.remember('flowDirection');\n }\n \n if (fluid) {\n if (flow !== elementFlow) {\n liquidPattern.remember('flow', flow);\n if (flow) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection, center);\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection, center);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(flowAnimationSpeed);\n }\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse, center) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n if (center) {\n var delta = deltaX * 1.17 * Math.cos(45*Math.PI/180);\n liquidPattern.animate(1000).ease('-').relative(delta, -delta).loop();\n } else {\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n }\n}\n\n", + "tags": [ + { + "tag": "center-fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "horizontal-fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}\n", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "vertical-fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}\n", + "actions": null + } + ], + "behavior": [ + { + "id": "fluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/top-tee-pipe.svg b/application/src/main/data/json/system/scada_symbols/top-tee-pipe.svg new file mode 100644 index 0000000000..1dfa27e706 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/top-tee-pipe.svg @@ -0,0 +1,854 @@ + +{ + "title": "Top tee pipe", + "description": "Top tee pipe with configurable left/right/top fluid and leak visualizations.", + "searchTags": [ + "pipe", + "tee" + ], + "widgetSizeX": 1, + "widgetSizeY": 1, + "stateRenderFunction": "var leftLiquidPattern = prepareLiquidPattern('left-liquid', 'left-fluid');\nvar rightLiquidPattern = prepareLiquidPattern('right-liquid', 'right-fluid');\nvar topLiquidPattern = prepareLiquidPattern('top-liquid', 'top-fluid');\n\nvar genericLiquidPattern = ctx.svg.defs().findOne('pattern#liquid');\nif (genericLiquidPattern) {\n genericLiquidPattern.id(ctx.api.generateElementId());\n leftLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n rightLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n topLiquidPattern.attr({'xlink:href': '#'+genericLiquidPattern.id()});\n}\n\nupdateLiquidPatternAnimation(leftLiquidPattern, 'left');\nupdateLiquidPatternAnimation(rightLiquidPattern, 'right');\nupdateLiquidPatternAnimation(topLiquidPattern, 'top');\n\n\nfunction prepareLiquidPattern(patternId, fluidElementTag) {\n var liquidPattern = ctx.svg.defs().findOne('pattern#'+patternId);\n var fluidElements = ctx.tags[fluidElementTag];\n\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n for (var i=0;i<fluidElements.length;i++) {\n fluidElements[i].fill(liquidPattern);\n }\n } else {\n liquidPattern = fluidElements[0].reference('fill');\n }\n return liquidPattern;\n}\n\nfunction updateLiquidPatternAnimation(liquidPattern, prefix) {\n if (liquidPattern) {\n var fluid = ctx.values[prefix + 'Fluid'] && !ctx.values.leak;\n var flow = ctx.values[prefix + 'Flow'];\n var flowDirection = ctx.values[prefix + 'FlowDirection'];\n var flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n var elementFluid = liquidPattern.remember('fluid');\n var elementFlow = null;\n var elementFlowDirection = null;\n \n if (fluid !== elementFluid) {\n liquidPattern.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n } else {\n elementFlow = liquidPattern.remember('flow');\n elementFlowDirection = liquidPattern.remember('flowDirection');\n }\n \n if (fluid) {\n if (flow !== elementFlow) {\n liquidPattern.remember('flow', flow);\n if (flow) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n liquidPattern.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(flowAnimationSpeed);\n }\n } else {\n ctx.api.resetAnimation(liquidPattern);\n }\n }\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n}\n\n", + "tags": [ + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "left-fluid", + "stateRenderFunction": "var fluid = ctx.values.leftFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "left-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.leftFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.leftFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "overlay", + "stateRenderFunction": "var fluid = (ctx.values.leftFluid || ctx.values.rightFluid ||\n ctx.values.topFluid) && !ctx.values.leak;\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "right-fluid", + "stateRenderFunction": "var fluid = ctx.values.rightFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "right-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.rightFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.rightFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-fluid", + "stateRenderFunction": "var fluid = ctx.values.topFluid && !ctx.values.leak;\n\nif (fluid) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-fluid-background", + "stateRenderFunction": "var fluid = ctx.values.topFluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.topFluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "leftFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.left-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.right-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.top-pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "leftFluidColor", + "name": "{i18n:scada.symbol.left-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "rightFluidColor", + "name": "{i18n:scada.symbol.right-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "topFluidColor", + "name": "{i18n:scada.symbol.top-fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/vertical-ball-valve.svg b/application/src/main/data/json/system/scada_symbols/vertical-ball-valve.svg new file mode 100644 index 0000000000..3aee266486 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/vertical-ball-valve.svg @@ -0,0 +1,271 @@ + +{ + "title": "Vertical ball valve", + "description": "Vertical ball valve with open/close animation and state colors.", + "searchTags": [ + "valve", + "ball" + ], + "widgetSizeX": 1, + "widgetSizeY": 1, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var opened = ctx.values.opened;\nvar openAnimate = element.remember('openAnimate');\nvar color = opened ? ctx.properties.openedColor : ctx.properties.closedColor;\nif (!openAnimate) {\n element.attr({fill: color});\n} else {\n ctx.api.animate(element, 500).attr({fill: color});\n element.remember('openAnimate', false);\n}\n", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": " ctx.tags.wheel.forEach(e => {\n e.remember('openAnimate', true);\n });\n ctx.tags.background.forEach(e => {\n e.remember('openAnimate', true);\n });\n\n\nvar 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": "wheel", + "stateRenderFunction": "var opened = ctx.values.opened;\nvar openAnimate = element.remember('openAnimate');\nvar angle = opened ? ctx.properties.openedRotationAngle : ctx.properties.closedRotationAngle;\nif (!openAnimate) {\n element.transform({rotate: angle, originY: 100});\n} else {\n ctx.api.animate(element, 500).transform({rotate: angle, originY: 100});\n element.remember('openAnimate', false);\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 + } + ], + "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": "#696969", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "openedRotationAngle", + "name": "{i18n:scada.symbol.opened-rotation-angle}", + "type": "number", + "default": 0, + "required": true, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": -179, + "max": 179, + "step": null + }, + { + "id": "closedRotationAngle", + "name": "{i18n:scada.symbol.closed-rotation-angle}", + "type": "number", + "default": 90, + "required": true, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": -179, + "max": 179, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/vertical-inline-flow-meter.svg b/application/src/main/data/json/system/scada_symbols/vertical-inline-flow-meter.svg new file mode 100644 index 0000000000..1c22c8d117 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/vertical-inline-flow-meter.svg @@ -0,0 +1,949 @@ + +{ + "title": "Vertical inline flow meter", + "description": "Vertical inline flow meter component used to display flow related value and render various states. Includes pipe fluid and leak visualizations.", + "searchTags": [ + "meter", + "flow meter" + ], + "widgetSizeX": 1, + "widgetSizeY": 2, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var value = ctx.values.value;\nvar colorProcessor = ctx.properties.backgroundColor;\ncolorProcessor.update(value);\nvar fill = colorProcessor.color;\nelement.attr({fill: fill});", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "border", + "stateRenderFunction": "var value = ctx.values.value;\nvar colorProcessor = ctx.properties.defaultBorderColor;\nif (ctx.values.critical) {\n colorProcessor = ctx.properties.criticalBorderColor;\n} else if (ctx.values.warning) {\n colorProcessor = ctx.properties.warningBorderColor;\n} else if (value) {\n colorProcessor = ctx.properties.activeBorderColor;\n}\ncolorProcessor.update(value);\nvar fill = colorProcessor.color;\nelement.attr({fill: fill});\n\nvar elementCriticalAnimation = element.remember('criticalAnimation');\nvar criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\nif (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n}", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "broken", + "stateRenderFunction": "var broken = ctx.values.broken;\nif (broken) {\n element.show();\n} else {\n element.hide();\n}", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nvar flow = ctx.values.flow;\nvar flowDirection = ctx.values.flowDirection;\n\nvar elementFluid = element.remember('fluid');\nvar elementFlow = null;\nvar elementFlowDirection = null;\n\nif (fluid !== elementFluid) {\n element.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n} else {\n elementFlow = element.remember('flow');\n elementFlowDirection = element.remember('flowDirection');\n}\n\nvar liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\nif (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n} else {\n liquidPattern = element.reference('fill');\n}\n\nif (fluid) {\n element.show();\n if (flow !== elementFlow) {\n element.remember('flow', flow);\n if (flow) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n } else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(ctx.values.flowAnimationSpeed);\n }\n} else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n element.hide();\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse) {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n }\n}", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "value", + "stateRenderFunction": "var value = ctx.values.value;\nctx.api.text(element, value.toFixed(0));\n", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + }, + { + "tag": "valueUnits", + "stateRenderFunction": "var units = ctx.properties.valueUnits;\nctx.api.text(element, units || '');\n", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'displayClick');" + } + } + } + ], + "behavior": [ + { + "id": "value", + "name": "{i18n:scada.symbol.value}", + "hint": "{i18n:scada.symbol.flow-meter-value-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": 0, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "flowRate" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-state-animation-hint}", + "group": "{i18n:scada.symbol.display}", + "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": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "broken", + "name": "{i18n:scada.symbol.broken}", + "hint": "{i18n:scada.symbol.broken-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.broken}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "displayClick", + "name": "{i18n:scada.symbol.on-display-click}", + "hint": "{i18n:scada.symbol.on-display-click-hint}", + "group": "{i18n:scada.symbol.display}", + "type": "widgetAction", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": null, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": { + "type": "doNothing", + "openInSeparateDialog": false, + "openInPopover": false + } + }, + { + "id": "fluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": "{i18n:scada.symbol.pipe}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.units}", + "type": "units", + "default": "m³/hr", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "medium-width", + "min": null, + "max": null, + "step": null + }, + { + "id": "defaultBorderColor", + "name": "{i18n:scada.symbol.default-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#4A4848", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "activeBorderColor", + "name": "{i18n:scada.symbol.active-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#1C943E", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "warningBorderColor", + "name": "{i18n:scada.symbol.warning-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#FAA405", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "criticalBorderColor", + "name": "{i18n:scada.symbol.critical-border-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#D12730", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "backgroundColor", + "name": "{i18n:scada.symbol.background-color}", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#FFFFFF", + "gradient": { + "advancedMode": false, + "gradient": [ + "rgba(0, 255, 0, 1)", + "rgba(255, 0, 0, 1)" + ], + "gradientAdvanced": [ + { + "source": { + "type": "constant" + }, + "color": "rgba(0, 255, 0, 1)" + }, + { + "source": { + "type": "constant" + }, + "color": "rgba(255, 0, 0, 1)" + } + ], + "minValue": 0, + "maxValue": 100 + }, + "rangeList": { + "advancedMode": false, + "range": [], + "rangeAdvanced": [] + }, + "colorFunction": "var temperature = value;\nif (typeof temperature !== undefined) {\n var percent = (temperature + 60)/120 * 100;\n return tinycolor.mix('blue', 'red', percent).toHexString();\n}\nreturn 'blue';" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0m³/hr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/vertical-pipe.svg b/application/src/main/data/json/system/scada_symbols/vertical-pipe.svg new file mode 100644 index 0000000000..18ea6837e0 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/vertical-pipe.svg @@ -0,0 +1,422 @@ + +{ + "title": "Vertical pipe", + "description": "Vertical pipe with fluid and leak visualizations.", + "searchTags": [ + "pipe", + "vertical pipe" + ], + "widgetSizeX": 1, + "widgetSizeY": 1, + "tags": [ + { + "tag": "fluid", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nvar flow = ctx.values.flow;\nvar flowDirection = ctx.values.flowDirection;\n\nvar elementFluid = element.remember('fluid');\nvar elementFlow = null;\nvar elementFlowDirection = null;\n\nif (fluid !== elementFluid) {\n element.remember('fluid', fluid);\n elementFlow = null;\n elementFlowDirection = null;\n} else {\n elementFlow = element.remember('flow');\n elementFlowDirection = element.remember('flowDirection');\n}\n\nvar liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\nif (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n} else {\n liquidPattern = element.reference('fill');\n}\n\nif (fluid) {\n element.show();\n if (flow !== elementFlow) {\n element.remember('flow', flow);\n if (flow) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n } else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n }\n } else if (flow && elementFlowDirection !== flowDirection) {\n element.remember('flowDirection', flowDirection);\n animateFlow(liquidPattern, flowDirection);\n }\n if (flow && liquidPattern) {\n liquidPattern.timeline().speed(ctx.values.flowAnimationSpeed);\n }\n} else {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n }\n element.hide();\n}\n\nfunction animateFlow(liquidPattern, forwardElseReverse) {\n if (liquidPattern) {\n ctx.api.resetAnimation(liquidPattern);\n var deltaX = forwardElseReverse ? 172 : -172;\n liquidPattern.animate(1000).ease('-').relative(deltaX, 0).loop();\n }\n}", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "var fluid = ctx.values.fluid && !ctx.values.leak;\nif (fluid) {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color});\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "leak", + "stateRenderFunction": "var leak = ctx.values.leak;\nif (leak) {\n element.show();\n} else {\n element.hide();\n}", + "actions": null + }, + { + "tag": "pipe-background", + "stateRenderFunction": "var color = ctx.properties.pipeColor;\nelement.attr({fill: color});", + "actions": null + } + ], + "behavior": [ + { + "id": "fluid", + "name": "{i18n:scada.symbol.fluid-presence}", + "hint": "{i18n:scada.symbol.fluid-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flow", + "name": "{i18n:scada.symbol.flow-presence}", + "hint": "{i18n:scada.symbol.flow-presence-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leak", + "name": "{i18n:scada.symbol.leak}", + "hint": "{i18n:scada.symbol.leak-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.leak-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], + "properties": [ + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "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 + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/vertical-short-tank.svg b/application/src/main/data/json/system/scada_symbols/vertical-short-tank.svg new file mode 100644 index 0000000000..662bdd1bb2 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/vertical-short-tank.svg @@ -0,0 +1,1374 @@ + +{ + "title": "Vertical short tank", + "description": "Vertical short tank with current volume value and level visualizations.", + "searchTags": [ + "tank", + "short tank" + ], + "widgetSizeX": 4, + "widgetSizeY": 3, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 245});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*442; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 245 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*442; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "scale", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 137;\n var majorIntervalLength = 442 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(523, y, 555, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 513, y: y + 2, 'text-anchor': 'end', 'dominant-baseline': 'middle', class: 'majorTickText'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(535, minorY, 555, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "actions": null + }, + { + "tag": "scale-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent || !ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "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 + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "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 + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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 + }, + { + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "#F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "scale", + "name": "{i18n:scada.symbol.scale}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorIntervals", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": 1 + }, + { + "id": "majorFont", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "font", + "default": { + "size": 24, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#00000061", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorWarningColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorCriticalColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorIntervals", + "name": "{i18n:scada.symbol.minor-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": null + }, + { + "id": "minorColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#0000001F", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorWarningColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorCriticalColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/vertical-tank.svg b/application/src/main/data/json/system/scada_symbols/vertical-tank.svg new file mode 100644 index 0000000000..258cb590d0 --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/vertical-tank.svg @@ -0,0 +1,1394 @@ + +{ + "title": "Vertical tank", + "description": "Vertical tank with current volume value and level visualizations.", + "searchTags": [ + "tank" + ], + "widgetSizeX": 3, + "widgetSizeY": 5, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var color = ctx.properties.tankColor;\nelement.attr({fill: color});", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'click');" + } + } + }, + { + "tag": "fluid", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var liquidPattern = ctx.svg.defs().findOne('pattern#liquid');\n if (liquidPattern) {\n liquidPattern.id(ctx.api.generateElementId());\n element.fill(liquidPattern);\n } else {\n liquidPattern = element.reference('fill');\n }\n\n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n liquidPattern.transform({translateY: 590});\n }\n\n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*765; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n liquidPattern.animate(500)\n .transform({ translateY: 590 + height });\n }\n}\n", + "actions": null + }, + { + "tag": "fluid-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var color = ctx.properties.fluidColor;\n element.attr({fill: color, 'fill-opacity': 1});\n \n var valueSet = element.remember('valueSet');\n if (!valueSet) {\n element.remember('valueSet', true);\n element.attr({height: 0});\n }\n \n var currentVolume = ctx.values.currentVolume; \n var tankCapacity = ctx.values.tankCapacity; \n\n var height = currentVolume / tankCapacity;\n height = Math.max(0, Math.min(1, height))*765; \n \n var elementHeight = element.remember('height');\n if (height !== elementHeight) {\n element.remember('height', height);\n ctx.api.animate(element, 500).attr({height: height});\n }\n}\n", + "actions": null + }, + { + "tag": "scale", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 205;\n var majorIntervalLength = 760 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(340, y, 372, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', 'dominant-baseline': 'middle', class: 'majorTickText'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.animate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(352, minorY, 372, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "actions": null + }, + { + "tag": "scale-background", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "top-layer", + "stateRenderFunction": "if (ctx.properties.transparent || !ctx.properties.scale) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box", + "stateRenderFunction": "if (!ctx.properties.valueBox) {\n element.hide();\n}", + "actions": null + }, + { + "tag": "value-box-background", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var color = ctx.properties.valueBoxColor;\n element.attr({fill: color});\n}", + "actions": null + }, + { + "tag": "value-text", + "stateRenderFunction": "if (ctx.properties.valueBox) {\n var valueTextFont = ctx.properties.valueTextFont;\n var valueTextColor = ctx.properties.valueTextColor;\n var currentVolume = ctx.values.currentVolume;\n var valueText = ctx.api.formatValue(currentVolume, 0, ctx.properties.valueUnits, false);\n ctx.api.font(element, valueTextFont, valueTextColor);\n ctx.api.text(element, valueText);\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "tankCapacity", + "name": "{i18n:scada.symbol.tank-capacity}", + "hint": "{i18n:scada.symbol.tank-capacity-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_ATTRIBUTE", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": "SERVER_SCOPE", + "key": "tankCapacity" + }, + "getTimeSeries": { + "key": "state" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "currentVolume", + "name": "{i18n:scada.symbol.current-volume}", + "hint": "{i18n:scada.symbol.current-volume-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "GET_TIME_SERIES", + "defaultValue": null, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "liquidVolume" + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "warning", + "name": "{i18n:scada.symbol.warning-state}", + "hint": "{i18n:scada.symbol.warning-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.warning}", + "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 + }, + { + "id": "critical", + "name": "{i18n:scada.symbol.critical-state}", + "hint": "{i18n:scada.symbol.critical-state-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": null, + "falseLabel": null, + "stateLabel": "{i18n:scada.symbol.critical}", + "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 + }, + { + "id": "criticalAnimation", + "name": "{i18n:scada.symbol.critical-state-animation}", + "hint": "{i18n:scada.symbol.critical-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 + }, + { + "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": "tankColor", + "name": "{i18n:scada.symbol.tank-color}", + "type": "color", + "default": "#E5E5E5", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "fluidColor", + "name": "{i18n:scada.symbol.fluid-color}", + "type": "color", + "default": "#1EC1F480", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBox", + "name": "{i18n:scada.symbol.value-box}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueBoxColor", + "name": "{i18n:scada.symbol.value-box}", + "type": "color", + "default": "#F3F3F3", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueUnits", + "name": "{i18n:scada.symbol.value-text}", + "type": "units", + "default": "gal", + "required": null, + "subLabel": "{i18n:scada.symbol.units}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextFont", + "name": "{i18n:scada.symbol.value-text}", + "type": "font", + "default": { + "size": 36, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "valueTextColor", + "name": "{i18n:scada.symbol.value-text}", + "type": "color", + "default": "#0000008A", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "valueBox", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "scale", + "name": "{i18n:scada.symbol.scale}", + "type": "switch", + "default": true, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "transparent", + "name": "{i18n:scada.symbol.transparent-mode}", + "type": "switch", + "default": false, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorIntervals", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "number", + "default": 10, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": 1 + }, + { + "id": "majorFont", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "font", + "default": { + "size": 24, + "sizeUnit": "px", + "family": "Roboto", + "weight": "500", + "style": "normal" + }, + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#00000061", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorWarningColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "majorCriticalColor", + "name": "{i18n:scada.symbol.major-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorIntervals", + "name": "{i18n:scada.symbol.minor-ticks}", + "type": "number", + "default": 5, + "required": null, + "subLabel": "{i18n:scada.symbol.intervals}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": 1, + "max": null, + "step": null + }, + { + "id": "minorColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#0000001F", + "required": null, + "subLabel": "{i18n:scada.symbol.normal}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorWarningColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#FAA405", + "required": null, + "subLabel": "{i18n:scada.symbol.warning}", + "divider": true, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "minorCriticalColor", + "name": "{i18n:scada.symbol.minor-ticks-color}", + "type": "color", + "default": "#D12730", + "required": null, + "subLabel": "{i18n:scada.symbol.critical}", + "divider": null, + "fieldSuffix": null, + "disableOnProperty": "scale", + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1660 gal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/vertical-wheel-valve.svg b/application/src/main/data/json/system/scada_symbols/vertical-wheel-valve.svg new file mode 100644 index 0000000000..d43083040f --- /dev/null +++ b/application/src/main/data/json/system/scada_symbols/vertical-wheel-valve.svg @@ -0,0 +1,300 @@ +{ + "title": "Vertical wheel valve", + "description": "Vertical wheel valve with open/close animation and state colors.", + "searchTags": [ + "valve", + "wheel" + ], + "widgetSizeX": 1, + "widgetSizeY": 2, + "tags": [ + { + "tag": "background", + "stateRenderFunction": "var opened = ctx.values.opened;\nvar openAnimate = element.remember('openAnimate');\nvar color = opened ? ctx.properties.openedColor : ctx.properties.closedColor;\nif (!openAnimate) {\n element.attr({fill: color});\n} else {\n ctx.api.animate(element, 500).attr({fill: color});\n element.remember('openAnimate', false);\n}\n", + "actions": null + }, + { + "tag": "clickArea", + "stateRenderFunction": null, + "actions": { + "click": { + "actionFunction": " ctx.tags.wheel.forEach(e => {\n e.remember('openAnimate', true);\n });\n ctx.tags.background.forEach(e => {\n e.remember('openAnimate', true);\n });\n\n\nvar 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": "wheel", + "stateRenderFunction": "var opened = ctx.values.opened;\nvar openAnimate = element.remember('openAnimate');\nvar angle = opened ? ctx.properties.openedRotationAngle : ctx.properties.closedRotationAngle;\nif (!openAnimate) {\n element.transform({rotate: angle});\n} else {\n ctx.api.animate(element, 500).transform({rotate: angle});\n element.remember('openAnimate', false);\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": true, + "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": false, + "valueToDataFunction": "/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;" + } + }, + "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": "#696969", + "required": null, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": null, + "max": null, + "step": null + }, + { + "id": "openedRotationAngle", + "name": "{i18n:scada.symbol.opened-rotation-angle}", + "type": "number", + "default": 0, + "required": true, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": -179, + "max": 179, + "step": null + }, + { + "id": "closedRotationAngle", + "name": "{i18n:scada.symbol.closed-rotation-angle}", + "type": "number", + "default": 90, + "required": true, + "subLabel": null, + "divider": null, + "fieldSuffix": null, + "disableOnProperty": null, + "rowClass": "", + "fieldClass": "", + "min": -179, + "max": 179, + "step": null + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/buttons.json b/application/src/main/data/json/system/widget_bundles/buttons.json index 1c498fc0c4..115db85e66 100644 --- a/application/src/main/data/json/system/widget_bundles/buttons.json +++ b/application/src/main/data/json/system/widget_bundles/buttons.json @@ -3,7 +3,7 @@ "alias": "buttons", "title": "Buttons", "image": "tb-image:YnV0dG9ucy5zdmc=:IkJ1dHRvbnMiIHN5c3RlbSBidW5kbGUgaW1hZ2U=;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHg9IjAuNzUiIHk9IjEyLjc1IiB3aWR0aD0iMTk4LjUiIGhlaWdodD0iNTguNSIgcng9IjMuMjUiIGZpbGw9IndoaXRlIi8+CjxyZWN0IHg9IjAuNzUiIHk9IjEyLjc1IiB3aWR0aD0iMTk4LjUiIGhlaWdodD0iNTguNSIgcng9IjMuMjUiIHN0cm9rZT0iIzNGNTJERCIgc3Ryb2tlLXdpZHRoPSIxLjUiLz4KPHBhdGggZD0iTTYyLjE2NjMgNTEuMzMzM1Y0NC4zMzMzSDY2LjgzM1Y1MS4zMzMzSDcyLjY2NjNWNDJINzYuMTY2M0w2NC40OTk3IDMxLjVMNTIuODMzIDQySDU2LjMzM1Y1MS4zMzMzSDYyLjE2NjNaIiBmaWxsPSIjM0Y1MkREIi8+CjxwYXRoIGQ9Ik05MC4xOTUzIDQyLjkzMTZIODYuMjFMODYuMTg4NSA0MC45NjU4SDg5LjY2ODlDOTAuMjU2MiA0MC45NjU4IDkwLjc1MzkgNDAuODc5OSA5MS4xNjIxIDQwLjcwOEM5MS41Nzc1IDQwLjUyOSA5MS44OTI2IDQwLjI3NDcgOTIuMTA3NCAzOS45NDUzQzkyLjMyMjMgMzkuNjA4NyA5Mi40Mjk3IDM5LjIwNDEgOTIuNDI5NyAzOC43MzE0QzkyLjQyOTcgMzguMjA4NyA5Mi4zMjk0IDM3Ljc4MjYgOTIuMTI4OSAzNy40NTMxQzkxLjkyODQgMzcuMTIzNyA5MS42MjA0IDM2Ljg4MzggOTEuMjA1MSAzNi43MzM0QzkwLjc5NjkgMzYuNTgzIDkwLjI3NDEgMzYuNTA3OCA4OS42MzY3IDM2LjUwNzhIODcuMDI2NFY1MEg4NC4zMzAxVjM0LjM1OTRIODkuNjM2N0M5MC40OTYxIDM0LjM1OTQgOTEuMjYyNCAzNC40NDE3IDkxLjkzNTUgMzQuNjA2NEM5Mi42MTU5IDM0Ljc3MTIgOTMuMTkyNCAzNS4wMjkgOTMuNjY1IDM1LjM3OTlDOTQuMTQ0OSAzNS43MjM2IDk0LjUwNjUgMzYuMTYwNSA5NC43NSAzNi42OTA0Qzk1LjAwMDcgMzcuMjIwNCA5NS4xMjYgMzcuODUwNiA5NS4xMjYgMzguNTgxMUM5NS4xMjYgMzkuMjI1NiA5NC45NzIgMzkuODE2NCA5NC42NjQxIDQwLjM1MzVDOTQuMzU2MSA0MC44ODM1IDkzLjkwMTQgNDEuMzE2NyA5My4yOTk4IDQxLjY1MzNDOTIuNjk4MiA0MS45ODk5IDkxLjk0OTkgNDIuMTkwNCA5MS4wNTQ3IDQyLjI1NDlMOTAuMTk1MyA0Mi45MzE2Wk05MC4wNzcxIDUwSDg1LjM2MTNMODYuNTc1MiA0Ny44NjIzSDkwLjA3NzFDOTAuNjg1OSA0Ny44NjIzIDkxLjE5NDMgNDcuNzYyIDkxLjYwMjUgNDcuNTYxNUM5Mi4wMTA3IDQ3LjM1MzggOTIuMzE1MSA0Ny4wNzEgOTIuNTE1NiA0Ni43MTI5QzkyLjcyMzMgNDYuMzQ3NyA5Mi44MjcxIDQ1LjkyMTUgOTIuODI3MSA0NS40MzQ2QzkyLjgyNzEgNDQuOTI2MSA5Mi43Mzc2IDQ0LjQ4NTcgOTIuNTU4NiA0NC4xMTMzQzkyLjM3OTYgNDMuNzMzNyA5Mi4wOTY3IDQzLjQ0MzcgOTEuNzEgNDMuMjQzMkM5MS4zMjMyIDQzLjAzNTUgOTAuODE4NCA0Mi45MzE2IDkwLjE5NTMgNDIuOTMxNkg4Ny4xNjZMODcuMTg3NSA0MC45NjU4SDkxLjEyOTlMOTEuNzQyMiA0MS43MDdDOTIuNjAxNiA0MS43MzU3IDkzLjMwNyA0MS45MjU1IDkzLjg1ODQgNDIuMjc2NEM5NC40MTcgNDIuNjI3MyA5NC44MzI0IDQzLjA4MiA5NS4xMDQ1IDQzLjY0MDZDOTUuMzc2NiA0NC4xOTkyIDk1LjUxMjcgNDQuODAwOCA5NS41MTI3IDQ1LjQ0NTNDOTUuNTEyNyA0Ni40NDA4IDk1LjI5NDMgNDcuMjc1MSA5NC44NTc0IDQ3Ljk0ODJDOTQuNDI3NyA0OC42MjE0IDkzLjgwODMgNDkuMTMzNSA5Mi45OTkgNDkuNDg0NEM5Mi4xODk4IDQ5LjgyODEgOTEuMjE1OCA1MCA5MC4wNzcxIDUwWk0xMDUuMjE2IDQ3LjI2MDdWMzguMzc3SDEwNy44MTVWNTBIMTA1LjM2NkwxMDUuMjE2IDQ3LjI2MDdaTTEwNS41ODEgNDQuODQzOEwxMDYuNDUxIDQ0LjgyMjNDMTA2LjQ1MSA0NS42MDI5IDEwNi4zNjUgNDYuMzIyNiAxMDYuMTkzIDQ2Ljk4MTRDMTA2LjAyMSA0Ny42MzMxIDEwNS43NTcgNDguMjAyNSAxMDUuMzk4IDQ4LjY4OTVDMTA1LjA0IDQ5LjE2OTMgMTA0LjU4MiA0OS41NDUyIDEwNC4wMjMgNDkuODE3NEMxMDMuNDY1IDUwLjA4MjQgMTAyLjc5NSA1MC4yMTQ4IDEwMi4wMTUgNTAuMjE0OEMxMDEuNDQ5IDUwLjIxNDggMTAwLjkzIDUwLjEzMjUgMTAwLjQ1NyA0OS45Njc4Qzk5Ljk4NDQgNDkuODAzMSA5OS41NzYyIDQ5LjU0ODggOTkuMjMyNCA0OS4yMDUxQzk4Ljg5NTggNDguODYxMyA5OC42MzQ0IDQ4LjQxMzcgOTguNDQ4MiA0Ny44NjIzQzk4LjI2MiA0Ny4zMTA5IDk4LjE2ODkgNDYuNjUyIDk4LjE2ODkgNDUuODg1N1YzOC4zNzdIMTAwLjc1OFY0NS45MDcyQzEwMC43NTggNDYuMzI5OCAxMDAuODA4IDQ2LjY4NDIgMTAwLjkwOCA0Ni45NzA3QzEwMS4wMDggNDcuMjUgMTAxLjE0NSA0Ny40NzU2IDEwMS4zMTYgNDcuNjQ3NUMxMDEuNDg4IDQ3LjgxOTMgMTAxLjY4OSA0Ny45NDExIDEwMS45MTggNDguMDEyN0MxMDIuMTQ3IDQ4LjA4NDMgMTAyLjM5MSA0OC4xMjAxIDEwMi42NDggNDguMTIwMUMxMDMuMzg2IDQ4LjEyMDEgMTAzLjk2NiA0Ny45NzY5IDEwNC4zODkgNDcuNjkwNEMxMDQuODE4IDQ3LjM5NjggMTA1LjEyMyA0Ny4wMDI5IDEwNS4zMDIgNDYuNTA4OEMxMDUuNDg4IDQ2LjAxNDYgMTA1LjU4MSA0NS40NTk2IDEwNS41ODEgNDQuODQzOFpNMTE2LjA0NyAzOC4zNzdWNDAuMjY3NkgxMDkuNDk0VjM4LjM3N0gxMTYuMDQ3Wk0xMTEuMzg1IDM1LjUzMDNIMTEzLjk3NFY0Ni43ODgxQzExMy45NzQgNDcuMTQ2MiAxMTQuMDI0IDQ3LjQyMTkgMTE0LjEyNCA0Ny42MTUyQzExNC4yMzEgNDcuODAxNCAxMTQuMzc4IDQ3LjkyNjggMTE0LjU2NCA0Ny45OTEyQzExNC43NTEgNDguMDU1NyAxMTQuOTY5IDQ4LjA4NzkgMTE1LjIyIDQ4LjA4NzlDMTE1LjM5OSA0OC4wODc5IDExNS41NzEgNDguMDc3MSAxMTUuNzM1IDQ4LjA1NTdDMTE1LjkgNDguMDM0MiAxMTYuMDMzIDQ4LjAxMjcgMTE2LjEzMyA0Ny45OTEyTDExNi4xNDQgNDkuOTY3OEMxMTUuOTI5IDUwLjAzMjIgMTE1LjY3OCA1MC4wODk1IDExNS4zOTIgNTAuMTM5NkMxMTUuMTEyIDUwLjE4OTggMTE0Ljc5IDUwLjIxNDggMTE0LjQyNSA1MC4yMTQ4QzExMy44MyA1MC4yMTQ4IDExMy4zMDQgNTAuMTExIDExMi44NDYgNDkuOTAzM0MxMTIuMzg3IDQ5LjY4ODUgMTEyLjAyOSA0OS4zNDExIDExMS43NzEgNDguODYxM0MxMTEuNTE0IDQ4LjM4MTUgMTExLjM4NSA0Ny43NDQxIDExMS4zODUgNDYuOTQ5MlYzNS41MzAzWk0xMjMuNjIzIDM4LjM3N1Y0MC4yNjc2SDExNy4wN1YzOC4zNzdIMTIzLjYyM1pNMTE4Ljk2MSAzNS41MzAzSDEyMS41NVY0Ni43ODgxQzEyMS41NSA0Ny4xNDYyIDEyMS42IDQ3LjQyMTkgMTIxLjcgNDcuNjE1MkMxMjEuODA4IDQ3LjgwMTQgMTIxLjk1NCA0Ny45MjY4IDEyMi4xNDEgNDcuOTkxMkMxMjIuMzI3IDQ4LjA1NTcgMTIyLjU0NSA0OC4wODc5IDEyMi43OTYgNDguMDg3OUMxMjIuOTc1IDQ4LjA4NzkgMTIzLjE0NyA0OC4wNzcxIDEyMy4zMTIgNDguMDU1N0MxMjMuNDc2IDQ4LjAzNDIgMTIzLjYwOSA0OC4wMTI3IDEyMy43MDkgNDcuOTkxMkwxMjMuNzIgNDkuOTY3OEMxMjMuNTA1IDUwLjAzMjIgMTIzLjI1NCA1MC4wODk1IDEyMi45NjggNTAuMTM5NkMxMjIuNjg4IDUwLjE4OTggMTIyLjM2NiA1MC4yMTQ4IDEyMi4wMDEgNTAuMjE0OEMxMjEuNDA3IDUwLjIxNDggMTIwLjg4IDUwLjExMSAxMjAuNDIyIDQ5LjkwMzNDMTE5Ljk2NCA0OS42ODg1IDExOS42MDUgNDkuMzQxMSAxMTkuMzQ4IDQ4Ljg2MTNDMTE5LjA5IDQ4LjM4MTUgMTE4Ljk2MSA0Ny43NDQxIDExOC45NjEgNDYuOTQ5MlYzNS41MzAzWk0xMjUuMTE5IDQ0LjMxNzRWNDQuMDcwM0MxMjUuMTE5IDQzLjIzMjQgMTI1LjI0MSA0Mi40NTU0IDEyNS40ODQgNDEuNzM5M0MxMjUuNzI4IDQxLjAxNiAxMjYuMDc5IDQwLjM4OTMgMTI2LjUzNyAzOS44NTk0QzEyNy4wMDMgMzkuMzIyMyAxMjcuNTY4IDM4LjkwNjkgMTI4LjIzNCAzOC42MTMzQzEyOC45MDggMzguMzEyNSAxMjkuNjY3IDM4LjE2MjEgMTMwLjUxMiAzOC4xNjIxQzEzMS4zNjQgMzguMTYyMSAxMzIuMTIzIDM4LjMxMjUgMTMyLjc4OSAzOC42MTMzQzEzMy40NjIgMzguOTA2OSAxMzQuMDMyIDM5LjMyMjMgMTM0LjQ5NyAzOS44NTk0QzEzNC45NjMgNDAuMzg5MyAxMzUuMzE3IDQxLjAxNiAxMzUuNTYxIDQxLjczOTNDMTM1LjgwNCA0Mi40NTU0IDEzNS45MjYgNDMuMjMyNCAxMzUuOTI2IDQ0LjA3MDNWNDQuMzE3NEMxMzUuOTI2IDQ1LjE1NTMgMTM1LjgwNCA0NS45MzIzIDEzNS41NjEgNDYuNjQ4NEMxMzUuMzE3IDQ3LjM2NDYgMTM0Ljk2MyA0Ny45OTEyIDEzNC40OTcgNDguNTI4M0MxMzQuMDMyIDQ5LjA1ODMgMTMzLjQ2NiA0OS40NzM2IDEzMi44IDQ5Ljc3NDRDMTMyLjEzNCA1MC4wNjggMTMxLjM3OCA1MC4yMTQ4IDEzMC41MzMgNTAuMjE0OEMxMjkuNjgxIDUwLjIxNDggMTI4LjkxOCA1MC4wNjggMTI4LjI0NSA0OS43NzQ0QzEyNy41NzkgNDkuNDczNiAxMjcuMDEzIDQ5LjA1ODMgMTI2LjU0OCA0OC41MjgzQzEyNi4wODIgNDcuOTkxMiAxMjUuNzI4IDQ3LjM2NDYgMTI1LjQ4NCA0Ni42NDg0QzEyNS4yNDEgNDUuOTMyMyAxMjUuMTE5IDQ1LjE1NTMgMTI1LjExOSA0NC4zMTc0Wk0xMjcuNzA4IDQ0LjA3MDNWNDQuMzE3NEMxMjcuNzA4IDQ0Ljg0MDIgMTI3Ljc2MiA0NS4zMzQzIDEyNy44NjkgNDUuNzk5OEMxMjcuOTc3IDQ2LjI2NTMgMTI4LjE0NSA0Ni42NzM1IDEyOC4zNzQgNDcuMDI0NEMxMjguNjAzIDQ3LjM3NTMgMTI4Ljg5NyA0Ny42NTEgMTI5LjI1NSA0Ny44NTE2QzEyOS42MTMgNDguMDUyMSAxMzAuMDM5IDQ4LjE1MjMgMTMwLjUzMyA0OC4xNTIzQzEzMS4wMTMgNDguMTUyMyAxMzEuNDI4IDQ4LjA1MjEgMTMxLjc3OSA0Ny44NTE2QzEzMi4xMzcgNDcuNjUxIDEzMi40MzEgNDcuMzc1MyAxMzIuNjYgNDcuMDI0NEMxMzIuODg5IDQ2LjY3MzUgMTMzLjA1OCA0Ni4yNjUzIDEzMy4xNjUgNDUuNzk5OEMxMzMuMjggNDUuMzM0MyAxMzMuMzM3IDQ0Ljg0MDIgMTMzLjMzNyA0NC4zMTc0VjQ0LjA3MDNDMTMzLjMzNyA0My41NTQ3IDEzMy4yOCA0My4wNjc3IDEzMy4xNjUgNDIuNjA5NEMxMzMuMDU4IDQyLjE0MzkgMTMyLjg4NiA0MS43MzIxIDEzMi42NDkgNDEuMzc0QzEzMi40MiA0MS4wMTYgMTMyLjEyNyA0MC43MzY3IDEzMS43NjkgNDAuNTM2MUMxMzEuNDE4IDQwLjMyODUgMTMwLjk5OSA0MC4yMjQ2IDEzMC41MTIgNDAuMjI0NkMxMzAuMDI1IDQwLjIyNDYgMTI5LjYwMiA0MC4zMjg1IDEyOS4yNDQgNDAuNTM2MUMxMjguODkzIDQwLjczNjcgMTI4LjYwMyA0MS4wMTYgMTI4LjM3NCA0MS4zNzRDMTI4LjE0NSA0MS43MzIxIDEyNy45NzcgNDIuMTQzOSAxMjcuODY5IDQyLjYwOTRDMTI3Ljc2MiA0My4wNjc3IDEyNy43MDggNDMuNTU0NyAxMjcuNzA4IDQ0LjA3MDNaTTE0MC45MTMgNDAuODU4NFY1MEgxMzguMzI0VjM4LjM3N0gxNDAuNzYzTDE0MC45MTMgNDAuODU4NFpNMTQwLjQ1MSA0My43NTg4TDEzOS42MTMgNDMuNzQ4QzEzOS42MiA0Mi45MjQ1IDEzOS43MzUgNDIuMTY4OSAxMzkuOTU3IDQxLjQ4MTRDMTQwLjE4NiA0MC43OTM5IDE0MC41MDEgNDAuMjAzMSAxNDAuOTAyIDM5LjcwOUMxNDEuMzExIDM5LjIxNDggMTQxLjc5OCAzOC44MzUzIDE0Mi4zNjMgMzguNTcwM0MxNDIuOTI5IDM4LjI5ODIgMTQzLjU1OSAzOC4xNjIxIDE0NC4yNTQgMzguMTYyMUMxNDQuODEyIDM4LjE2MjEgMTQ1LjMxNyAzOC4yNDA5IDE0NS43NjkgMzguMzk4NEMxNDYuMjI3IDM4LjU0ODggMTQ2LjYxNyAzOC43OTU5IDE0Ni45MzkgMzkuMTM5NkMxNDcuMjY5IDM5LjQ4MzQgMTQ3LjUyIDM5LjkzMSAxNDcuNjkxIDQwLjQ4MjRDMTQ3Ljg2MyA0MS4wMjY3IDE0Ny45NDkgNDEuNjk2MyAxNDcuOTQ5IDQyLjQ5MTJWNTBIMTQ1LjM1VjQyLjQ4MDVDMTQ1LjM1IDQxLjkyMTkgMTQ1LjI2NyA0MS40ODE0IDE0NS4xMDMgNDEuMTU5MkMxNDQuOTQ1IDQwLjgyOTggMTQ0LjcxMiA0MC41OTcgMTQ0LjQwNCA0MC40NjA5QzE0NC4xMDQgNDAuMzE3NyAxNDMuNzI4IDQwLjI0NjEgMTQzLjI3NiA0MC4yNDYxQzE0Mi44MzIgNDAuMjQ2MSAxNDIuNDM1IDQwLjMzOTIgMTQyLjA4NCA0MC41MjU0QzE0MS43MzMgNDAuNzExNiAxNDEuNDM2IDQwLjk2NTggMTQxLjE5MiA0MS4yODgxQzE0MC45NTYgNDEuNjEwNCAxNDAuNzczIDQxLjk4MjcgMTQwLjY0NSA0Mi40MDUzQzE0MC41MTYgNDIuODI3OCAxNDAuNDUxIDQzLjI3OSAxNDAuNDUxIDQzLjc1ODhaIiBmaWxsPSIjM0Y1MkREIi8+CjxyZWN0IHg9IjAuNzUiIHk9Ijg4Ljc1IiB3aWR0aD0iMTk4LjUiIGhlaWdodD0iNTguNSIgcng9IjMuMjUiIGZpbGw9IndoaXRlIi8+CjxyZWN0IHg9IjAuNzUiIHk9Ijg4Ljc1IiB3aWR0aD0iMTk4LjUiIGhlaWdodD0iNTguNSIgcng9IjMuMjUiIHN0cm9rZT0iIzNGNTJERCIgc3Ryb2tlLXdpZHRoPSIxLjUiLz4KPHBhdGggZD0iTTY1LjQ5OTcgMTExVjExMy4zMzNINzUuNTIxM0w2NC4zMzMgMTI0LjUyMkw2NS45NzggMTI2LjE2N0w3Ny4xNjYzIDExNC45NzhWMTI1SDc5LjQ5OTdWMTExSDY1LjQ5OTdaIiBmaWxsPSIjM0Y1MkREIi8+CjxwYXRoIGQ9Ik0xMDAuNTY0IDEyMS45NzJDMTAwLjU2NCAxMjEuNjQ5IDEwMC41MTQgMTIxLjM2MyAxMDAuNDE0IDEyMS4xMTJDMTAwLjMyMSAxMjAuODYyIDEwMC4xNTMgMTIwLjYzMiA5OS45MDkyIDEyMC40MjVDOTkuNjY1NyAxMjAuMjE3IDk5LjMyMTkgMTIwLjAxNyA5OC44Nzc5IDExOS44MjNDOTguNDQxMSAxMTkuNjIzIDk3Ljg4MjUgMTE5LjQxOSA5Ny4yMDIxIDExOS4yMTFDOTYuNDU3NCAxMTguOTgyIDk1Ljc2OTkgMTE4LjcyOCA5NS4xMzk2IDExOC40NDhDOTQuNTE2NiAxMTguMTYyIDkzLjk3MjMgMTE3LjgzMiA5My41MDY4IDExNy40NkM5My4wNDEzIDExNy4wOCA5Mi42Nzk3IDExNi42NDcgOTIuNDIxOSAxMTYuMTZDOTIuMTY0MSAxMTUuNjY2IDkyLjAzNTIgMTE1LjA5NyA5Mi4wMzUyIDExNC40NTJDOTIuMDM1MiAxMTMuODE1IDkyLjE2NzYgMTEzLjIzNSA5Mi40MzI2IDExMi43MTJDOTIuNzA0OCAxMTIuMTg5IDkzLjA4NzkgMTExLjczOCA5My41ODIgMTExLjM1OEM5NC4wODMzIDExMC45NzIgOTQuNjc0MiAxMTAuNjc0IDk1LjM1NDUgMTEwLjQ2N0M5Ni4wMzQ4IDExMC4yNTIgOTYuNzg2OCAxMTAuMTQ1IDk3LjYxMDQgMTEwLjE0NUM5OC43NzA1IDExMC4xNDUgOTkuNzY5NSAxMTAuMzU5IDEwMC42MDcgMTEwLjc4OUMxMDEuNDUyIDExMS4yMTkgMTAyLjEwMSAxMTEuNzk1IDEwMi41NTIgMTEyLjUxOUMxMDMuMDEgMTEzLjI0MiAxMDMuMjM5IDExNC4wNCAxMDMuMjM5IDExNC45MTRIMTAwLjU2NEMxMDAuNTY0IDExNC4zOTggMTAwLjQ1MyAxMTMuOTQ0IDEwMC4yMzEgMTEzLjU1QzEwMC4wMTcgMTEzLjE0OSA5OS42ODcyIDExMi44MzQgOTkuMjQzMiAxMTIuNjA0Qzk4LjgwNjMgMTEyLjM3NSA5OC4yNTEzIDExMi4yNjEgOTcuNTc4MSAxMTIuMjYxQzk2Ljk0MDggMTEyLjI2MSA5Ni40MTA4IDExMi4zNTcgOTUuOTg4MyAxMTIuNTUxQzk1LjU2NTggMTEyLjc0NCA5NS4yNTA3IDExMy4wMDYgOTUuMDQzIDExMy4zMzVDOTQuODM1MyAxMTMuNjY0IDk0LjczMTQgMTE0LjAzNyA5NC43MzE0IDExNC40NTJDOTQuNzMxNCAxMTQuNzQ2IDk0Ljc5OTUgMTE1LjAxNCA5NC45MzU1IDExNS4yNThDOTUuMDcxNiAxMTUuNDk0IDk1LjI3OTMgMTE1LjcxNiA5NS41NTg2IDExNS45MjRDOTUuODM3OSAxMTYuMTI0IDk2LjE4ODggMTE2LjMxNCA5Ni42MTEzIDExNi40OTNDOTcuMDMzOSAxMTYuNjcyIDk3LjUzMTYgMTE2Ljg0NCA5OC4xMDQ1IDExNy4wMDlDOTguOTcxIDExNy4yNjcgOTkuNzI2NiAxMTcuNTUzIDEwMC4zNzEgMTE3Ljg2OEMxMDEuMDE2IDExOC4xNzYgMTAxLjU1MyAxMTguNTI3IDEwMS45ODIgMTE4LjkyMUMxMDIuNDEyIDExOS4zMTUgMTAyLjczNCAxMTkuNzYyIDEwMi45NDkgMTIwLjI2NEMxMDMuMTY0IDEyMC43NTggMTAzLjI3MSAxMjEuMzIgMTAzLjI3MSAxMjEuOTVDMTAzLjI3MSAxMjIuNjA5IDEwMy4xMzkgMTIzLjIwMyAxMDIuODc0IDEyMy43MzNDMTAyLjYwOSAxMjQuMjU2IDEwMi4yMjkgMTI0LjcwNCAxMDEuNzM1IDEyNS4wNzZDMTAxLjI0OCAxMjUuNDQxIDEwMC42NjEgMTI1LjcyNCA5OS45NzM2IDEyNS45MjVDOTkuMjkzMyAxMjYuMTE4IDk4LjUzNDIgMTI2LjIxNSA5Ny42OTYzIDEyNi4yMTVDOTYuOTQ0MyAxMjYuMjE1IDk2LjIwMzEgMTI2LjExNSA5NS40NzI3IDEyNS45MTRDOTQuNzQ5MyAxMjUuNzE0IDk0LjA5MDUgMTI1LjQwOSA5My40OTYxIDEyNS4wMDFDOTIuOTAxNyAxMjQuNTg2IDkyLjQyOSAxMjQuMDcgOTIuMDc4MSAxMjMuNDU0QzkxLjcyNzIgMTIyLjgzMSA5MS41NTE4IDEyMi4xMDQgOTEuNTUxOCAxMjEuMjczSDk0LjI0OEM5NC4yNDggMTIxLjc4MiA5NC4zMzQgMTIyLjIxNSA5NC41MDU5IDEyMi41NzNDOTQuNjg0OSAxMjIuOTMxIDk0LjkzMiAxMjMuMjI1IDk1LjI0NzEgMTIzLjQ1NEM5NS41NjIyIDEyMy42NzYgOTUuOTI3NCAxMjMuODQxIDk2LjM0MjggMTIzLjk0OEM5Ni43NjUzIDEyNC4wNTYgOTcuMjE2NSAxMjQuMTA5IDk3LjY5NjMgMTI0LjEwOUM5OC4zMjY1IDEyNC4xMDkgOTguODUyOSAxMjQuMDIgOTkuMjc1NCAxMjMuODQxQzk5LjcwNTEgMTIzLjY2MiAxMDAuMDI3IDEyMy40MTEgMTAwLjI0MiAxMjMuMDg5QzEwMC40NTcgMTIyLjc2NyAxMDAuNTY0IDEyMi4zOTQgMTAwLjU2NCAxMjEuOTcyWk0xMTAuNzcyIDEyNi4yMTVDMTA5LjkxMyAxMjYuMjE1IDEwOS4xMzYgMTI2LjA3NSAxMDguNDQxIDEyNS43OTZDMTA3Ljc1NCAxMjUuNTA5IDEwNy4xNjcgMTI1LjExMiAxMDYuNjggMTI0LjYwNEMxMDYuMiAxMjQuMDk1IDEwNS44MzEgMTIzLjQ5NyAxMDUuNTczIDEyMi44MUMxMDUuMzE1IDEyMi4xMjIgMTA1LjE4NyAxMjEuMzgxIDEwNS4xODcgMTIwLjU4NlYxMjAuMTU2QzEwNS4xODcgMTE5LjI0NyAxMDUuMzE5IDExOC40MjMgMTA1LjU4NCAxMTcuNjg2QzEwNS44NDkgMTE2Ljk0OCAxMDYuMjE4IDExNi4zMTggMTA2LjY5IDExNS43OTVDMTA3LjE2MyAxMTUuMjY1IDEwNy43MjIgMTE0Ljg2IDEwOC4zNjYgMTE0LjU4MUMxMDkuMDExIDExNC4zMDIgMTA5LjcwOSAxMTQuMTYyIDExMC40NjEgMTE0LjE2MkMxMTEuMjkyIDExNC4xNjIgMTEyLjAxOSAxMTQuMzAyIDExMi42NDIgMTE0LjU4MUMxMTMuMjY1IDExNC44NiAxMTMuNzggMTE1LjI1NCAxMTQuMTg4IDExNS43NjNDMTE0LjYwNCAxMTYuMjY0IDExNC45MTIgMTE2Ljg2MiAxMTUuMTEyIDExNy41NTdDMTE1LjMyIDExOC4yNTEgMTE1LjQyNCAxMTkuMDE4IDExNS40MjQgMTE5Ljg1NVYxMjAuOTYySDEwNi40NDNWMTE5LjEwNEgxMTIuODY3VjExOC44OTlDMTEyLjg1MyAxMTguNDM0IDExMi43NiAxMTcuOTk3IDExMi41ODggMTE3LjU4OUMxMTIuNDIzIDExNy4xODEgMTEyLjE2OSAxMTYuODUxIDExMS44MjUgMTE2LjYwMUMxMTEuNDgxIDExNi4zNSAxMTEuMDIzIDExNi4yMjUgMTEwLjQ1IDExNi4yMjVDMTEwLjAyMSAxMTYuMjI1IDEwOS42MzcgMTE2LjMxOCAxMDkuMzAxIDExNi41MDRDMTA4Ljk3MSAxMTYuNjgzIDEwOC42OTYgMTE2Ljk0NCAxMDguNDc0IDExNy4yODhDMTA4LjI1MiAxMTcuNjMyIDEwOC4wOCAxMTguMDQ3IDEwNy45NTggMTE4LjUzNEMxMDcuODQzIDExOS4wMTQgMTA3Ljc4NiAxMTkuNTU1IDEwNy43ODYgMTIwLjE1NlYxMjAuNTg2QzEwNy43ODYgMTIxLjA5NCAxMDcuODU0IDEyMS41NjcgMTA3Ljk5IDEyMi4wMDRDMTA4LjEzMyAxMjIuNDM0IDEwOC4zNDEgMTIyLjgxIDEwOC42MTMgMTIzLjEzMkMxMDguODg1IDEyMy40NTQgMTA5LjIxNSAxMjMuNzA4IDEwOS42MDIgMTIzLjg5NUMxMDkuOTg4IDEyNC4wNzQgMTEwLjQyOSAxMjQuMTYzIDExMC45MjMgMTI0LjE2M0MxMTEuNTQ2IDEyNC4xNjMgMTEyLjEwMSAxMjQuMDM4IDExMi41ODggMTIzLjc4N0MxMTMuMDc1IDEyMy41MzYgMTEzLjQ5NyAxMjMuMTgyIDExMy44NTUgMTIyLjcyNEwxMTUuMjIgMTI0LjA0NUMxMTQuOTY5IDEyNC40MSAxMTQuNjQzIDEyNC43NjEgMTE0LjI0MiAxMjUuMDk4QzExMy44NDEgMTI1LjQyNyAxMTMuMzUxIDEyNS42OTYgMTEyLjc3MSAxMjUuOTAzQzExMi4xOTggMTI2LjExMSAxMTEuNTMyIDEyNi4yMTUgMTEwLjc3MiAxMjYuMjE1Wk0xMjAuMjYxIDExNi44NThWMTI2SDExNy42NzJWMTE0LjM3N0gxMjAuMTFMMTIwLjI2MSAxMTYuODU4Wk0xMTkuNzk5IDExOS43NTlMMTE4Ljk2MSAxMTkuNzQ4QzExOC45NjggMTE4LjkyNCAxMTkuMDgzIDExOC4xNjkgMTE5LjMwNSAxMTcuNDgxQzExOS41MzQgMTE2Ljc5NCAxMTkuODQ5IDExNi4yMDMgMTIwLjI1IDExNS43MDlDMTIwLjY1OCAxMTUuMjE1IDEyMS4xNDUgMTE0LjgzNSAxMjEuNzExIDExNC41N0MxMjIuMjc3IDExNC4yOTggMTIyLjkwNyAxMTQuMTYyIDEyMy42MDIgMTE0LjE2MkMxMjQuMTYgMTE0LjE2MiAxMjQuNjY1IDExNC4yNDEgMTI1LjExNiAxMTQuMzk4QzEyNS41NzUgMTE0LjU0OSAxMjUuOTY1IDExNC43OTYgMTI2LjI4NyAxMTUuMTRDMTI2LjYxNyAxMTUuNDgzIDEyNi44NjcgMTE1LjkzMSAxMjcuMDM5IDExNi40ODJDMTI3LjIxMSAxMTcuMDI3IDEyNy4yOTcgMTE3LjY5NiAxMjcuMjk3IDExOC40OTFWMTI2SDEyNC42OTdWMTE4LjQ4QzEyNC42OTcgMTE3LjkyMiAxMjQuNjE1IDExNy40ODEgMTI0LjQ1IDExNy4xNTlDMTI0LjI5MyAxMTYuODMgMTI0LjA2IDExNi41OTcgMTIzLjc1MiAxMTYuNDYxQzEyMy40NTEgMTE2LjMxOCAxMjMuMDc1IDExNi4yNDYgMTIyLjYyNCAxMTYuMjQ2QzEyMi4xOCAxMTYuMjQ2IDEyMS43ODMgMTE2LjMzOSAxMjEuNDMyIDExNi41MjVDMTIxLjA4MSAxMTYuNzEyIDEyMC43ODQgMTE2Ljk2NiAxMjAuNTQgMTE3LjI4OEMxMjAuMzA0IDExNy42MSAxMjAuMTIxIDExNy45ODMgMTE5Ljk5MiAxMTguNDA1QzExOS44NjMgMTE4LjgyOCAxMTkuNzk5IDExOS4yNzkgMTE5Ljc5OSAxMTkuNzU5Wk0xMzcuMjc5IDEyMy41OTRWMTA5LjVIMTM5Ljg3OVYxMjZIMTM3LjUyNkwxMzcuMjc5IDEyMy41OTRaTTEyOS43MTcgMTIwLjMxN1YxMjAuMDkyQzEyOS43MTcgMTE5LjIxMSAxMjkuODIxIDExOC40MDkgMTMwLjAyOCAxMTcuNjg2QzEzMC4yMzYgMTE2Ljk1NSAxMzAuNTM3IDExNi4zMjggMTMwLjkzMSAxMTUuODA2QzEzMS4zMjUgMTE1LjI3NiAxMzEuODA0IDExNC44NzEgMTMyLjM3IDExNC41OTJDMTMyLjkzNiAxMTQuMzA1IDEzMy41NzMgMTE0LjE2MiAxMzQuMjgyIDExNC4xNjJDMTM0Ljk4NCAxMTQuMTYyIDEzNS42IDExNC4yOTggMTM2LjEzIDExNC41N0MxMzYuNjYgMTE0Ljg0MiAxMzcuMTExIDExNS4yMzMgMTM3LjQ4MyAxMTUuNzQxQzEzNy44NTYgMTE2LjI0MyAxMzguMTUzIDExNi44NDQgMTM4LjM3NSAxMTcuNTQ2QzEzOC41OTcgMTE4LjI0MSAxMzguNzU1IDExOS4wMTQgMTM4Ljg0OCAxMTkuODY2VjEyMC41ODZDMTM4Ljc1NSAxMjEuNDE3IDEzOC41OTcgMTIyLjE3NiAxMzguMzc1IDEyMi44NjNDMTM4LjE1MyAxMjMuNTUxIDEzNy44NTYgMTI0LjE0NSAxMzcuNDgzIDEyNC42NDZDMTM3LjExMSAxMjUuMTQ4IDEzNi42NTYgMTI1LjUzNSAxMzYuMTE5IDEyNS44MDdDMTM1LjU4OSAxMjYuMDc5IDEzNC45NyAxMjYuMjE1IDEzNC4yNjEgMTI2LjIxNUMxMzMuNTU5IDEyNi4yMTUgMTMyLjkyNSAxMjYuMDY4IDEzMi4zNTkgMTI1Ljc3NEMxMzEuODAxIDEyNS40ODEgMTMxLjMyNSAxMjUuMDY5IDEzMC45MzEgMTI0LjUzOUMxMzAuNTM3IDEyNC4wMDkgMTMwLjIzNiAxMjMuMzg2IDEzMC4wMjggMTIyLjY3QzEyOS44MjEgMTIxLjk0NyAxMjkuNzE3IDEyMS4xNjIgMTI5LjcxNyAxMjAuMzE3Wk0xMzIuMzA2IDEyMC4wOTJWMTIwLjMxN0MxMzIuMzA2IDEyMC44NDcgMTMyLjM1MiAxMjEuMzQxIDEzMi40NDUgMTIxLjhDMTMyLjU0NiAxMjIuMjU4IDEzMi43IDEyMi42NjMgMTMyLjkwNyAxMjMuMDE0QzEzMy4xMTUgMTIzLjM1NyAxMzMuMzgzIDEyMy42MyAxMzMuNzEzIDEyMy44M0MxMzQuMDQ5IDEyNC4wMjMgMTM0LjQ1MSAxMjQuMTIgMTM0LjkxNiAxMjQuMTJDMTM1LjUwMyAxMjQuMTIgMTM1Ljk4NyAxMjMuOTkxIDEzNi4zNjYgMTIzLjczM0MxMzYuNzQ2IDEyMy40NzYgMTM3LjA0MyAxMjMuMTI4IDEzNy4yNTggMTIyLjY5MUMxMzcuNDggMTIyLjI0NyAxMzcuNjMgMTIxLjc1MyAxMzcuNzA5IDEyMS4yMDlWMTE5LjI2NUMxMzcuNjY2IDExOC44NDIgMTM3LjU3NiAxMTguNDQ4IDEzNy40NCAxMTguMDgzQzEzNy4zMTIgMTE3LjcxOCAxMzcuMTM2IDExNy4zOTkgMTM2LjkxNCAxMTcuMTI3QzEzNi42OTIgMTE2Ljg0OCAxMzYuNDE2IDExNi42MzMgMTM2LjA4NyAxMTYuNDgyQzEzNS43NjUgMTE2LjMyNSAxMzUuMzgyIDExNi4yNDYgMTM0LjkzOCAxMTYuMjQ2QzEzNC40NjUgMTE2LjI0NiAxMzQuMDY0IDExNi4zNDYgMTMzLjczNCAxMTYuNTQ3QzEzMy40MDUgMTE2Ljc0NyAxMzMuMTMzIDExNy4wMjMgMTMyLjkxOCAxMTcuMzc0QzEzMi43MSAxMTcuNzI1IDEzMi41NTYgMTE4LjEzMyAxMzIuNDU2IDExOC41OTlDMTMyLjM1NiAxMTkuMDY0IDEzMi4zMDYgMTE5LjU2MiAxMzIuMzA2IDEyMC4wOTJaIiBmaWxsPSIjM0Y1MkREIi8+Cjwvc3ZnPgo=", - "description": "Facilitates user interaction by enabling navigation between dashboard states, sending RPC commands to devices, and updating device attributes or time-series data.", + "description": "Facilitates user interaction by enabling navigation between dashboard states, sending RPC commands to devices, and updating device attributes or time series data.", "order": 7500, "name": "Buttons" }, diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json index 84f8cd9785..81cb2fdbb1 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -23,6 +23,7 @@ "cards.html_card", "cards.html_value_card", "cards.markdown_card", - "cards.simple_card" + "cards.simple_card", + "unread_notifications" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/html_widgets.json b/application/src/main/data/json/system/widget_bundles/html_widgets.json index 9293ed5fcb..c8215d7fe1 100644 --- a/application/src/main/data/json/system/widget_bundles/html_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/html_widgets.json @@ -3,7 +3,7 @@ "alias": "html_widgets", "title": "HTML widgets", "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAA81BMVEUAAADg4ODf39/g4ODg4ODR0dHPz8/R0dHR0dH////f39/t7e3v7+/6+vr09PT8/Pzn5+fR0dHk5OT29vbx8fH4+Pjr6+uUlJTp6emMjIyGhobLy8vGxsalpaXh4eGAgIC3t7erq6vb29u5ubnZ2dmamprDw8PAwMCysrKnp6fd3d3IyMjW1taJiYmurq6jo6Ofn5+Pj4/X19fNzc3T09OXl5eDg4NtbW16enq7u7t9fX2RkZG9vb13d3ehoaG0tLSwsLBycnLQ0NBXV1deXl5kZGTPz8+cnJxqampnZ2dbW1tSUlJOTk5hYWFHR0c3NzdAQEAykmEAAAAACXRSTlMA7yC/r+8gv69spyeXAAAWJklEQVR42uybiZaiRhiFsyc/u+ybbIIIioAKuCuttlt3Z97/aYKgpnti9jYnnZM7Z/6pQ48Wn7eKul3CF1989/23tbeykI+mb74qOL5FePStcPhgItkvv/riBw0thJV/ytYHBAHAvv6ixpcnX6pqYX8ShBWhEkNx8AdFse/syZdf1NCfHaGa1M9Da4D5Ig4+RdFcVatX+Bpo/mtUZrSFSnbAw2+LH9eql6bqW2b+74IhBcjVkV6cIVcQPPadAY7X1a7LkGWtPv4oRMPodbdEXYJKtYz6vREg+2TZWDcYeCXOa78LSIXCJ8GTfQHxjU307NWMTMiSmnuq1adNB3pDD2h65Lk8sErXU8UCRFQIptZN9hTX6y54vE8AYH3M8Lo0VFLYgrhPNxKkAKFb3SeD4UdeSwRsZA5YQn2WjQ7zd0GwiyNN93gF6cRZsPbUOJM3ieqcqlgNn60+1rcW7ZpSiNckx2wWIEi4ZBU5lJ/pxyCMZGrSY/nHSWdjbmc4lAoGLGZNbCNTcWD1KPy04CeRuVOxZG4OXd5d68sB806OFOqsriCEsJ3LD6gghIGL1k+Vq4ZPGGRBWCMsVxqjaqryLBs+bz2EN2V6tO90ZXqQNyTDC41YWfHaS4UPqeHNR5LI76YcNPMOvTKauipmhjqLRrlDU7O6SJDv4sjnIO1dnu3D0S7LN85odqp0NUaixbQVqaN4rg8pNbAK5PnL3gAqCaGW9Z0QsCAKk10Wmcoa4MmGUuE22zh1AvICxAswWDe6EgKzRjebhLEpMuPFu80R7C2Iv95uJs3HtTzT3cf8VDE4Sd3WmNq2JaSdUD+D1PMwVbAowGtZJ5JwMWtMn9Pxrn8COfagVHud6s8uA8MFA82MKkDUvEnvjObO6NkUh4+7JMC7OeINP80UvgKhho+5ytHDqdRl+OBUoZTr9KDnLOaZMB5eQCS2rtv9PEg3tJLLesrbL4rxhL4GwX7st19sEsxMeuDTsfDSQMN0cjTEZDaJHwCcYTzC3s0RtVs3O2cQYsA/Um9qJcrGALNpZNpaqARfYwFwpAasSnH9ZWvEcErLsIGdMrxLiirA9HJ2LYaZsgCi0UJwy1gufZxSGscmTrtLgwLwG613uWr9+axFchz5+SHmdAQnf+d1ZSGB3QZybAEAVy1b+PvNEezS+keyFtc3Gp+Z8A6OfPD0+/k68mAuff4Dg1wc0SRHTukPDHJxhO62jafeBwZ5NUeU1UcG+Tlr9fTkIw+tqyNWIPc+8mS/OiLoNf4jX34vjtBPMyFpix8X5HrVSpLkQ4NUjmD/nZX9mrU+KshfcYRloRLD/juw31y1aqOp9jMIJnLl7hsDJI0BhcIrMVMDKnVa7K2wLvJlFeEizMFI+Ez3umrxYSL/vCAS7QaNk8Rjw+IwZ8pGbzaiMEGCSg2dh18IF0cuSxa1wV7Ont/zdwe57mstm/OVfQYhjEkaaawhyE7Pn02UPEHFgWIRQPh9BeELEFbjOLrvjHmcerQYgsKBxCj0QRmwJFWX5QVW1NTEzuSPwskRrKYMOKhE9/sUCffJWr3Fs38GYcP1Su/z4X41ayrxOBhHVMcUBJWsJbHgUoIkLg22Iwj5TLQTYdLwPZrB+mZ7LMQmrumr54lY1JUsQil/PrML0nCSeARUegwnEn2nrGXudOQMAj1huCCg5wxNtB2OM6mOoHZzKBCttInw/CQPHRuN0sF2aBW1lbvSyHUXgrFDGiuWU4fpAIo67EMlwlopHHCree+6byzW3MMUv48jjWSv8GeQjqqoaFkpN6zP51GnbZoziW8JGgAWv8xUsIQlNPR2UZFg7iT52DMbMxDXCNdstxUoqtKEi9YFCClMPIWBUty0a65bzH0cQfl168bK3pjbhOo0U32UxheQQzzXkKRONnQl8UhbXnqzPAvUE8jGghvaNBkAohlnWjUxqDyaPi+Ju/yGaCzM5zb/S5BWpEE/GQWSK19ABKkzifylbEjDoi68FBmtDWFP/zpIEBgI4bqNVa8CoYdRc7cg7uGIHcjjOnVjQXxs8qCNLFeIgxbRn/IAxGhE1kxNCwPZJLS5HDeBTljb4XohEA4FNzTQg0d0EkvO+dxJI57Ibe4uK7uIiL+5suMEQ36+lXU6QnI4/L64goBkmVcHWO6eWQvFPmxoPDlyRcEujjA0IpIc/4vdfoKB3xXOwu8LI++YfmlbvIBo27pKUIajEgVU+3r1Z9T+ZaC8Fe63L4gkbVb//hYP0eXv5wg9F7QLyMNwaTP8NHN4AHayZK4pKyzPsTf9HKQ9RC9NZV3lKxN+XVRWu1v65bubZ+sKIltQyJnzQNhmnwMgkP4jhcZyH2FwP84HCF/mK7HIWh0Wp5smUTDQj4rNKMf+gCBR42UwOBvH9wedGnHOV2hHUdATiM8yGgcaip/eGX+/lb2Xb3e3QKil1GCBfIgkvUVJz1LUY0fZyjEUQ+RYZUqFsTBiFUGgAChPklJR+SQJBm4FnxyHhlJTPZDSAePGkofCKJCDgZY9IF6PCmmoN/n6OO5a5Hs5Ikr16U0QwpKjAmcsDeIYiSVFdwhfyAaamrT7qjefz0b1zKdaeQ+IVqYoK0Q5DhYr4rUjE0cOpITVOqOXHjkTBj2WyrpxndfyAczqyHiuzDzsvRxR1tP6ui3+EqSqTcmFUECEsKgoZ6YAvSiZyWEkjbuR3obesAdUEgF1QJR1UTXSPsJFE9NzFhJf63afVHzWYACo/VEQ4QyiN6CbWO/lSJjNdi8O9RZkOxfLSoEqLYjt5ATiCTzX1QFoL89mk5Y0W7amdAEyAHErEP6qADkFFdJ6In8GaY4akr1yFscrSJYqDJU9XkAm/rvta9l2a/Pw2dAyE1djyooEQWMonUHIxVixqfqmmw59L1YGFgAie7Y4zQ3v6QwC2qE9wC4gAK6k7Nqd1QUk63v6AzvzHvYhMo76ev3dvkMspAT0ZyBWkC+xqnbqwXNMdRsw7WLQCfKtNo3QRR18IZsl5Gl+SDbWCtInaiCBKFGACdnwEUp1XYB+nZ3M0n2PFPo4ADqx8KiFucNZNqXzVS758F6OVPUCUhsbGleufHgZs3Ac6bcDj/w5eHGvUhNZBi+C5Pt9L8OvUYwg4K2Iz/aaqvfh9UWZ4t7NEezVvpYvDz0eXol18llE/0426ed5+gB/VphTK+p7OlK1SgtYjPuTd7GJlZVk9TqeBAL+kNjy/zHExd7f7Bdn/oAjlG/b1BmEd73OnyJh1bp38otrI1AIizh0AaWIS70tvPtAFvjTop4YuFqvPKpp+K2PS2v+gZXdTbaRfwahuwePhT8hZlCfPQAQo7x26tDdXRIX3r3U20Jyt6jU2AXy0bqGOdZx2Fu9NGZ/IGvFutm6XrVAXrIAGG1RHIkhvogDLvoIhrMEEBhJUBZNwOkHFEmyiEYAgCU/AFnLXChESHVcOfgIA1znR01jysrilI+wJBC0pXEAbNWRKdhFHcU2Sed1DeUncdEZTjWaZbyzaObcDc5bPs8YO6Q4AgyCFFW0fPaWI/K88OMtiBrrnkaYY71LkVY91Q3eVaDtEr1oLHRIepmOTYIy5KB/BmGNHVlm200PVz6lqQq09KMgaGXtoZIuuxy40ligAdwuevJOX2AAIMwx1DxmQgORDmmo8QuphQGnpnpinbsRW3raYhZPslQjuZE8UcjivCT3liPpzOnzb0Fou7k36fG2mbWocapIsSV4EAoYaivBmKrn7WiImPnU21MVCJ3Uy6HcP5C48tQ3PjFs80dFYcvKa8dm49hnjqZdYwGEfeeEnCtFxcYGMIOdoCBiLDeHDmvFMQ1EOFSClC+7odsr1bKZxaE53yPKyl0eKNyqeU/sDUceF+mx9gaERNxWNvf1BkieUWatCgTlHxfBWBW60NCVIhOHw2YFggSjcmQ5QvVbyQsC7I8AUFX6QIEUsXmq8tdd8kXsF7Up1QDKu7awKsxBV6DL2yVHervshrI2ic2c5ggih1EMaLrEO4v6yv/ckUrPLeoCEnRZQPNZuI+sAkSoL+IRHk38sh862Uf6rCl0cUNvy8O6t/TPjkjlpBb3DxXIsfc5iLBlieU+twCq1TJdYkV1Iv4CUmUgWJ5BpmnVDUVqwsEhChBfirYTwFJz8LSNjtaN3fiePViPxAuIk/QYf2aIQViB9PRA1SXRielk0hMksZX26rO2MLNDWRMvkx2LdByArK3wKwj5IyIyp0ox9PGRzqaEJtKrEQMthwLgM+WEOTbK6VKnMP4M0ojbGFaCVN3QGC02d5qxo5r5g5vR/U2tmVGdZ+uXjiC7zUpGrkNrkK9N0dlnB1OLVYhabC2ZrSS2P1wfHMzI9htdpOez9Qz15dVeJysQUlkPAIjCesAfZgB7C0BabdplbVgvq1WKodluk3cAnKwoDckCgHZQZrLF7jnpzLvQmqOgBQeh1kqgPaHKbijlsNsLjHrcb5aAxavnOtD5Jnumb+1r9ejX+1ocWzQYlIBKJEoXppLnAIVjLFkcQeZygcBhePG3fwonmJmLwO+Uz5buqtIH5NQkebZ8/1O+kk0UALZbCqrlnXu9i1Y1qm6KHtDTAaZKUAxXpgkU/7V9rQrkljDpsI7fRNTiErLWO1CJdzbZqc2aU/D3t0MBlVPwVpyukkWVRiTc1rWbP/kdIvbLL0PJ21yA47/1UyAASPgndPOqRdG/dMRH8DMR9mZPzVa5olI95nb06nIkjcC9dTtr8b1FSP8CRKoTVQOdTDC4ivWk298hVtDtZyC6mQZ31m1H2rvZ/ArCFYdI4AgsPYOQmHvygCSwche1lxin9lLnz0cYDCUAJ1CUrW7kLliTHIe76rYj4saxxevQaieprhDtRF7NKxDKPMUf0OqypwHAVNaANtP9TKRa8sQG7FEYO2IRiHSvylpFpZ96cH9dQS4avMixKl5AJNlVeHEfjPZRBcIOAokGQhgaQ4EAxkxJor4bBTPazIxkhyqHqF9j1YNqPwCQnePJNXbiwP11BblYYqzm9ReVv4B0MYCG3P95jpTxpzaZwnQyAD/pglVlraLly10vhUIPh4QCAGJeTiCipcNddduRRuajwzn9GqQVDz4DUSYqNCcKNGUbbMEoQNSianE0F8qJZMVPk0vwAMIL4K667Yh9rPGbJfUapLdb4CcQtW4BwGJiAS3VuVCiOVPHgXckrqF3nJjrZ4/GRqyWFfpIk/axfA90uIT76wpylfP0VJjyGgS8w9OniABn3QQAa/giI83Np00TOo5XxrGXlxytZZ+OdRAnn467tvtyeJrhhCeX9tQ+EXA33Xak8oTSfnErIF5eWjmmPIazTFGIot2SB9XGFYZft7g4ngFgTkGYz0/coG3ubMhtR7A/cZsTLow5+HX5T0w5Q0y4v64g3/Lof+DeeBL54nsERT/6I64A7DfFs7rax39WF/vy9NTxD7WP/vT0l19/9cX/+qm9e91REwjAMNzTnw+YgzOMoiKCIIjgCdGqtWratE16/xdUFNvapkfb7tLWN3EzjjHjE5nFTSRbvR49fqj9wZ48uqNFHj00GP5czHx4R4s8MfEHK6+Zv5NFNIY/G3v44E4W0fCn0x7cxSI3yA1SdoPcIDfIPUJyGwgdBCGD1gZoaJC5k7q+L8B91/WHL7K267prayuBpJg3KwrRYip3BhlM6qj5gOHVld8LXcehkM5275jreT31ciE8CbiR45CKQqjnpAdwdz3/BKIJBiDZAsUD+tjCGaKJqh5aiGz/BWxbG6gLyNieky9BBnatshDemZhq53kTcYRQT9CBFkYAvgR5Xd3NDjXeIxgbMopqk/1e2N7ep2FcjD6D7Ab7nlvMV3WzA1JCSYBRQillhBoS6jg6f1GKHm8Exwflab6ykIv+5vPI+26QG+QGuUFukM/7ZyCMlT9YGRg7T5e3L1ef61WDiGcuIBe74Wx0LDb7oxBAMmqYfLTGVwpWSdUgTvcA0Naz+tZ/thn7ttlcPSNQ01XLEKvBXwihwGDjALTZ2mjgy+UnkHzacCmMQ2OaA9pucSgg5r6xG1YJ0g/DbfcC0m/uEY2ml5BhdxF3XdVfeo1NZi5a49kqUfFy/KxVrxCk22yONheQN/um0Y/jS8h4YzE36m1c8FZH67osXyXWZifaq6RCEI9S6/LQWjjd7TLvXEImLRNAvmmDzvrZyj7ukd5qOZu1wgpBPt8jDdXozszdJWTQ5QjDoLuHaMViM0D5jpimJqsMwXp1ICfILI5jASBtTcebgeyPBv1ujS5ah8YqkZ2lN27wykD4NAHobiwBe2oBND5ATAXZd2i9f8wBwNpvFq4C9xZv5gTOtO9PU+j7Rd8mlYH8YITgmGKX38wlrELnkV/t+xBJ8LORCkKsyWSaX7JclLHI/KojJpWDiKkCBgk+5K5QZnc5vtaROJC/DtGH5HdBFiaKdgTnAreDU9xbc3wr79chrNMyfxNE+TgWK5TRQ72EkIGzPUO03klogKbcHY8zAO3C+bwTAuIQxy6tAATsxOngnJejHG9tvIfU1ijyOYzpVCempwMdEHNimZA7S8nXY3Y9JOnsdSDwOi9ADu1DCHHoJPLavxAHQ5QND6SE6Dv5BciKAgiHBeR8aFkuitbG1ZDNaLbxoS1HzU0gV93uQTRbzU37SkjmoUy9kUDntIKFL0BiFOXJR4gZc/ZLe2RpGaO+2nW5vpzKVUMYg01gPFuoqyD6hKEsrqF8kdsQX4IMUJTaHyEQAy/iv7TZ5bMGHW0ajWVDrjygXwxbM3oNhC0kysTb5mw2ezV7I1+OisHT5Ux9D1LkTMJfhTzrpkE6PEGm3XkxJFdAVJ/jsvNmv3hHch9F+y9BCDs9Q/81CItWcTJ9cYK83kzt2L7mt5an4TsQHgNgz8XnEApkEYp2V0MOCyqnExC/2XrD5cgFmD1r9YMrIO5yfUygNv8aBNvxa7vzjH8GWbsZIR13bk9sBt+6AvLli++ZZLgCQvkpCmKgjOKcIjjH855J3z+kZDlQvUCB8TzTAZgKuPdPv+f+/o/xx26QG+QGuUFukM+7Qf5TCGGf3f+7IJoBDCmKehyfFBhf+fd5lKngEzHFh8x7g/A2sAYzGFKhCCRAZfnq8joUBYjBTnNEUapIcY+1BTEAqsCUKQE4CYGkOD5b2vp9QVSk6gnZphFNRU0gkdk8EQCNauu6ZddqKkxtmbUTZ7jtpVYW1kJ9WzMi1n4data6FpnAfG2K9jwwbb3N/eC+IEi1pA4rW1slRI/SWhtIh8it9uvMV2HPMG0wfeggtQIOW3+t04hGILaYIxCANUdYyyOkPkUi7w1Cwwg00pMCkjuw9VBwHchOkB7npO7Yug3QzyAh1AXE1jhHvjXvE8LCAHKb+zwVVpj6Mp+nHDDtNKpzO8hoO5vT9MULcYJkdmaT4HRo5cnQKiE01LUk04xEn5N2dm8QGASoc0NSwizLAOEWw3FGL+4LCV2YUMUcJaAkCwSF5Oo4VYcyQQkArpjFiaTQYdbv/zzyI2n1yp5Hfi5W3RPiJ/0bH1H+RshDhj8bKyB3schjA3+w8pr5O1nk0UOT4Y9VXjN/R4s80f5c5TXzd7HIO9jqsoYL1vqIAAAAAElFTkSuQmCC", - "description": "Visualize HTML based on a configurable template or function and device attributes or time-series values.", + "description": "Visualize HTML based on a configurable template or function and device attributes or time series values.", "order": 21000, "externalId": null, "name": "HTML widgets" diff --git a/application/src/main/data/json/system/widget_bundles/scada_symbols.json b/application/src/main/data/json/system/widget_bundles/scada_symbols.json new file mode 100644 index 0000000000..f1fbfbc297 --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/scada_symbols.json @@ -0,0 +1,14 @@ +{ + "widgetsBundle": { + "alias": "scada_symbols", + "title": "SCADA symbols", + "scada": true, + "image": null, + "description": "Bundle with SCADA symbols", + "order": 9200, + "name": "SCADA symbols" + }, + "widgetTypeFqns": [ + "scada_symbol" + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/scada_water_system_symbols.json b/application/src/main/data/json/system/widget_bundles/scada_water_system_symbols.json new file mode 100644 index 0000000000..1ec69037ae --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/scada_water_system_symbols.json @@ -0,0 +1,67 @@ +{ + "widgetsBundle": { + "alias": "scada_water_system_symbols", + "title": "SCADA water system symbols", + "scada": true, + "image": null, + "description": "Bundle with SCADA symbols for water system", + "order": 9300, + "name": "SCADA water system symbols" + }, + "widgetTypeFqns": [ + "horizontal_pipe", + "long_horizontal_pipe", + "vertical_pipe", + "long_vertical_pipe", + "left_bottom_elbow_pipe", + "bottom_right_elbow_pipe", + "top_right_elbow_pipe", + "left_top_elbow_pipe", + "cross_pipe", + "left_tee_pipe", + "bottom_tee_pipe", + "right_tee_pipe", + "top_tee_pipe", + "right_elbow_drain_pipe", + "left_elbow_drain_pipe", + "left_drain_pipe", + "right_drain_pipe", + "short_left_drain_pipe", + "short_right_drain_pipe", + "top_flow_meter", + "right_flow_meter", + "bottom_flow_meter", + "left_flow_meter", + "horizontal_inline_flow_meter", + "vertical_inline_flow_meter", + "centrifugal_pump", + "small_right_motor_pump", + "small_left_motor_pump", + "right_motor_pump", + "left_motor_pump", + "short_bottom_filter", + "long_bottom_filter", + "short_top_filter", + "long_top_filter", + "horizontal_wheel_valve", + "vertical_wheel_valve", + "horizontal_ball_valve", + "vertical_ball_valve", + "vertical_tank", + "stand_vertical_tank", + "cylindrical_tank", + "stand_cylindrical_tank", + "vertical_short_tank", + "stand_vertical_short_tank", + "large_cylindrical_tank", + "large_stand_cylindrical_tank", + "large_vertical_tank", + "large_stand_vertical_tank", + "horizontal_tank", + "stand_horizontal_tank", + "spherical_tank", + "small_spherical_tank", + "elevated_tank", + "pool" + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/bar_chart.json b/application/src/main/data/json/system/widget_types/bar_chart.json index 264c314686..a28d086a5b 100644 --- a/application/src/main/data/json/system/widget_types/bar_chart.json +++ b/application/src/main/data/json/system/widget_types/bar_chart.json @@ -3,7 +3,7 @@ "name": "Bar chart", "deprecated": false, "image": "tb-image:Y2hhcnRfKDIpLnN2Zw==:IkJhciBjaGFydCIgc3lzdGVtIHdpZGdldCBpbWFnZQ==;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF80MTgzXzkwODc3KSI+CjxwYXRoIGQ9Ik0yLjg0NzY2IDEuMjgxMjVWN0gyLjEyNVYyLjE4MzU5TDAuNjY3OTY5IDIuNzE0ODRWMi4wNjI1TDIuNzM0MzggMS4yODEyNUgyLjg0NzY2Wk04LjY3NTE3IDMuNzAzMTJWNC41NzAzMUM4LjY3NTE3IDUuMDM2NDYgOC42MzM1MSA1LjQyOTY5IDguNTUwMTcgNS43NUM4LjQ2Njg0IDYuMDcwMzEgOC4zNDcwNSA2LjMyODEyIDguMTkwOCA2LjUyMzQ0QzguMDM0NTUgNi43MTg3NSA3Ljg0NTc1IDYuODYwNjggNy42MjQzOSA2Ljk0OTIyQzcuNDA1NjQgNy4wMzUxNiA3LjE1ODI1IDcuMDc4MTIgNi44ODIyIDcuMDc4MTJDNi42NjM0NSA3LjA3ODEyIDYuNDYxNjMgNy4wNTA3OCA2LjI3NjczIDYuOTk2MDlDNi4wOTE4NCA2Ljk0MTQxIDUuOTI1MTcgNi44NTQxNyA1Ljc3NjczIDYuNzM0MzhDNS42MzA5IDYuNjExOTggNS41MDU5IDYuNDUzMTIgNS40MDE3MyA2LjI1NzgxQzUuMjk3NTcgNi4wNjI1IDUuMjE4MTQgNS44MjU1MiA1LjE2MzQ1IDUuNTQ2ODhDNS4xMDg3NyA1LjI2ODIzIDUuMDgxNDIgNC45NDI3MSA1LjA4MTQyIDQuNTcwMzFWMy43MDMxMkM1LjA4MTQyIDMuMjM2OTggNS4xMjMwOSAyLjg0NjM1IDUuMjA2NDIgMi41MzEyNUM1LjI5MjM2IDIuMjE2MTUgNS40MTM0NSAxLjk2MzU0IDUuNTY5NyAxLjc3MzQ0QzUuNzI1OTUgMS41ODA3MyA1LjkxMzQ1IDEuNDQyNzEgNi4xMzIyIDEuMzU5MzhDNi4zNTM1NiAxLjI3NjA0IDYuNjAwOTUgMS4yMzQzOCA2Ljg3NDM5IDEuMjM0MzhDNy4wOTU3NSAxLjIzNDM4IDcuMjk4ODcgMS4yNjE3MiA3LjQ4Mzc3IDEuMzE2NDFDNy42NzEyNyAxLjM2ODQ5IDcuODM3OTMgMS40NTMxMiA3Ljk4Mzc3IDEuNTcwMzFDOC4xMjk2IDEuNjg0OSA4LjI1MzMgMS44Mzg1NCA4LjM1NDg2IDIuMDMxMjVDOC40NTkwMyAyLjIyMTM1IDguNTM4NDUgMi40NTQ0MyA4LjU5MzE0IDIuNzMwNDdDOC42NDc4MyAzLjAwNjUxIDguNjc1MTcgMy4zMzA3MyA4LjY3NTE3IDMuNzAzMTJaTTcuOTQ4NjEgNC42ODc1VjMuNTgyMDNDNy45NDg2MSAzLjMyNjgyIDcuOTMyOTggMy4xMDI4NiA3LjkwMTczIDIuOTEwMTZDNy44NzMwOSAyLjcxNDg0IDcuODMwMTIgMi41NDgxOCA3Ljc3MjgzIDIuNDEwMTZDNy43MTU1NCAyLjI3MjE0IDcuNjQyNjIgMi4xNjAxNiA3LjU1NDA4IDIuMDc0MjJDNy40NjgxNCAxLjk4ODI4IDcuMzY3ODggMS45MjU3OCA3LjI1MzMgMS44ODY3MkM3LjE0MTMyIDEuODQ1MDUgNy4wMTUwMiAxLjgyNDIyIDYuODc0MzkgMS44MjQyMkM2LjcwMjUyIDEuODI0MjIgNi41NTAxNyAxLjg1Njc3IDYuNDE3MzYgMS45MjE4OEM2LjI4NDU1IDEuOTg0MzggNi4xNzI1NyAyLjA4NDY0IDYuMDgxNDIgMi4yMjI2NkM1Ljk5Mjg4IDIuMzYwNjggNS45MjUxNyAyLjU0MTY3IDUuODc4MyAyLjc2NTYyQzUuODMxNDIgMi45ODk1OCA1LjgwNzk4IDMuMjYxNzIgNS44MDc5OCAzLjU4MjAzVjQuNjg3NUM1LjgwNzk4IDQuOTQyNzEgNS44MjIzMSA1LjE2Nzk3IDUuODUwOTUgNS4zNjMyOEM1Ljg4MjIgNS41NTg1OSA1LjkyNzc4IDUuNzI3ODYgNS45ODc2NyA1Ljg3MTA5QzYuMDQ3NTcgNi4wMTE3MiA2LjEyMDQ4IDYuMTI3NiA2LjIwNjQyIDYuMjE4NzVDNi4yOTIzNiA2LjMwOTkgNi4zOTEzMiA2LjM3NzYgNi41MDMzIDYuNDIxODhDNi42MTc4OCA2LjQ2MzU0IDYuNzQ0MTggNi40ODQzOCA2Ljg4MjIgNi40ODQzOEM3LjA1OTI5IDYuNDg0MzggNy4yMTQyMyA2LjQ1MDUyIDcuMzQ3MDUgNi4zODI4MUM3LjQ3OTg2IDYuMzE1MSA3LjU5MDU0IDYuMjA5NjQgNy42NzkwOCA2LjA2NjQxQzcuNzcwMjIgNS45MjA1NyA3LjgzNzkzIDUuNzM0MzggNy44ODIyIDUuNTA3ODFDNy45MjY0NyA1LjI3ODY1IDcuOTQ4NjEgNS4wMDUyMSA3Ljk0ODYxIDQuNjg3NVpNMTMuMzA3NCAzLjcwMzEyVjQuNTcwMzFDMTMuMzA3NCA1LjAzNjQ2IDEzLjI2NTcgNS40Mjk2OSAxMy4xODI0IDUuNzVDMTMuMDk5IDYuMDcwMzEgMTIuOTc5MyA2LjMyODEyIDEyLjgyMyA2LjUyMzQ0QzEyLjY2NjggNi43MTg3NSAxMi40Nzc5IDYuODYwNjggMTIuMjU2NiA2Ljk0OTIyQzEyLjAzNzggNy4wMzUxNiAxMS43OTA0IDcuMDc4MTIgMTEuNTE0NCA3LjA3ODEyQzExLjI5NTcgNy4wNzgxMiAxMS4wOTM4IDcuMDUwNzggMTAuOTA4OSA2Ljk5NjA5QzEwLjcyNCA2Ljk0MTQxIDEwLjU1NzQgNi44NTQxNyAxMC40MDg5IDYuNzM0MzhDMTAuMjYzMSA2LjYxMTk4IDEwLjEzODEgNi40NTMxMiAxMC4wMzM5IDYuMjU3ODFDOS45Mjk3NyA2LjA2MjUgOS44NTAzNCA1LjgyNTUyIDkuNzk1NjYgNS41NDY4OEM5Ljc0MDk3IDUuMjY4MjMgOS43MTM2MyA0Ljk0MjcxIDkuNzEzNjMgNC41NzAzMVYzLjcwMzEyQzkuNzEzNjMgMy4yMzY5OCA5Ljc1NTI5IDIuODQ2MzUgOS44Mzg2MyAyLjUzMTI1QzkuOTI0NTYgMi4yMTYxNSAxMC4wNDU3IDEuOTYzNTQgMTAuMjAxOSAxLjc3MzQ0QzEwLjM1ODIgMS41ODA3MyAxMC41NDU3IDEuNDQyNzEgMTAuNzY0NCAxLjM1OTM4QzEwLjk4NTggMS4yNzYwNCAxMS4yMzMyIDEuMjM0MzggMTEuNTA2NiAxLjIzNDM4QzExLjcyNzkgMS4yMzQzOCAxMS45MzExIDEuMjYxNzIgMTIuMTE2IDEuMzE2NDFDMTIuMzAzNSAxLjM2ODQ5IDEyLjQ3MDEgMS40NTMxMiAxMi42MTYgMS41NzAzMUMxMi43NjE4IDEuNjg0OSAxMi44ODU1IDEuODM4NTQgMTIuOTg3MSAyLjAzMTI1QzEzLjA5MTIgMi4yMjEzNSAxMy4xNzA3IDIuNDU0NDMgMTMuMjI1MyAyLjczMDQ3QzEzLjI4IDMuMDA2NTEgMTMuMzA3NCAzLjMzMDczIDEzLjMwNzQgMy43MDMxMlpNMTIuNTgwOCA0LjY4NzVWMy41ODIwM0MxMi41ODA4IDMuMzI2ODIgMTIuNTY1MiAzLjEwMjg2IDEyLjUzMzkgMi45MTAxNkMxMi41MDUzIDIuNzE0ODQgMTIuNDYyMyAyLjU0ODE4IDEyLjQwNSAyLjQxMDE2QzEyLjM0NzcgMi4yNzIxNCAxMi4yNzQ4IDIuMTYwMTYgMTIuMTg2MyAyLjA3NDIyQzEyLjEwMDMgMS45ODgyOCAxMi4wMDAxIDEuOTI1NzggMTEuODg1NSAxLjg4NjcyQzExLjc3MzUgMS44NDUwNSAxMS42NDcyIDEuODI0MjIgMTEuNTA2NiAxLjgyNDIyQzExLjMzNDcgMS44MjQyMiAxMS4xODI0IDEuODU2NzcgMTEuMDQ5NiAxLjkyMTg4QzEwLjkxNjggMS45ODQzOCAxMC44MDQ4IDIuMDg0NjQgMTAuNzEzNiAyLjIyMjY2QzEwLjYyNTEgMi4zNjA2OCAxMC41NTc0IDIuNTQxNjcgMTAuNTEwNSAyLjc2NTYyQzEwLjQ2MzYgMi45ODk1OCAxMC40NDAyIDMuMjYxNzIgMTAuNDQwMiAzLjU4MjAzVjQuNjg3NUMxMC40NDAyIDQuOTQyNzEgMTAuNDU0NSA1LjE2Nzk3IDEwLjQ4MzIgNS4zNjMyOEMxMC41MTQ0IDUuNTU4NTkgMTAuNTYgNS43Mjc4NiAxMC42MTk5IDUuODcxMDlDMTAuNjc5OCA2LjAxMTcyIDEwLjc1MjcgNi4xMjc2IDEwLjgzODYgNi4yMTg3NUMxMC45MjQ2IDYuMzA5OSAxMS4wMjM1IDYuMzc3NiAxMS4xMzU1IDYuNDIxODhDMTEuMjUwMSA2LjQ2MzU0IDExLjM3NjQgNi40ODQzOCAxMS41MTQ0IDYuNDg0MzhDMTEuNjkxNSA2LjQ4NDM4IDExLjg0NjQgNi40NTA1MiAxMS45NzkzIDYuMzgyODFDMTIuMTEyMSA2LjMxNTEgMTIuMjIyNyA2LjIwOTY0IDEyLjMxMTMgNi4wNjY0MUMxMi40MDI0IDUuOTIwNTcgMTIuNDcwMSA1LjczNDM4IDEyLjUxNDQgNS41MDc4MUMxMi41NTg3IDUuMjc4NjUgMTIuNTgwOCA1LjAwNTIxIDEyLjU4MDggNC42ODc1Wk0xNC4zMDY4IDIuNzA3MDNWMi40MDYyNUMxNC4zMDY4IDIuMTkwMSAxNC4zNTM2IDEuOTkzNDkgMTQuNDQ3NCAxLjgxNjQxQzE0LjU0MTEgMS42MzkzMiAxNC42NzUzIDEuNDk3NCAxNC44NDk3IDEuMzkwNjJDMTUuMDI0MiAxLjI4Mzg1IDE1LjIzMTIgMS4yMzA0NyAxNS40NzA4IDEuMjMwNDdDMTUuNzE1NiAxLjIzMDQ3IDE1LjkyNCAxLjI4Mzg1IDE2LjA5NTggMS4zOTA2MkMxNi4yNzAzIDEuNDk3NCAxNi40MDQ0IDEuNjM5MzIgMTYuNDk4MiAxLjgxNjQxQzE2LjU5MTkgMS45OTM0OSAxNi42Mzg4IDIuMTkwMSAxNi42Mzg4IDIuNDA2MjVWMi43MDcwM0MxNi42Mzg4IDIuOTE3OTcgMTYuNTkxOSAzLjExMTk4IDE2LjQ5ODIgMy4yODkwNkMxNi40MDcgMy40NjYxNSAxNi4yNzQyIDMuNjA4MDcgMTYuMDk5NyAzLjcxNDg0QzE1LjkyNzkgMy44MjE2MSAxNS43MjA4IDMuODc1IDE1LjQ3ODYgMy44NzVDMTUuMjM2NSAzLjg3NSAxNS4wMjY4IDMuODIxNjEgMTQuODQ5NyAzLjcxNDg0QzE0LjY3NTMgMy42MDgwNyAxNC41NDExIDMuNDY2MTUgMTQuNDQ3NCAzLjI4OTA2QzE0LjM1MzYgMy4xMTE5OCAxNC4zMDY4IDIuOTE3OTcgMTQuMzA2OCAyLjcwNzAzWk0xNC44NDk3IDIuNDA2MjVWMi43MDcwM0MxNC44NDk3IDIuODI2ODIgMTQuODcxOSAyLjk0MDEgMTQuOTE2MSAzLjA0Njg4QzE0Ljk2MyAzLjE1MzY1IDE1LjAzMzMgMy4yNDA4OSAxNS4xMjcxIDMuMzA4NTlDMTUuMjIwOCAzLjM3MzcgMTUuMzM4IDMuNDA2MjUgMTUuNDc4NiAzLjQwNjI1QzE1LjYxOTMgMy40MDYyNSAxNS43MzUyIDMuMzczNyAxNS44MjYzIDMuMzA4NTlDMTUuOTE3NCAzLjI0MDg5IDE1Ljk4NTIgMy4xNTM2NSAxNi4wMjk0IDMuMDQ2ODhDMTYuMDczNyAyLjk0MDEgMTYuMDk1OCAyLjgyNjgyIDE2LjA5NTggMi43MDcwM1YyLjQwNjI1QzE2LjA5NTggMi4yODM4NSAxNi4wNzI0IDIuMTY5MjcgMTYuMDI1NSAyLjA2MjVDMTUuOTgxMiAxLjk1MzEyIDE1LjkxMjIgMS44NjU4OSAxNS44MTg1IDEuODAwNzhDMTUuNzI3MyAxLjczMzA3IDE1LjYxMTUgMS42OTkyMiAxNS40NzA4IDEuNjk5MjJDMTUuMzMyOCAxLjY5OTIyIDE1LjIxNjkgMS43MzMwNyAxNS4xMjMyIDEuODAwNzhDMTUuMDMyIDEuODY1ODkgMTQuOTYzIDEuOTUzMTIgMTQuOTE2MSAyLjA2MjVDMTQuODcxOSAyLjE2OTI3IDE0Ljg0OTcgMi4yODM4NSAxNC44NDk3IDIuNDA2MjVaTTE3LjA3NjMgNS45MTAxNlY1LjYwNTQ3QzE3LjA3NjMgNS4zOTE5MyAxNy4xMjMyIDUuMTk2NjEgMTcuMjE2OSA1LjAxOTUzQzE3LjMxMDcgNC44NDI0NSAxNy40NDQ4IDQuNzAwNTIgMTcuNjE5MyA0LjU5Mzc1QzE3Ljc5MzcgNC40ODY5OCAxOC4wMDA4IDQuNDMzNTkgMTguMjQwNCA0LjQzMzU5QzE4LjQ4NTIgNC40MzM1OSAxOC42OTM1IDQuNDg2OTggMTguODY1NCA0LjU5Mzc1QzE5LjAzOTggNC43MDA1MiAxOS4xNzQgNC44NDI0NSAxOS4yNjc3IDUuMDE5NTNDMTkuMzYxNSA1LjE5NjYxIDE5LjQwODMgNS4zOTE5MyAxOS40MDgzIDUuNjA1NDdWNS45MTAxNkMxOS40MDgzIDYuMTIzNyAxOS4zNjE1IDYuMzE5MDEgMTkuMjY3NyA2LjQ5NjA5QzE5LjE3NjYgNi42NzMxOCAxOS4wNDM3IDYuODE1MSAxOC44NjkzIDYuOTIxODhDMTguNjk3NCA3LjAyODY1IDE4LjQ5MDQgNy4wODIwMyAxOC4yNDgyIDcuMDgyMDNDMTguMDA2IDcuMDgyMDMgMTcuNzk3NyA3LjAyODY1IDE3LjYyMzIgNi45MjE4OEMxNy40NDg3IDYuODE1MSAxNy4zMTMzIDYuNjczMTggMTcuMjE2OSA2LjQ5NjA5QzE3LjEyMzIgNi4zMTkwMSAxNy4wNzYzIDYuMTIzNyAxNy4wNzYzIDUuOTEwMTZaTTE3LjYxOTMgNS42MDU0N1Y1LjkxMDE2QzE3LjYxOTMgNi4wMjk5NSAxNy42NDE0IDYuMTQ0NTMgMTcuNjg1NyA2LjI1MzkxQzE3LjczMjUgNi4zNjA2OCAxNy44MDI5IDYuNDQ3OTIgMTcuODk2NiA2LjUxNTYyQzE3Ljk5MDQgNi41ODA3MyAxOC4xMDc1IDYuNjEzMjggMTguMjQ4MiA2LjYxMzI4QzE4LjM4ODggNi42MTMyOCAxOC41MDQ3IDYuNTgwNzMgMTguNTk1OCA2LjUxNTYyQzE4LjY4OTYgNi40NDc5MiAxOC43NTg2IDYuMzYwNjggMTguODAyOSA2LjI1MzkxQzE4Ljg0NzEgNi4xNDcxNCAxOC44NjkzIDYuMDMyNTUgMTguODY5MyA1LjkxMDE2VjUuNjA1NDdDMTguODY5MyA1LjQ4MzA3IDE4Ljg0NTggNS4zNjg0OSAxOC43OTkgNS4yNjE3MkMxOC43NTQ3IDUuMTU0OTUgMTguNjg1NyA1LjA2OTAxIDE4LjU5MTkgNS4wMDM5MUMxOC41MDA4IDQuOTM2MiAxOC4zODM2IDQuOTAyMzQgMTguMjQwNCA0LjkwMjM0QzE4LjEwMjMgNC45MDIzNCAxNy45ODY1IDQuOTM2MiAxNy44OTI3IDUuMDAzOTFDMTcuODAxNiA1LjA2OTAxIDE3LjczMjUgNS4xNTQ5NSAxNy42ODU3IDUuMjYxNzJDMTcuNjQxNCA1LjM2ODQ5IDE3LjYxOTMgNS40ODMwNyAxNy42MTkzIDUuNjA1NDdaTTE4LjQyIDIuMTIxMDlMMTUuNjQyNyA2LjU2NjQxTDE1LjIzNjUgNi4zMDg1OUwxOC4wMTM4IDEuODYzMjhMMTguNDIgMi4xMjEwOVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPHBhdGggZD0iTTguMDU4NTkgMzMuNjY3N0M4LjA1ODU5IDM0LjAxNDEgNy45Nzc4NiAzNC4zMDgzIDcuODE2NDEgMzQuNTUwNUM3LjY1NzU1IDM0Ljc5MDEgNy40NDE0MSAzNC45NzI0IDcuMTY3OTcgMzUuMDk3NEM2Ljg5NzE0IDM1LjIyMjQgNi41OTExNSAzNS4yODQ5IDYuMjUgMzUuMjg0OUM1LjkwODg1IDM1LjI4NDkgNS42MDE1NiAzNS4yMjI0IDUuMzI4MTIgMzUuMDk3NEM1LjA1NDY5IDM0Ljk3MjQgNC44Mzg1NCAzNC43OTAxIDQuNjc5NjkgMzQuNTUwNUM0LjUyMDgzIDM0LjMwODMgNC40NDE0MSAzNC4wMTQxIDQuNDQxNDEgMzMuNjY3N0M0LjQ0MTQxIDMzLjQ0MTIgNC40ODQzOCAzMy4yMzQxIDQuNTcwMzEgMzMuMDQ2NkM0LjY1ODg1IDMyLjg1NjUgNC43ODI1NSAzMi42OTEyIDQuOTQxNDEgMzIuNTUwNUM1LjEwMjg2IDMyLjQwOTkgNS4yOTI5NyAzMi4zMDE4IDUuNTExNzIgMzIuMjI2M0M1LjczMzA3IDMyLjE0ODIgNS45NzY1NiAzMi4xMDkxIDYuMjQyMTkgMzIuMTA5MUM2LjU5MTE1IDMyLjEwOTEgNi45MDIzNCAzMi4xNzY4IDcuMTc1NzggMzIuMzEyM0M3LjQ0OTIyIDMyLjQ0NTEgNy42NjQwNiAzMi42Mjg3IDcuODIwMzEgMzIuODYzQzcuOTc5MTcgMzMuMDk3NCA4LjA1ODU5IDMzLjM2NTYgOC4wNTg1OSAzMy42Njc3Wk03LjMzMjAzIDMzLjY1MjFDNy4zMzIwMyAzMy40NDEyIDcuMjg2NDYgMzMuMjU1IDcuMTk1MzEgMzMuMDkzNUM3LjEwNDE3IDMyLjkyOTQgNi45NzY1NiAzMi44MDE4IDYuODEyNSAzMi43MTA3QzYuNjQ4NDQgMzIuNjE5NSA2LjQ1ODMzIDMyLjU3NCA2LjI0MjE5IDMyLjU3NEM2LjAyMDgzIDMyLjU3NCA1LjgyOTQzIDMyLjYxOTUgNS42Njc5NyAzMi43MTA3QzUuNTA5MTEgMzIuODAxOCA1LjM4NTQyIDMyLjkyOTQgNS4yOTY4OCAzMy4wOTM1QzUuMjA4MzMgMzMuMjU1IDUuMTY0MDYgMzMuNDQxMiA1LjE2NDA2IDMzLjY1MjFDNS4xNjQwNiAzMy44NzA4IDUuMjA3MDMgMzQuMDU4MyA1LjI5Mjk3IDM0LjIxNDZDNS4zODE1MSAzNC4zNjgyIDUuNTA2NTEgMzQuNDg2NyA1LjY2Nzk3IDM0LjU3MDFDNS44MzIwMyAzNC42NTA4IDYuMDI2MDQgMzQuNjkxMiA2LjI1IDM0LjY5MTJDNi40NzM5NiAzNC42OTEyIDYuNjY2NjcgMzQuNjUwOCA2LjgyODEyIDM0LjU3MDFDNi45ODk1OCAzNC40ODY3IDcuMTEzMjggMzQuMzY4MiA3LjE5OTIyIDM0LjIxNDZDNy4yODc3NiAzNC4wNTgzIDcuMzMyMDMgMzMuODcwOCA3LjMzMjAzIDMzLjY1MjFaTTcuOTI1NzggMzAuOTk5OEM3LjkyNTc4IDMxLjI3NTggNy44NTI4NiAzMS41MjQ1IDcuNzA3MDMgMzEuNzQ1OEM3LjU2MTIgMzEuOTY3MiA3LjM2MTk4IDMyLjE0MTcgNy4xMDkzOCAzMi4yNjkzQzYuODU2NzcgMzIuMzk2OSA2LjU3MDMxIDMyLjQ2MDcgNi4yNSAzMi40NjA3QzUuOTI0NDggMzIuNDYwNyA1LjYzNDExIDMyLjM5NjkgNS4zNzg5MSAzMi4yNjkzQzUuMTI2MyAzMi4xNDE3IDQuOTI4MzkgMzEuOTY3MiA0Ljc4NTE2IDMxLjc0NThDNC42NDE5MyAzMS41MjQ1IDQuNTcwMzEgMzEuMjc1OCA0LjU3MDMxIDMwLjk5OThDNC41NzAzMSAzMC42NjkgNC42NDE5MyAzMC4zODc4IDQuNzg1MTYgMzAuMTU2QzQuOTMwOTkgMjkuOTI0MiA1LjEzMDIxIDI5Ljc0NzIgNS4zODI4MSAyOS42MjQ4QzUuNjM1NDIgMjkuNTAyNCA1LjkyMzE4IDI5LjQ0MTIgNi4yNDYwOSAyOS40NDEyQzYuNTcxNjEgMjkuNDQxMiA2Ljg2MDY4IDI5LjUwMjQgNy4xMTMyOCAyOS42MjQ4QzcuMzY1ODkgMjkuNzQ3MiA3LjU2MzggMjkuOTI0MiA3LjcwNzAzIDMwLjE1NkM3Ljg1Mjg2IDMwLjM4NzggNy45MjU3OCAzMC42NjkgNy45MjU3OCAzMC45OTk4Wk03LjIwMzEyIDMxLjAxMTVDNy4yMDMxMiAzMC44MjE0IDcuMTYyNzYgMzAuNjUzNCA3LjA4MjAzIDMwLjUwNzZDNy4wMDEzIDMwLjM2MTcgNi44ODkzMiAzMC4yNDcyIDYuNzQ2MDkgMzAuMTYzOEM2LjYwMjg2IDMwLjA3NzkgNi40MzYyIDMwLjAzNDkgNi4yNDYwOSAzMC4wMzQ5QzYuMDU1OTkgMzAuMDM0OSA1Ljg4OTMyIDMwLjA3NTMgNS43NDYwOSAzMC4xNTZDNS42MDU0NyAzMC4yMzQxIDUuNDk0NzkgMzAuMzQ2MSA1LjQxNDA2IDMwLjQ5MTlDNS4zMzU5NCAzMC42Mzc4IDUuMjk2ODggMzAuODExIDUuMjk2ODggMzEuMDExNUM1LjI5Njg4IDMxLjIwNjggNS4zMzU5NCAzMS4zNzc0IDUuNDE0MDYgMzEuNTIzMkM1LjQ5NDc5IDMxLjY2OSA1LjYwNjc3IDMxLjc4MjMgNS43NSAzMS44NjNDNS44OTMyMyAzMS45NDM4IDYuMDU5OSAzMS45ODQxIDYuMjUgMzEuOTg0MUM2LjQ0MDEgMzEuOTg0MSA2LjYwNTQ3IDMxLjk0MzggNi43NDYwOSAzMS44NjNDNi44ODkzMiAzMS43ODIzIDcuMDAxMyAzMS42NjkgNy4wODIwMyAzMS41MjMyQzcuMTYyNzYgMzEuMzc3NCA3LjIwMzEyIDMxLjIwNjggNy4yMDMxMiAzMS4wMTE1Wk0xMi42NzUyIDMxLjkwOTlWMzIuNzc3MUMxMi42NzUyIDMzLjI0MzIgMTIuNjMzNSAzMy42MzY1IDEyLjU1MDIgMzMuOTU2OEMxMi40NjY4IDM0LjI3NzEgMTIuMzQ3IDM0LjUzNDkgMTIuMTkwOCAzNC43MzAyQzEyLjAzNDUgMzQuOTI1NSAxMS44NDU3IDM1LjA2NzUgMTEuNjI0NCAzNS4xNTZDMTEuNDA1NiAzNS4yNDE5IDExLjE1ODIgMzUuMjg0OSAxMC44ODIyIDM1LjI4NDlDMTAuNjYzNSAzNS4yODQ5IDEwLjQ2MTYgMzUuMjU3NiAxMC4yNzY3IDM1LjIwMjlDMTAuMDkxOCAzNS4xNDgyIDkuOTI1MTcgMzUuMDYxIDkuNzc2NzMgMzQuOTQxMkM5LjYzMDkgMzQuODE4OCA5LjUwNTkgMzQuNjU5OSA5LjQwMTczIDM0LjQ2NDZDOS4yOTc1NyAzNC4yNjkzIDkuMjE4MTQgMzQuMDMyMyA5LjE2MzQ1IDMzLjc1MzdDOS4xMDg3NyAzMy40NzUgOS4wODE0MiAzMy4xNDk1IDkuMDgxNDIgMzIuNzc3MVYzMS45MDk5QzkuMDgxNDIgMzEuNDQzOCA5LjEyMzA5IDMxLjA1MzEgOS4yMDY0MiAzMC43MzhDOS4yOTIzNiAzMC40MjI5IDkuNDEzNDUgMzAuMTcwMyA5LjU2OTcgMjkuOTgwMkM5LjcyNTk1IDI5Ljc4NzUgOS45MTM0NSAyOS42NDk1IDEwLjEzMjIgMjkuNTY2MkMxMC4zNTM2IDI5LjQ4MjggMTAuNjAxIDI5LjQ0MTIgMTAuODc0NCAyOS40NDEyQzExLjA5NTcgMjkuNDQxMiAxMS4yOTg5IDI5LjQ2ODUgMTEuNDgzOCAyOS41MjMyQzExLjY3MTMgMjkuNTc1MyAxMS44Mzc5IDI5LjY1OTkgMTEuOTgzOCAyOS43NzcxQzEyLjEyOTYgMjkuODkxNyAxMi4yNTMzIDMwLjA0NTMgMTIuMzU0OSAzMC4yMzhDMTIuNDU5IDMwLjQyODEgMTIuNTM4NSAzMC42NjEyIDEyLjU5MzEgMzAuOTM3M0MxMi42NDc4IDMxLjIxMzMgMTIuNjc1MiAzMS41Mzc1IDEyLjY3NTIgMzEuOTA5OVpNMTEuOTQ4NiAzMi44OTQzVjMxLjc4ODhDMTEuOTQ4NiAzMS41MzM2IDExLjkzMyAzMS4zMDk3IDExLjkwMTcgMzEuMTE2OUMxMS44NzMxIDMwLjkyMTYgMTEuODMwMSAzMC43NTUgMTEuNzcyOCAzMC42MTY5QzExLjcxNTUgMzAuNDc4OSAxMS42NDI2IDMwLjM2NjkgMTEuNTU0MSAzMC4yODFDMTEuNDY4MSAzMC4xOTUxIDExLjM2NzkgMzAuMTMyNiAxMS4yNTMzIDMwLjA5MzVDMTEuMTQxMyAzMC4wNTE4IDExLjAxNSAzMC4wMzEgMTAuODc0NCAzMC4wMzFDMTAuNzAyNSAzMC4wMzEgMTAuNTUwMiAzMC4wNjM2IDEwLjQxNzQgMzAuMTI4N0MxMC4yODQ1IDMwLjE5MTIgMTAuMTcyNiAzMC4yOTE0IDEwLjA4MTQgMzAuNDI5NEM5Ljk5Mjg4IDMwLjU2NzUgOS45MjUxNyAzMC43NDg1IDkuODc4MyAzMC45NzI0QzkuODMxNDIgMzEuMTk2NCA5LjgwNzk4IDMxLjQ2ODUgOS44MDc5OCAzMS43ODg4VjMyLjg5NDNDOS44MDc5OCAzMy4xNDk1IDkuODIyMzEgMzMuMzc0OCA5Ljg1MDk1IDMzLjU3MDFDOS44ODIyIDMzLjc2NTQgOS45Mjc3OCAzMy45MzQ3IDkuOTg3NjcgMzQuMDc3OUMxMC4wNDc2IDM0LjIxODUgMTAuMTIwNSAzNC4zMzQ0IDEwLjIwNjQgMzQuNDI1NUMxMC4yOTI0IDM0LjUxNjcgMTAuMzkxMyAzNC41ODQ0IDEwLjUwMzMgMzQuNjI4N0MxMC42MTc5IDM0LjY3MDMgMTAuNzQ0MiAzNC42OTEyIDEwLjg4MjIgMzQuNjkxMkMxMS4wNTkzIDM0LjY5MTIgMTEuMjE0MiAzNC42NTczIDExLjM0NyAzNC41ODk2QzExLjQ3OTkgMzQuNTIxOSAxMS41OTA1IDM0LjQxNjQgMTEuNjc5MSAzNC4yNzMyQzExLjc3MDIgMzQuMTI3NCAxMS44Mzc5IDMzLjk0MTIgMTEuODgyMiAzMy43MTQ2QzExLjkyNjUgMzMuNDg1NCAxMS45NDg2IDMzLjIxMiAxMS45NDg2IDMyLjg5NDNaTTEzLjY3NDYgMzAuOTEzOFYzMC42MTNDMTMuNjc0NiAzMC4zOTY5IDEzLjcyMTQgMzAuMjAwMyAxMy44MTUyIDMwLjAyMzJDMTMuOTA4OSAyOS44NDYxIDE0LjA0MzEgMjkuNzA0MiAxNC4yMTc1IDI5LjU5NzRDMTQuMzkyIDI5LjQ5MDYgMTQuNTk5IDI5LjQzNzMgMTQuODM4NiAyOS40MzczQzE1LjA4MzQgMjkuNDM3MyAxNS4yOTE4IDI5LjQ5MDYgMTUuNDYzNiAyOS41OTc0QzE1LjYzODEgMjkuNzA0MiAxNS43NzIyIDI5Ljg0NjEgMTUuODY2IDMwLjAyMzJDMTUuOTU5NyAzMC4yMDAzIDE2LjAwNjYgMzAuMzk2OSAxNi4wMDY2IDMwLjYxM1YzMC45MTM4QzE2LjAwNjYgMzEuMTI0OCAxNS45NTk3IDMxLjMxODggMTUuODY2IDMxLjQ5NThDMTUuNzc0OCAzMS42NzI5IDE1LjY0MiAzMS44MTQ5IDE1LjQ2NzUgMzEuOTIxNkMxNS4yOTU3IDMyLjAyODQgMTUuMDg4NiAzMi4wODE4IDE0Ljg0NjQgMzIuMDgxOEMxNC42MDQzIDMyLjA4MTggMTQuMzk0NiAzMi4wMjg0IDE0LjIxNzUgMzEuOTIxNkMxNC4wNDMxIDMxLjgxNDkgMTMuOTA4OSAzMS42NzI5IDEzLjgxNTIgMzEuNDk1OEMxMy43MjE0IDMxLjMxODggMTMuNjc0NiAzMS4xMjQ4IDEzLjY3NDYgMzAuOTEzOFpNMTQuMjE3NSAzMC42MTNWMzAuOTEzOEMxNC4yMTc1IDMxLjAzMzYgMTQuMjM5NyAzMS4xNDY5IDE0LjI4MzkgMzEuMjUzN0MxNC4zMzA4IDMxLjM2MDQgMTQuNDAxMSAzMS40NDc3IDE0LjQ5NDkgMzEuNTE1NEMxNC41ODg2IDMxLjU4MDUgMTQuNzA1OCAzMS42MTMgMTQuODQ2NCAzMS42MTNDMTQuOTg3MSAzMS42MTMgMTUuMTAyOSAzMS41ODA1IDE1LjE5NDEgMzEuNTE1NEMxNS4yODUyIDMxLjQ0NzcgMTUuMzUyOSAzMS4zNjA0IDE1LjM5NzIgMzEuMjUzN0MxNS40NDE1IDMxLjE0NjkgMTUuNDYzNiAzMS4wMzM2IDE1LjQ2MzYgMzAuOTEzOFYzMC42MTNDMTUuNDYzNiAzMC40OTA2IDE1LjQ0MDIgMzAuMzc2MSAxNS4zOTMzIDMwLjI2OTNDMTUuMzQ5IDMwLjE1OTkgMTUuMjggMzAuMDcyNyAxNS4xODYzIDMwLjAwNzZDMTUuMDk1MSAyOS45Mzk5IDE0Ljk3OTMgMjkuOTA2IDE0LjgzODYgMjkuOTA2QzE0LjcwMDYgMjkuOTA2IDE0LjU4NDcgMjkuOTM5OSAxNC40OTEgMzAuMDA3NkMxNC4zOTk4IDMwLjA3MjcgMTQuMzMwOCAzMC4xNTk5IDE0LjI4MzkgMzAuMjY5M0MxNC4yMzk3IDMwLjM3NjEgMTQuMjE3NSAzMC40OTA2IDE0LjIxNzUgMzAuNjEzWk0xNi40NDQxIDM0LjExNjlWMzMuODEyM0MxNi40NDQxIDMzLjU5ODcgMTYuNDkxIDMzLjQwMzQgMTYuNTg0NyAzMy4yMjYzQzE2LjY3ODUgMzMuMDQ5MiAxNi44MTI2IDMyLjkwNzMgMTYuOTg3MSAzMi44MDA1QzE3LjE2MTUgMzIuNjkzOCAxNy4zNjg2IDMyLjY0MDQgMTcuNjA4MiAzMi42NDA0QzE3Ljg1MjkgMzIuNjQwNCAxOC4wNjEzIDMyLjY5MzggMTguMjMzMiAzMi44MDA1QzE4LjQwNzYgMzIuOTA3MyAxOC41NDE4IDMzLjA0OTIgMTguNjM1NSAzMy4yMjYzQzE4LjcyOTMgMzMuNDAzNCAxOC43NzYxIDMzLjU5ODcgMTguNzc2MSAzMy44MTIzVjM0LjExNjlDMTguNzc2MSAzNC4zMzA1IDE4LjcyOTMgMzQuNTI1OCAxOC42MzU1IDM0LjcwMjlDMTguNTQ0NCAzNC44OCAxOC40MTE1IDM1LjAyMTkgMTguMjM3MSAzNS4xMjg3QzE4LjA2NTIgMzUuMjM1NCAxNy44NTgyIDM1LjI4ODggMTcuNjE2IDM1LjI4ODhDMTcuMzczOCAzNS4yODg4IDE3LjE2NTQgMzUuMjM1NCAxNi45OTEgMzUuMTI4N0MxNi44MTY1IDM1LjAyMTkgMTYuNjgxMSAzNC44OCAxNi41ODQ3IDM0LjcwMjlDMTYuNDkxIDM0LjUyNTggMTYuNDQ0MSAzNC4zMzA1IDE2LjQ0NDEgMzQuMTE2OVpNMTYuOTg3MSAzMy44MTIzVjM0LjExNjlDMTYuOTg3MSAzNC4yMzY3IDE3LjAwOTIgMzQuMzUxMyAxNy4wNTM1IDM0LjQ2MDdDMTcuMTAwMyAzNC41Njc1IDE3LjE3MDcgMzQuNjU0NyAxNy4yNjQ0IDM0LjcyMjRDMTcuMzU4MiAzNC43ODc1IDE3LjQ3NTMgMzQuODIwMSAxNy42MTYgMzQuODIwMUMxNy43NTY2IDM0LjgyMDEgMTcuODcyNSAzNC43ODc1IDE3Ljk2MzYgMzQuNzIyNEMxOC4wNTc0IDM0LjY1NDcgMTguMTI2NCAzNC41Njc1IDE4LjE3MDcgMzQuNDYwN0MxOC4yMTQ5IDM0LjM1MzkgMTguMjM3MSAzNC4yMzkzIDE4LjIzNzEgMzQuMTE2OVYzMy44MTIzQzE4LjIzNzEgMzMuNjg5OSAxOC4yMTM2IDMzLjU3NTMgMTguMTY2OCAzMy40Njg1QzE4LjEyMjUgMzMuMzYxNyAxOC4wNTM1IDMzLjI3NTggMTcuOTU5NyAzMy4yMTA3QzE3Ljg2ODYgMzMuMTQzIDE3Ljc1MTQgMzMuMTA5MSAxNy42MDgyIDMzLjEwOTFDMTcuNDcwMSAzMy4xMDkxIDE3LjM1NDMgMzMuMTQzIDE3LjI2MDUgMzMuMjEwN0MxNy4xNjk0IDMzLjI3NTggMTcuMTAwMyAzMy4zNjE3IDE3LjA1MzUgMzMuNDY4NUMxNy4wMDkyIDMzLjU3NTMgMTYuOTg3MSAzMy42ODk5IDE2Ljk4NzEgMzMuODEyM1pNMTcuNzg3OCAzMC4zMjc5TDE1LjAxMDUgMzQuNzczMkwxNC42MDQzIDM0LjUxNTRMMTcuMzgxNiAzMC4wNzAxTDE3Ljc4NzggMzAuMzI3OVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPHBhdGggZD0iTTcuMjQ2MDkgNTcuNzE4M0g3LjMwODU5VjU4LjMzMTVINy4yNDYwOUM2Ljg2MzI4IDU4LjMzMTUgNi41NDI5NyA1OC4zOTQgNi4yODUxNiA1OC41MTlDNi4wMjczNCA1OC42NDE0IDUuODIyOTIgNTguODA2OCA1LjY3MTg4IDU5LjAxNTFDNS41MjA4MyA1OS4yMjA5IDUuNDExNDYgNTkuNDUyNiA1LjM0Mzc1IDU5LjcxMDRDNS4yNzg2NSA1OS45NjgzIDUuMjQ2MDkgNjAuMjMgNS4yNDYwOSA2MC40OTU2VjYxLjMzMTVDNS4yNDYwOSA2MS41ODQxIDUuMjc2MDQgNjEuODA4MSA1LjMzNTk0IDYyLjAwMzRDNS4zOTU4MyA2Mi4xOTYxIDUuNDc3ODYgNjIuMzU4OSA1LjU4MjAzIDYyLjQ5MTdDNS42ODYyIDYyLjYyNDUgNS44MDMzOSA2Mi43MjQ4IDUuOTMzNTkgNjIuNzkyNUM2LjA2NjQxIDYyLjg2MDIgNi4yMDQ0MyA2Mi44OTQgNi4zNDc2NiA2Mi44OTRDNi41MTQzMiA2Mi44OTQgNi42NjI3NiA2Mi44NjI4IDYuNzkyOTcgNjIuODAwM0M2LjkyMzE4IDYyLjczNTIgNy4wMzI1NSA2Mi42NDUzIDcuMTIxMDkgNjIuNTMwOEM3LjIxMjI0IDYyLjQxMzYgNy4yODEyNSA2Mi4yNzU2IDcuMzI4MTIgNjIuMTE2N0M3LjM3NSA2MS45NTc4IDcuMzk4NDQgNjEuNzgzNCA3LjM5ODQ0IDYxLjU5MzNDNy4zOTg0NCA2MS40MjQgNy4zNzc2IDYxLjI2MTIgNy4zMzU5NCA2MS4xMDVDNy4yOTQyNyA2MC45NDYxIDcuMjMwNDcgNjAuODA1NSA3LjE0NDUzIDYwLjY4MzFDNy4wNTg1OSA2MC41NTgxIDYuOTUwNTIgNjAuNDYwNCA2LjgyMDMxIDYwLjM5MDFDNi42OTI3MSA2MC4zMTcyIDYuNTQwMzYgNjAuMjgwOCA2LjM2MzI4IDYwLjI4MDhDNi4xNjI3NiA2MC4yODA4IDUuOTc1MjYgNjAuMzMwMiA1LjgwMDc4IDYwLjQyOTJDNS42Mjg5MSA2MC41MjU2IDUuNDg2OTggNjAuNjUzMiA1LjM3NSA2MC44MTJDNS4yNjU2MiA2MC45NjgzIDUuMjAzMTIgNjEuMTM4OCA1LjE4NzUgNjEuMzIzN0w0LjgwNDY5IDYxLjMxOThDNC44NDExNSA2MS4wMjgyIDQuOTA4ODUgNjAuNzc5NSA1LjAwNzgxIDYwLjU3MzdDNS4xMDkzOCA2MC4zNjU0IDUuMjM0MzggNjAuMTk2MSA1LjM4MjgxIDYwLjA2NTlDNS41MzM4NSA1OS45MzMxIDUuNzAxODIgNTkuODM2OCA1Ljg4NjcyIDU5Ljc3NjlDNi4wNzQyMiA1OS43MTQ0IDYuMjcyMTQgNTkuNjgzMSA2LjQ4MDQ3IDU5LjY4MzFDNi43NjQzMiA1OS42ODMxIDcuMDA5MTEgNTkuNzM2NSA3LjIxNDg0IDU5Ljg0MzNDNy40MjA1NyA1OS45NSA3LjU4OTg0IDYwLjA5MzMgNy43MjI2NiA2MC4yNzI5QzcuODU1NDcgNjAuNDUgNy45NTMxMiA2MC42NTA2IDguMDE1NjIgNjAuODc0NUM4LjA4MDczIDYxLjA5NTkgOC4xMTMyOCA2MS4zMjM3IDguMTEzMjggNjEuNTU4MUM4LjExMzI4IDYxLjgyNjMgOC4wNzU1MiA2Mi4wNzc2IDggNjIuMzEyQzcuOTI0NDggNjIuNTQ2NCA3LjgxMTIgNjIuNzUyMSA3LjY2MDE2IDYyLjkyOTJDNy41MTE3MiA2My4xMDYzIDcuMzI4MTIgNjMuMjQ0MyA3LjEwOTM4IDYzLjM0MzNDNi44OTA2MiA2My40NDIyIDYuNjM2NzIgNjMuNDkxNyA2LjM0NzY2IDYzLjQ5MTdDNi4wNDAzNiA2My40OTE3IDUuNzcyMTQgNjMuNDI5MiA1LjU0Mjk3IDYzLjMwNDJDNS4zMTM4IDYzLjE3NjYgNS4xMjM3IDYzLjAwNzMgNC45NzI2NiA2Mi43OTY0QzQuODIxNjEgNjIuNTg1NCA0LjcwODMzIDYyLjM1MTEgNC42MzI4MSA2Mi4wOTMzQzQuNTU3MjkgNjEuODM1NCA0LjUxOTUzIDYxLjU3MzcgNC41MTk1MyA2MS4zMDgxVjYwLjk2ODNDNC41MTk1MyA2MC41NjcyIDQuNTU5OSA2MC4xNzQgNC42NDA2MiA1OS43ODg2QzQuNzIxMzUgNTkuNDAzMiA0Ljg2MDY4IDU5LjA1NDIgNS4wNTg1OSA1OC43NDE3QzUuMjU5MTEgNTguNDI5MiA1LjUzNjQ2IDU4LjE4MDUgNS44OTA2MiA1Ny45OTU2QzYuMjQ0NzkgNTcuODEwNyA2LjY5NjYxIDU3LjcxODMgNy4yNDYwOSA1Ny43MTgzWk0xMi42NzUyIDYwLjExNjdWNjAuOTgzOUMxMi42NzUyIDYxLjQ1IDEyLjYzMzUgNjEuODQzMyAxMi41NTAyIDYyLjE2MzZDMTIuNDY2OCA2Mi40ODM5IDEyLjM0NyA2Mi43NDE3IDEyLjE5MDggNjIuOTM3QzEyLjAzNDUgNjMuMTMyMyAxMS44NDU3IDYzLjI3NDMgMTEuNjI0NCA2My4zNjI4QzExLjQwNTYgNjMuNDQ4NyAxMS4xNTgyIDYzLjQ5MTcgMTAuODgyMiA2My40OTE3QzEwLjY2MzUgNjMuNDkxNyAxMC40NjE2IDYzLjQ2NDQgMTAuMjc2NyA2My40MDk3QzEwLjA5MTggNjMuMzU1IDkuOTI1MTcgNjMuMjY3NyA5Ljc3NjczIDYzLjE0NzlDOS42MzA5IDYzLjAyNTYgOS41MDU5IDYyLjg2NjcgOS40MDE3MyA2Mi42NzE0QzkuMjk3NTcgNjIuNDc2MSA5LjIxODE0IDYyLjIzOTEgOS4xNjM0NSA2MS45NjA0QzkuMTA4NzcgNjEuNjgxOCA5LjA4MTQyIDYxLjM1NjMgOS4wODE0MiA2MC45ODM5VjYwLjExNjdDOS4wODE0MiA1OS42NTA2IDkuMTIzMDkgNTkuMjU5OSA5LjIwNjQyIDU4Ljk0NDhDOS4yOTIzNiA1OC42Mjk3IDkuNDEzNDUgNTguMzc3MSA5LjU2OTcgNTguMTg3QzkuNzI1OTUgNTcuOTk0MyA5LjkxMzQ1IDU3Ljg1NjMgMTAuMTMyMiA1Ny43NzI5QzEwLjM1MzYgNTcuNjg5NiAxMC42MDEgNTcuNjQ3OSAxMC44NzQ0IDU3LjY0NzlDMTEuMDk1NyA1Ny42NDc5IDExLjI5ODkgNTcuNjc1MyAxMS40ODM4IDU3LjczQzExLjY3MTMgNTcuNzgyMSAxMS44Mzc5IDU3Ljg2NjcgMTEuOTgzOCA1Ny45ODM5QzEyLjEyOTYgNTguMDk4NSAxMi4yNTMzIDU4LjI1MjEgMTIuMzU0OSA1OC40NDQ4QzEyLjQ1OSA1OC42MzQ5IDEyLjUzODUgNTguODY4IDEyLjU5MzEgNTkuMTQ0QzEyLjY0NzggNTkuNDIwMSAxMi42NzUyIDU5Ljc0NDMgMTIuNjc1MiA2MC4xMTY3Wk0xMS45NDg2IDYxLjEwMTFWNTkuOTk1NkMxMS45NDg2IDU5Ljc0MDQgMTEuOTMzIDU5LjUxNjQgMTEuOTAxNyA1OS4zMjM3QzExLjg3MzEgNTkuMTI4NCAxMS44MzAxIDU4Ljk2MTggMTEuNzcyOCA1OC44MjM3QzExLjcxNTUgNTguNjg1NyAxMS42NDI2IDU4LjU3MzcgMTEuNTU0MSA1OC40ODc4QzExLjQ2ODEgNTguNDAxOSAxMS4zNjc5IDU4LjMzOTQgMTEuMjUzMyA1OC4zMDAzQzExLjE0MTMgNTguMjU4NiAxMS4wMTUgNTguMjM3OCAxMC44NzQ0IDU4LjIzNzhDMTAuNzAyNSA1OC4yMzc4IDEwLjU1MDIgNTguMjcwMyAxMC40MTc0IDU4LjMzNTRDMTAuMjg0NSA1OC4zOTc5IDEwLjE3MjYgNTguNDk4MiAxMC4wODE0IDU4LjYzNjJDOS45OTI4OCA1OC43NzQzIDkuOTI1MTcgNTguOTU1MiA5Ljg3ODMgNTkuMTc5MkM5LjgzMTQyIDU5LjQwMzIgOS44MDc5OCA1OS42NzUzIDkuODA3OTggNTkuOTk1NlY2MS4xMDExQzkuODA3OTggNjEuMzU2MyA5LjgyMjMxIDYxLjU4MTUgOS44NTA5NSA2MS43NzY5QzkuODgyMiA2MS45NzIyIDkuOTI3NzggNjIuMTQxNCA5Ljk4NzY3IDYyLjI4NDdDMTAuMDQ3NiA2Mi40MjUzIDEwLjEyMDUgNjIuNTQxMiAxMC4yMDY0IDYyLjYzMjNDMTAuMjkyNCA2Mi43MjM1IDEwLjM5MTMgNjIuNzkxMiAxMC41MDMzIDYyLjgzNTRDMTAuNjE3OSA2Mi44NzcxIDEwLjc0NDIgNjIuODk3OSAxMC44ODIyIDYyLjg5NzlDMTEuMDU5MyA2Mi44OTc5IDExLjIxNDIgNjIuODY0MSAxMS4zNDcgNjIuNzk2NEMxMS40Nzk5IDYyLjcyODcgMTEuNTkwNSA2Mi42MjMyIDExLjY3OTEgNjIuNDhDMTEuNzcwMiA2Mi4zMzQxIDExLjgzNzkgNjIuMTQ3OSAxMS44ODIyIDYxLjkyMTRDMTEuOTI2NSA2MS42OTIyIDExLjk0ODYgNjEuNDE4OCAxMS45NDg2IDYxLjEwMTFaTTEzLjY3NDYgNTkuMTIwNlY1OC44MTk4QzEzLjY3NDYgNTguNjAzNyAxMy43MjE0IDU4LjQwNzEgMTMuODE1MiA1OC4yM0MxMy45MDg5IDU4LjA1MjkgMTQuMDQzMSA1Ny45MTEgMTQuMjE3NSA1Ny44MDQyQzE0LjM5MiA1Ny42OTc0IDE0LjU5OSA1Ny42NDQgMTQuODM4NiA1Ny42NDRDMTUuMDgzNCA1Ny42NDQgMTUuMjkxOCA1Ny42OTc0IDE1LjQ2MzYgNTcuODA0MkMxNS42MzgxIDU3LjkxMSAxNS43NzIyIDU4LjA1MjkgMTUuODY2IDU4LjIzQzE1Ljk1OTcgNTguNDA3MSAxNi4wMDY2IDU4LjYwMzcgMTYuMDA2NiA1OC44MTk4VjU5LjEyMDZDMTYuMDA2NiA1OS4zMzE1IDE1Ljk1OTcgNTkuNTI1NiAxNS44NjYgNTkuNzAyNkMxNS43NzQ4IDU5Ljg3OTcgMTUuNjQyIDYwLjAyMTYgMTUuNDY3NSA2MC4xMjg0QzE1LjI5NTcgNjAuMjM1MiAxNS4wODg2IDYwLjI4ODYgMTQuODQ2NCA2MC4yODg2QzE0LjYwNDMgNjAuMjg4NiAxNC4zOTQ2IDYwLjIzNTIgMTQuMjE3NSA2MC4xMjg0QzE0LjA0MzEgNjAuMDIxNiAxMy45MDg5IDU5Ljg3OTcgMTMuODE1MiA1OS43MDI2QzEzLjcyMTQgNTkuNTI1NiAxMy42NzQ2IDU5LjMzMTUgMTMuNjc0NiA1OS4xMjA2Wk0xNC4yMTc1IDU4LjgxOThWNTkuMTIwNkMxNC4yMTc1IDU5LjI0MDQgMTQuMjM5NyA1OS4zNTM3IDE0LjI4MzkgNTkuNDYwNEMxNC4zMzA4IDU5LjU2NzIgMTQuNDAxMSA1OS42NTQ1IDE0LjQ5NDkgNTkuNzIyMkMxNC41ODg2IDU5Ljc4NzMgMTQuNzA1OCA1OS44MTk4IDE0Ljg0NjQgNTkuODE5OEMxNC45ODcxIDU5LjgxOTggMTUuMTAyOSA1OS43ODczIDE1LjE5NDEgNTkuNzIyMkMxNS4yODUyIDU5LjY1NDUgMTUuMzUyOSA1OS41NjcyIDE1LjM5NzIgNTkuNDYwNEMxNS40NDE1IDU5LjM1MzcgMTUuNDYzNiA1OS4yNDA0IDE1LjQ2MzYgNTkuMTIwNlY1OC44MTk4QzE1LjQ2MzYgNTguNjk3NCAxNS40NDAyIDU4LjU4MjggMTUuMzkzMyA1OC40NzYxQzE1LjM0OSA1OC4zNjY3IDE1LjI4IDU4LjI3OTUgMTUuMTg2MyA1OC4yMTQ0QzE1LjA5NTEgNTguMTQ2NiAxNC45NzkzIDU4LjExMjggMTQuODM4NiA1OC4xMTI4QzE0LjcwMDYgNTguMTEyOCAxNC41ODQ3IDU4LjE0NjYgMTQuNDkxIDU4LjIxNDRDMTQuMzk5OCA1OC4yNzk1IDE0LjMzMDggNTguMzY2NyAxNC4yODM5IDU4LjQ3NjFDMTQuMjM5NyA1OC41ODI4IDE0LjIxNzUgNTguNjk3NCAxNC4yMTc1IDU4LjgxOThaTTE2LjQ0NDEgNjIuMzIzN1Y2Mi4wMTlDMTYuNDQ0MSA2MS44MDU1IDE2LjQ5MSA2MS42MTAyIDE2LjU4NDcgNjEuNDMzMUMxNi42Nzg1IDYxLjI1NiAxNi44MTI2IDYxLjExNDEgMTYuOTg3MSA2MS4wMDczQzE3LjE2MTUgNjAuOTAwNiAxNy4zNjg2IDYwLjg0NzIgMTcuNjA4MiA2MC44NDcyQzE3Ljg1MjkgNjAuODQ3MiAxOC4wNjEzIDYwLjkwMDYgMTguMjMzMiA2MS4wMDczQzE4LjQwNzYgNjEuMTE0MSAxOC41NDE4IDYxLjI1NiAxOC42MzU1IDYxLjQzMzFDMTguNzI5MyA2MS42MTAyIDE4Ljc3NjEgNjEuODA1NSAxOC43NzYxIDYyLjAxOVY2Mi4zMjM3QzE4Ljc3NjEgNjIuNTM3MyAxOC43MjkzIDYyLjczMjYgMTguNjM1NSA2Mi45MDk3QzE4LjU0NDQgNjMuMDg2OCAxOC40MTE1IDYzLjIyODcgMTguMjM3MSA2My4zMzU0QzE4LjA2NTIgNjMuNDQyMiAxNy44NTgyIDYzLjQ5NTYgMTcuNjE2IDYzLjQ5NTZDMTcuMzczOCA2My40OTU2IDE3LjE2NTQgNjMuNDQyMiAxNi45OTEgNjMuMzM1NEMxNi44MTY1IDYzLjIyODcgMTYuNjgxMSA2My4wODY4IDE2LjU4NDcgNjIuOTA5N0MxNi40OTEgNjIuNzMyNiAxNi40NDQxIDYyLjUzNzMgMTYuNDQ0MSA2Mi4zMjM3Wk0xNi45ODcxIDYyLjAxOVY2Mi4zMjM3QzE2Ljk4NzEgNjIuNDQzNSAxNy4wMDkyIDYyLjU1ODEgMTcuMDUzNSA2Mi42Njc1QzE3LjEwMDMgNjIuNzc0MyAxNy4xNzA3IDYyLjg2MTUgMTcuMjY0NCA2Mi45MjkyQzE3LjM1ODIgNjIuOTk0MyAxNy40NzUzIDYzLjAyNjkgMTcuNjE2IDYzLjAyNjlDMTcuNzU2NiA2My4wMjY5IDE3Ljg3MjUgNjIuOTk0MyAxNy45NjM2IDYyLjkyOTJDMTguMDU3NCA2Mi44NjE1IDE4LjEyNjQgNjIuNzc0MyAxOC4xNzA3IDYyLjY2NzVDMTguMjE0OSA2Mi41NjA3IDE4LjIzNzEgNjIuNDQ2MSAxOC4yMzcxIDYyLjMyMzdWNjIuMDE5QzE4LjIzNzEgNjEuODk2NiAxOC4yMTM2IDYxLjc4MjEgMTguMTY2OCA2MS42NzUzQzE4LjEyMjUgNjEuNTY4NSAxOC4wNTM1IDYxLjQ4MjYgMTcuOTU5NyA2MS40MTc1QzE3Ljg2ODYgNjEuMzQ5OCAxNy43NTE0IDYxLjMxNTkgMTcuNjA4MiA2MS4zMTU5QzE3LjQ3MDEgNjEuMzE1OSAxNy4zNTQzIDYxLjM0OTggMTcuMjYwNSA2MS40MTc1QzE3LjE2OTQgNjEuNDgyNiAxNy4xMDAzIDYxLjU2ODUgMTcuMDUzNSA2MS42NzUzQzE3LjAwOTIgNjEuNzgyMSAxNi45ODcxIDYxLjg5NjYgMTYuOTg3MSA2Mi4wMTlaTTE3Ljc4NzggNTguNTM0N0wxNS4wMTA1IDYyLjk4TDE0LjYwNDMgNjIuNzIyMkwxNy4zODE2IDU4LjI3NjlMMTcuNzg3OCA1OC41MzQ3WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8cGF0aCBkPSJNOC4zMTY0MSA4OS43MDYzVjkwLjNINC4yMDcwM1Y4OS44NzQzTDYuNzUzOTEgODUuOTMyOUg3LjM0Mzc1TDYuNzEwOTQgODcuMDczNUw1LjAyNzM0IDg5LjcwNjNIOC4zMTY0MVpNNy41MjM0NCA4NS45MzI5VjkxLjYyMDRINi44MDA3OFY4NS45MzI5SDcuNTIzNDRaTTEyLjY3NTIgODguMzIzNVY4OS4xOTA3QzEyLjY3NTIgODkuNjU2OCAxMi42MzM1IDkwLjA1IDEyLjU1MDIgOTAuMzcwNEMxMi40NjY4IDkwLjY5MDcgMTIuMzQ3IDkwLjk0ODUgMTIuMTkwOCA5MS4xNDM4QzEyLjAzNDUgOTEuMzM5MSAxMS44NDU3IDkxLjQ4MSAxMS42MjQ0IDkxLjU2OTZDMTEuNDA1NiA5MS42NTU1IDExLjE1ODIgOTEuNjk4NSAxMC44ODIyIDkxLjY5ODVDMTAuNjYzNSA5MS42OTg1IDEwLjQ2MTYgOTEuNjcxMSAxMC4yNzY3IDkxLjYxNjVDMTAuMDkxOCA5MS41NjE4IDkuOTI1MTcgOTEuNDc0NSA5Ljc3NjczIDkxLjM1NDdDOS42MzA5IDkxLjIzMjMgOS41MDU5IDkxLjA3MzUgOS40MDE3MyA5MC44NzgyQzkuMjk3NTcgOTAuNjgyOSA5LjIxODE0IDkwLjQ0NTkgOS4xNjM0NSA5MC4xNjcyQzkuMTA4NzcgODkuODg4NiA5LjA4MTQyIDg5LjU2MzEgOS4wODE0MiA4OS4xOTA3Vjg4LjMyMzVDOS4wODE0MiA4Ny44NTczIDkuMTIzMDkgODcuNDY2NyA5LjIwNjQyIDg3LjE1MTZDOS4yOTIzNiA4Ni44MzY1IDkuNDEzNDUgODYuNTgzOSA5LjU2OTcgODYuMzkzOEM5LjcyNTk1IDg2LjIwMTEgOS45MTM0NSA4Ni4wNjMxIDEwLjEzMjIgODUuOTc5N0MxMC4zNTM2IDg1Ljg5NjQgMTAuNjAxIDg1Ljg1NDcgMTAuODc0NCA4NS44NTQ3QzExLjA5NTcgODUuODU0NyAxMS4yOTg5IDg1Ljg4MjEgMTEuNDgzOCA4NS45MzY4QzExLjY3MTMgODUuOTg4OSAxMS44Mzc5IDg2LjA3MzUgMTEuOTgzOCA4Ni4xOTA3QzEyLjEyOTYgODYuMzA1MyAxMi4yNTMzIDg2LjQ1ODkgMTIuMzU0OSA4Ni42NTE2QzEyLjQ1OSA4Ni44NDE3IDEyLjUzODUgODcuMDc0OCAxMi41OTMxIDg3LjM1MDhDMTIuNjQ3OCA4Ny42MjY5IDEyLjY3NTIgODcuOTUxMSAxMi42NzUyIDg4LjMyMzVaTTExLjk0ODYgODkuMzA3OVY4OC4yMDI0QzExLjk0ODYgODcuOTQ3MiAxMS45MzMgODcuNzIzMiAxMS45MDE3IDg3LjUzMDVDMTEuODczMSA4Ny4zMzUyIDExLjgzMDEgODcuMTY4NSAxMS43NzI4IDg3LjAzMDVDMTEuNzE1NSA4Ni44OTI1IDExLjY0MjYgODYuNzgwNSAxMS41NTQxIDg2LjY5NDZDMTEuNDY4MSA4Ni42MDg2IDExLjM2NzkgODYuNTQ2MSAxMS4yNTMzIDg2LjUwNzFDMTEuMTQxMyA4Ni40NjU0IDExLjAxNSA4Ni40NDQ2IDEwLjg3NDQgODYuNDQ0NkMxMC43MDI1IDg2LjQ0NDYgMTAuNTUwMiA4Ni40NzcxIDEwLjQxNzQgODYuNTQyMkMxMC4yODQ1IDg2LjYwNDcgMTAuMTcyNiA4Ni43MDUgMTAuMDgxNCA4Ni44NDNDOS45OTI4OCA4Ni45ODEgOS45MjUxNyA4Ny4xNjIgOS44NzgzIDg3LjM4NkM5LjgzMTQyIDg3LjYwOTkgOS44MDc5OCA4Ny44ODIxIDkuODA3OTggODguMjAyNFY4OS4zMDc5QzkuODA3OTggODkuNTYzMSA5LjgyMjMxIDg5Ljc4ODMgOS44NTA5NSA4OS45ODM2QzkuODgyMiA5MC4xNzkgOS45Mjc3OCA5MC4zNDgyIDkuOTg3NjcgOTAuNDkxNUMxMC4wNDc2IDkwLjYzMjEgMTAuMTIwNSA5MC43NDggMTAuMjA2NCA5MC44MzkxQzEwLjI5MjQgOTAuOTMwMyAxMC4zOTEzIDkwLjk5OCAxMC41MDMzIDkxLjA0MjJDMTAuNjE3OSA5MS4wODM5IDEwLjc0NDIgOTEuMTA0NyAxMC44ODIyIDkxLjEwNDdDMTEuMDU5MyA5MS4xMDQ3IDExLjIxNDIgOTEuMDcwOSAxMS4zNDcgOTEuMDAzMkMxMS40Nzk5IDkwLjkzNTUgMTEuNTkwNSA5MC44MyAxMS42NzkxIDkwLjY4NjhDMTEuNzcwMiA5MC41NDA5IDExLjgzNzkgOTAuMzU0NyAxMS44ODIyIDkwLjEyODJDMTEuOTI2NSA4OS44OTkgMTEuOTQ4NiA4OS42MjU2IDExLjk0ODYgODkuMzA3OVpNMTMuNjc0NiA4Ny4zMjc0Vjg3LjAyNjZDMTMuNjc0NiA4Ni44MTA1IDEzLjcyMTQgODYuNjEzOSAxMy44MTUyIDg2LjQzNjhDMTMuOTA4OSA4Ni4yNTk3IDE0LjA0MzEgODYuMTE3OCAxNC4yMTc1IDg2LjAxMUMxNC4zOTIgODUuOTA0MiAxNC41OTkgODUuODUwOCAxNC44Mzg2IDg1Ljg1MDhDMTUuMDgzNCA4NS44NTA4IDE1LjI5MTggODUuOTA0MiAxNS40NjM2IDg2LjAxMUMxNS42MzgxIDg2LjExNzggMTUuNzcyMiA4Ni4yNTk3IDE1Ljg2NiA4Ni40MzY4QzE1Ljk1OTcgODYuNjEzOSAxNi4wMDY2IDg2LjgxMDUgMTYuMDA2NiA4Ny4wMjY2Vjg3LjMyNzRDMTYuMDA2NiA4Ny41MzgzIDE1Ljk1OTcgODcuNzMyMyAxNS44NjYgODcuOTA5NEMxNS43NzQ4IDg4LjA4NjUgMTUuNjQyIDg4LjIyODQgMTUuNDY3NSA4OC4zMzUyQzE1LjI5NTcgODguNDQyIDE1LjA4ODYgODguNDk1NCAxNC44NDY0IDg4LjQ5NTRDMTQuNjA0MyA4OC40OTU0IDE0LjM5NDYgODguNDQyIDE0LjIxNzUgODguMzM1MkMxNC4wNDMxIDg4LjIyODQgMTMuOTA4OSA4OC4wODY1IDEzLjgxNTIgODcuOTA5NEMxMy43MjE0IDg3LjczMjMgMTMuNjc0NiA4Ny41MzgzIDEzLjY3NDYgODcuMzI3NFpNMTQuMjE3NSA4Ny4wMjY2Vjg3LjMyNzRDMTQuMjE3NSA4Ny40NDcyIDE0LjIzOTcgODcuNTYwNSAxNC4yODM5IDg3LjY2NzJDMTQuMzMwOCA4Ny43NzQgMTQuNDAxMSA4Ny44NjEyIDE0LjQ5NDkgODcuOTI5QzE0LjU4ODYgODcuOTk0MSAxNC43MDU4IDg4LjAyNjYgMTQuODQ2NCA4OC4wMjY2QzE0Ljk4NzEgODguMDI2NiAxNS4xMDI5IDg3Ljk5NDEgMTUuMTk0MSA4Ny45MjlDMTUuMjg1MiA4Ny44NjEyIDE1LjM1MjkgODcuNzc0IDE1LjM5NzIgODcuNjY3MkMxNS40NDE1IDg3LjU2MDUgMTUuNDYzNiA4Ny40NDcyIDE1LjQ2MzYgODcuMzI3NFY4Ny4wMjY2QzE1LjQ2MzYgODYuOTA0MiAxNS40NDAyIDg2Ljc4OTYgMTUuMzkzMyA4Ni42ODI5QzE1LjM0OSA4Ni41NzM1IDE1LjI4IDg2LjQ4NjIgMTUuMTg2MyA4Ni40MjExQzE1LjA5NTEgODYuMzUzNCAxNC45NzkzIDg2LjMxOTYgMTQuODM4NiA4Ni4zMTk2QzE0LjcwMDYgODYuMzE5NiAxNC41ODQ3IDg2LjM1MzQgMTQuNDkxIDg2LjQyMTFDMTQuMzk5OCA4Ni40ODYyIDE0LjMzMDggODYuNTczNSAxNC4yODM5IDg2LjY4MjlDMTQuMjM5NyA4Ni43ODk2IDE0LjIxNzUgODYuOTA0MiAxNC4yMTc1IDg3LjAyNjZaTTE2LjQ0NDEgOTAuNTMwNVY5MC4yMjU4QzE2LjQ0NDEgOTAuMDEyMyAxNi40OTEgODkuODE3IDE2LjU4NDcgODkuNjM5OUMxNi42Nzg1IDg5LjQ2MjggMTYuODEyNiA4OS4zMjA5IDE2Ljk4NzEgODkuMjE0MUMxNy4xNjE1IDg5LjEwNzMgMTcuMzY4NiA4OS4wNTQgMTcuNjA4MiA4OS4wNTRDMTcuODUyOSA4OS4wNTQgMTguMDYxMyA4OS4xMDczIDE4LjIzMzIgODkuMjE0MUMxOC40MDc2IDg5LjMyMDkgMTguNTQxOCA4OS40NjI4IDE4LjYzNTUgODkuNjM5OUMxOC43MjkzIDg5LjgxNyAxOC43NzYxIDkwLjAxMjMgMTguNzc2MSA5MC4yMjU4VjkwLjUzMDVDMTguNzc2MSA5MC43NDQxIDE4LjcyOTMgOTAuOTM5NCAxOC42MzU1IDkxLjExNjVDMTguNTQ0NCA5MS4yOTM1IDE4LjQxMTUgOTEuNDM1NSAxOC4yMzcxIDkxLjU0MjJDMTguMDY1MiA5MS42NDkgMTcuODU4MiA5MS43MDI0IDE3LjYxNiA5MS43MDI0QzE3LjM3MzggOTEuNzAyNCAxNy4xNjU0IDkxLjY0OSAxNi45OTEgOTEuNTQyMkMxNi44MTY1IDkxLjQzNTUgMTYuNjgxMSA5MS4yOTM1IDE2LjU4NDcgOTEuMTE2NUMxNi40OTEgOTAuOTM5NCAxNi40NDQxIDkwLjc0NDEgMTYuNDQ0MSA5MC41MzA1Wk0xNi45ODcxIDkwLjIyNThWOTAuNTMwNUMxNi45ODcxIDkwLjY1MDMgMTcuMDA5MiA5MC43NjQ5IDE3LjA1MzUgOTAuODc0M0MxNy4xMDAzIDkwLjk4MSAxNy4xNzA3IDkxLjA2ODMgMTcuMjY0NCA5MS4xMzZDMTcuMzU4MiA5MS4yMDExIDE3LjQ3NTMgOTEuMjMzNiAxNy42MTYgOTEuMjMzNkMxNy43NTY2IDkxLjIzMzYgMTcuODcyNSA5MS4yMDExIDE3Ljk2MzYgOTEuMTM2QzE4LjA1NzQgOTEuMDY4MyAxOC4xMjY0IDkwLjk4MSAxOC4xNzA3IDkwLjg3NDNDMTguMjE0OSA5MC43Njc1IDE4LjIzNzEgOTAuNjUyOSAxOC4yMzcxIDkwLjUzMDVWOTAuMjI1OEMxOC4yMzcxIDkwLjEwMzQgMTguMjEzNiA4OS45ODg5IDE4LjE2NjggODkuODgyMUMxOC4xMjI1IDg5Ljc3NTMgMTguMDUzNSA4OS42ODk0IDE3Ljk1OTcgODkuNjI0M0MxNy44Njg2IDg5LjU1NjYgMTcuNzUxNCA4OS41MjI3IDE3LjYwODIgODkuNTIyN0MxNy40NzAxIDg5LjUyMjcgMTcuMzU0MyA4OS41NTY2IDE3LjI2MDUgODkuNjI0M0MxNy4xNjk0IDg5LjY4OTQgMTcuMTAwMyA4OS43NzUzIDE3LjA1MzUgODkuODgyMUMxNy4wMDkyIDg5Ljk4ODkgMTYuOTg3MSA5MC4xMDM0IDE2Ljk4NzEgOTAuMjI1OFpNMTcuNzg3OCA4Ni43NDE1TDE1LjAxMDUgOTEuMTg2OEwxNC42MDQzIDkwLjkyOUwxNy4zODE2IDg2LjQ4MzZMMTcuNzg3OCA4Ni43NDE1WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8cGF0aCBkPSJNOC4xOTkyMiAxMTkuMjMzVjExOS44MjdINC40NzY1NlYxMTkuMzA4TDYuMzM5ODQgMTE3LjIzM0M2LjU2OTAxIDExNi45NzggNi43NDYwOSAxMTYuNzYyIDYuODcxMDkgMTE2LjU4NUM2Ljk5ODcgMTE2LjQwNSA3LjA4NzI0IDExNi4yNDUgNy4xMzY3MiAxMTYuMTA0QzcuMTg4OCAxMTUuOTYxIDcuMjE0ODQgMTE1LjgxNSA3LjIxNDg0IDExNS42NjdDNy4yMTQ4NCAxMTUuNDc5IDcuMTc1NzggMTE1LjMxIDcuMDk3NjYgMTE1LjE1OUM3LjAyMjE0IDExNS4wMDYgNi45MTAxNiAxMTQuODgzIDYuNzYxNzIgMTE0Ljc5MkM2LjYxMzI4IDExNC43MDEgNi40MzM1OSAxMTQuNjU1IDYuMjIyNjYgMTE0LjY1NUM1Ljk3MDA1IDExNC42NTUgNS43NTkxMSAxMTQuNzA1IDUuNTg5ODQgMTE0LjgwNEM1LjQyMzE4IDExNC45IDUuMjk4MTggMTE1LjAzNSA1LjIxNDg0IDExNS4yMUM1LjEzMTUxIDExNS4zODQgNS4wODk4NCAxMTUuNTg1IDUuMDg5ODQgMTE1LjgxMkg0LjM2NzE5QzQuMzY3MTkgMTE1LjQ5MSA0LjQzNzUgMTE1LjE5OCA0LjU3ODEyIDExNC45MzNDNC43MTg3NSAxMTQuNjY3IDQuOTI3MDggMTE0LjQ1NiA1LjIwMzEyIDExNC4zQzUuNDc5MTcgMTE0LjE0MSA1LjgxOTAxIDExNC4wNjIgNi4yMjI2NiAxMTQuMDYyQzYuNTgyMDMgMTE0LjA2MiA2Ljg4OTMyIDExNC4xMjUgNy4xNDQ1MyAxMTQuMjUzQzcuMzk5NzQgMTE0LjM3OCA3LjU5NTA1IDExNC41NTUgNy43MzA0NyAxMTQuNzg0QzcuODY4NDkgMTE1LjAxMSA3LjkzNzUgMTE1LjI3NiA3LjkzNzUgMTE1LjU4MUM3LjkzNzUgMTE1Ljc0OCA3LjkwODg1IDExNS45MTcgNy44NTE1NiAxMTYuMDg5QzcuNzk2ODggMTE2LjI1OCA3LjcyMDA1IDExNi40MjcgNy42MjEwOSAxMTYuNTk3QzcuNTI0NzQgMTE2Ljc2NiA3LjQxMTQ2IDExNi45MzMgNy4yODEyNSAxMTcuMDk3QzcuMTUzNjUgMTE3LjI2MSA3LjAxNjkzIDExNy40MjIgNi44NzEwOSAxMTcuNTgxTDUuMzQ3NjYgMTE5LjIzM0g4LjE5OTIyWk0xMi42NzUyIDExNi41M1YxMTcuMzk3QzEyLjY3NTIgMTE3Ljg2NCAxMi42MzM1IDExOC4yNTcgMTIuNTUwMiAxMTguNTc3QzEyLjQ2NjggMTE4Ljg5NyAxMi4zNDcgMTE5LjE1NSAxMi4xOTA4IDExOS4zNTFDMTIuMDM0NSAxMTkuNTQ2IDExLjg0NTcgMTE5LjY4OCAxMS42MjQ0IDExOS43NzZDMTEuNDA1NiAxMTkuODYyIDExLjE1ODIgMTE5LjkwNSAxMC44ODIyIDExOS45MDVDMTAuNjYzNSAxMTkuOTA1IDEwLjQ2MTYgMTE5Ljg3OCAxMC4yNzY3IDExOS44MjNDMTAuMDkxOCAxMTkuNzY5IDkuOTI1MTcgMTE5LjY4MSA5Ljc3NjczIDExOS41NjJDOS42MzA5IDExOS40MzkgOS41MDU5IDExOS4yOCA5LjQwMTczIDExOS4wODVDOS4yOTc1NyAxMTguODkgOS4yMTgxNCAxMTguNjUzIDkuMTYzNDUgMTE4LjM3NEM5LjEwODc3IDExOC4wOTUgOS4wODE0MiAxMTcuNzcgOS4wODE0MiAxMTcuMzk3VjExNi41M0M5LjA4MTQyIDExNi4wNjQgOS4xMjMwOSAxMTUuNjc0IDkuMjA2NDIgMTE1LjM1OEM5LjI5MjM2IDExNS4wNDMgOS40MTM0NSAxMTQuNzkxIDkuNTY5NyAxMTQuNjAxQzkuNzI1OTUgMTE0LjQwOCA5LjkxMzQ1IDExNC4yNyAxMC4xMzIyIDExNC4xODdDMTAuMzUzNiAxMTQuMTAzIDEwLjYwMSAxMTQuMDYyIDEwLjg3NDQgMTE0LjA2MkMxMS4wOTU3IDExNC4wNjIgMTEuMjk4OSAxMTQuMDg5IDExLjQ4MzggMTE0LjE0NEMxMS42NzEzIDExNC4xOTYgMTEuODM3OSAxMTQuMjggMTEuOTgzOCAxMTQuMzk3QzEyLjEyOTYgMTE0LjUxMiAxMi4yNTMzIDExNC42NjYgMTIuMzU0OSAxMTQuODU4QzEyLjQ1OSAxMTUuMDQ5IDEyLjUzODUgMTE1LjI4MiAxMi41OTMxIDExNS41NThDMTIuNjQ3OCAxMTUuODM0IDEyLjY3NTIgMTE2LjE1OCAxMi42NzUyIDExNi41M1pNMTEuOTQ4NiAxMTcuNTE1VjExNi40MDlDMTEuOTQ4NiAxMTYuMTU0IDExLjkzMyAxMTUuOTMgMTEuOTAxNyAxMTUuNzM3QzExLjg3MzEgMTE1LjU0MiAxMS44MzAxIDExNS4zNzUgMTEuNzcyOCAxMTUuMjM3QzExLjcxNTUgMTE1LjA5OSAxMS42NDI2IDExNC45ODcgMTEuNTU0MSAxMTQuOTAxQzExLjQ2ODEgMTE0LjgxNSAxMS4zNjc5IDExNC43NTMgMTEuMjUzMyAxMTQuNzE0QzExLjE0MTMgMTE0LjY3MiAxMS4wMTUgMTE0LjY1MSAxMC44NzQ0IDExNC42NTFDMTAuNzAyNSAxMTQuNjUxIDEwLjU1MDIgMTE0LjY4NCAxMC40MTc0IDExNC43NDlDMTAuMjg0NSAxMTQuODEyIDEwLjE3MjYgMTE0LjkxMiAxMC4wODE0IDExNS4wNUM5Ljk5Mjg4IDExNS4xODggOS45MjUxNyAxMTUuMzY5IDkuODc4MyAxMTUuNTkzQzkuODMxNDIgMTE1LjgxNyA5LjgwNzk4IDExNi4wODkgOS44MDc5OCAxMTYuNDA5VjExNy41MTVDOS44MDc5OCAxMTcuNzcgOS44MjIzMSAxMTcuOTk1IDkuODUwOTUgMTE4LjE5QzkuODgyMiAxMTguMzg2IDkuOTI3NzggMTE4LjU1NSA5Ljk4NzY3IDExOC42OThDMTAuMDQ3NiAxMTguODM5IDEwLjEyMDUgMTE4Ljk1NSAxMC4yMDY0IDExOS4wNDZDMTAuMjkyNCAxMTkuMTM3IDEwLjM5MTMgMTE5LjIwNSAxMC41MDMzIDExOS4yNDlDMTAuNjE3OSAxMTkuMjkxIDEwLjc0NDIgMTE5LjMxMiAxMC44ODIyIDExOS4zMTJDMTEuMDU5MyAxMTkuMzEyIDExLjIxNDIgMTE5LjI3OCAxMS4zNDcgMTE5LjIxQzExLjQ3OTkgMTE5LjE0MiAxMS41OTA1IDExOS4wMzcgMTEuNjc5MSAxMTguODk0QzExLjc3MDIgMTE4Ljc0OCAxMS44Mzc5IDExOC41NjIgMTEuODgyMiAxMTguMzM1QzExLjkyNjUgMTE4LjEwNiAxMS45NDg2IDExNy44MzIgMTEuOTQ4NiAxMTcuNTE1Wk0xMy42NzQ2IDExNS41MzRWMTE1LjIzM0MxMy42NzQ2IDExNS4wMTcgMTMuNzIxNCAxMTQuODIxIDEzLjgxNTIgMTE0LjY0NEMxMy45MDg5IDExNC40NjYgMTQuMDQzMSAxMTQuMzI1IDE0LjIxNzUgMTE0LjIxOEMxNC4zOTIgMTE0LjExMSAxNC41OTkgMTE0LjA1OCAxNC44Mzg2IDExNC4wNThDMTUuMDgzNCAxMTQuMDU4IDE1LjI5MTggMTE0LjExMSAxNS40NjM2IDExNC4yMThDMTUuNjM4MSAxMTQuMzI1IDE1Ljc3MjIgMTE0LjQ2NiAxNS44NjYgMTE0LjY0NEMxNS45NTk3IDExNC44MjEgMTYuMDA2NiAxMTUuMDE3IDE2LjAwNjYgMTE1LjIzM1YxMTUuNTM0QzE2LjAwNjYgMTE1Ljc0NSAxNS45NTk3IDExNS45MzkgMTUuODY2IDExNi4xMTZDMTUuNzc0OCAxMTYuMjkzIDE1LjY0MiAxMTYuNDM1IDE1LjQ2NzUgMTE2LjU0MkMxNS4yOTU3IDExNi42NDkgMTUuMDg4NiAxMTYuNzAyIDE0Ljg0NjQgMTE2LjcwMkMxNC42MDQzIDExNi43MDIgMTQuMzk0NiAxMTYuNjQ5IDE0LjIxNzUgMTE2LjU0MkMxNC4wNDMxIDExNi40MzUgMTMuOTA4OSAxMTYuMjkzIDEzLjgxNTIgMTE2LjExNkMxMy43MjE0IDExNS45MzkgMTMuNjc0NiAxMTUuNzQ1IDEzLjY3NDYgMTE1LjUzNFpNMTQuMjE3NSAxMTUuMjMzVjExNS41MzRDMTQuMjE3NSAxMTUuNjU0IDE0LjIzOTcgMTE1Ljc2NyAxNC4yODM5IDExNS44NzRDMTQuMzMwOCAxMTUuOTgxIDE0LjQwMTEgMTE2LjA2OCAxNC40OTQ5IDExNi4xMzZDMTQuNTg4NiAxMTYuMjAxIDE0LjcwNTggMTE2LjIzMyAxNC44NDY0IDExNi4yMzNDMTQuOTg3MSAxMTYuMjMzIDE1LjEwMjkgMTE2LjIwMSAxNS4xOTQxIDExNi4xMzZDMTUuMjg1MiAxMTYuMDY4IDE1LjM1MjkgMTE1Ljk4MSAxNS4zOTcyIDExNS44NzRDMTUuNDQxNSAxMTUuNzY3IDE1LjQ2MzYgMTE1LjY1NCAxNS40NjM2IDExNS41MzRWMTE1LjIzM0MxNS40NjM2IDExNS4xMTEgMTUuNDQwMiAxMTQuOTk2IDE1LjM5MzMgMTE0Ljg5QzE1LjM0OSAxMTQuNzggMTUuMjggMTE0LjY5MyAxNS4xODYzIDExNC42MjhDMTUuMDk1MSAxMTQuNTYgMTQuOTc5MyAxMTQuNTI2IDE0LjgzODYgMTE0LjUyNkMxNC43MDA2IDExNC41MjYgMTQuNTg0NyAxMTQuNTYgMTQuNDkxIDExNC42MjhDMTQuMzk5OCAxMTQuNjkzIDE0LjMzMDggMTE0Ljc4IDE0LjI4MzkgMTE0Ljg5QzE0LjIzOTcgMTE0Ljk5NiAxNC4yMTc1IDExNS4xMTEgMTQuMjE3NSAxMTUuMjMzWk0xNi40NDQxIDExOC43MzdWMTE4LjQzM0MxNi40NDQxIDExOC4yMTkgMTYuNDkxIDExOC4wMjQgMTYuNTg0NyAxMTcuODQ3QzE2LjY3ODUgMTE3LjY3IDE2LjgxMjYgMTE3LjUyOCAxNi45ODcxIDExNy40MjFDMTcuMTYxNSAxMTcuMzE0IDE3LjM2ODYgMTE3LjI2MSAxNy42MDgyIDExNy4yNjFDMTcuODUyOSAxMTcuMjYxIDE4LjA2MTMgMTE3LjMxNCAxOC4yMzMyIDExNy40MjFDMTguNDA3NiAxMTcuNTI4IDE4LjU0MTggMTE3LjY3IDE4LjYzNTUgMTE3Ljg0N0MxOC43MjkzIDExOC4wMjQgMTguNzc2MSAxMTguMjE5IDE4Ljc3NjEgMTE4LjQzM1YxMTguNzM3QzE4Ljc3NjEgMTE4Ljk1MSAxOC43MjkzIDExOS4xNDYgMTguNjM1NSAxMTkuMzIzQzE4LjU0NDQgMTE5LjUgMTguNDExNSAxMTkuNjQyIDE4LjIzNzEgMTE5Ljc0OUMxOC4wNjUyIDExOS44NTYgMTcuODU4MiAxMTkuOTA5IDE3LjYxNiAxMTkuOTA5QzE3LjM3MzggMTE5LjkwOSAxNy4xNjU0IDExOS44NTYgMTYuOTkxIDExOS43NDlDMTYuODE2NSAxMTkuNjQyIDE2LjY4MTEgMTE5LjUgMTYuNTg0NyAxMTkuMzIzQzE2LjQ5MSAxMTkuMTQ2IDE2LjQ0NDEgMTE4Ljk1MSAxNi40NDQxIDExOC43MzdaTTE2Ljk4NzEgMTE4LjQzM1YxMTguNzM3QzE2Ljk4NzEgMTE4Ljg1NyAxNy4wMDkyIDExOC45NzIgMTcuMDUzNSAxMTkuMDgxQzE3LjEwMDMgMTE5LjE4OCAxNy4xNzA3IDExOS4yNzUgMTcuMjY0NCAxMTkuMzQzQzE3LjM1ODIgMTE5LjQwOCAxNy40NzUzIDExOS40NCAxNy42MTYgMTE5LjQ0QzE3Ljc1NjYgMTE5LjQ0IDE3Ljg3MjUgMTE5LjQwOCAxNy45NjM2IDExOS4zNDNDMTguMDU3NCAxMTkuMjc1IDE4LjEyNjQgMTE5LjE4OCAxOC4xNzA3IDExOS4wODFDMTguMjE0OSAxMTguOTc0IDE4LjIzNzEgMTE4Ljg2IDE4LjIzNzEgMTE4LjczN1YxMTguNDMzQzE4LjIzNzEgMTE4LjMxIDE4LjIxMzYgMTE4LjE5NiAxOC4xNjY4IDExOC4wODlDMTguMTIyNSAxMTcuOTgyIDE4LjA1MzUgMTE3Ljg5NiAxNy45NTk3IDExNy44MzFDMTcuODY4NiAxMTcuNzYzIDE3Ljc1MTQgMTE3LjcyOSAxNy42MDgyIDExNy43MjlDMTcuNDcwMSAxMTcuNzI5IDE3LjM1NDMgMTE3Ljc2MyAxNy4yNjA1IDExNy44MzFDMTcuMTY5NCAxMTcuODk2IDE3LjEwMDMgMTE3Ljk4MiAxNy4wNTM1IDExOC4wODlDMTcuMDA5MiAxMTguMTk2IDE2Ljk4NzEgMTE4LjMxIDE2Ljk4NzEgMTE4LjQzM1pNMTcuNzg3OCAxMTQuOTQ4TDE1LjAxMDUgMTE5LjM5NEwxNC42MDQzIDExOS4xMzZMMTcuMzgxNiAxMTQuNjlMMTcuNzg3OCAxMTQuOTQ4WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8cGF0aCBkPSJNMTMuMDQzIDE0NC43MzdWMTQ1LjYwNEMxMy4wNDMgMTQ2LjA3IDEzLjAwMTMgMTQ2LjQ2NCAxMi45MTggMTQ2Ljc4NEMxMi44MzQ2IDE0Ny4xMDQgMTIuNzE0OCAxNDcuMzYyIDEyLjU1ODYgMTQ3LjU1N0MxMi40MDIzIDE0Ny43NTMgMTIuMjEzNSAxNDcuODk1IDExLjk5MjIgMTQ3Ljk4M0MxMS43NzM0IDE0OC4wNjkgMTEuNTI2IDE0OC4xMTIgMTEuMjUgMTQ4LjExMkMxMS4wMzEyIDE0OC4xMTIgMTAuODI5NCAxNDguMDg1IDEwLjY0NDUgMTQ4LjAzQzEwLjQ1OTYgMTQ3Ljk3NSAxMC4yOTMgMTQ3Ljg4OCAxMC4xNDQ1IDE0Ny43NjhDOS45OTg3IDE0Ny42NDYgOS44NzM3IDE0Ny40ODcgOS43Njk1MyAxNDcuMjkyQzkuNjY1MzYgMTQ3LjA5NiA5LjU4NTk0IDE0Ni44NTkgOS41MzEyNSAxNDYuNTgxQzkuNDc2NTYgMTQ2LjMwMiA5LjQ0OTIyIDE0NS45NzcgOS40NDkyMiAxNDUuNjA0VjE0NC43MzdDOS40NDkyMiAxNDQuMjcxIDkuNDkwODkgMTQzLjg4IDkuNTc0MjIgMTQzLjU2NUM5LjY2MDE2IDE0My4yNSA5Ljc4MTI1IDE0Mi45OTcgOS45Mzc1IDE0Mi44MDdDMTAuMDkzOCAxNDIuNjE1IDEwLjI4MTIgMTQyLjQ3NyAxMC41IDE0Mi4zOTNDMTAuNzIxNCAxNDIuMzEgMTAuOTY4OCAxNDIuMjY4IDExLjI0MjIgMTQyLjI2OEMxMS40NjM1IDE0Mi4yNjggMTEuNjY2NyAxNDIuMjk2IDExLjg1MTYgMTQyLjM1QzEyLjAzOTEgMTQyLjQwMiAxMi4yMDU3IDE0Mi40ODcgMTIuMzUxNiAxNDIuNjA0QzEyLjQ5NzQgMTQyLjcxOSAxMi42MjExIDE0Mi44NzIgMTIuNzIyNyAxNDMuMDY1QzEyLjgyNjggMTQzLjI1NSAxMi45MDYyIDE0My40ODggMTIuOTYwOSAxNDMuNzY0QzEzLjAxNTYgMTQ0LjA0IDEzLjA0MyAxNDQuMzY1IDEzLjA0MyAxNDQuNzM3Wk0xMi4zMTY0IDE0NS43MjFWMTQ0LjYxNkMxMi4zMTY0IDE0NC4zNjEgMTIuMzAwOCAxNDQuMTM3IDEyLjI2OTUgMTQzLjk0NEMxMi4yNDA5IDE0My43NDkgMTIuMTk3OSAxNDMuNTgyIDEyLjE0MDYgMTQzLjQ0NEMxMi4wODMzIDE0My4zMDYgMTIuMDEwNCAxNDMuMTk0IDExLjkyMTkgMTQzLjEwOEMxMS44MzU5IDE0My4wMjIgMTEuNzM1NyAxNDIuOTYgMTEuNjIxMSAxNDIuOTIxQzExLjUwOTEgMTQyLjg3OSAxMS4zODI4IDE0Mi44NTggMTEuMjQyMiAxNDIuODU4QzExLjA3MDMgMTQyLjg1OCAxMC45MTggMTQyLjg5MSAxMC43ODUyIDE0Mi45NTZDMTAuNjUyMyAxNDMuMDE4IDEwLjU0MDQgMTQzLjExOSAxMC40NDkyIDE0My4yNTdDMTAuMzYwNyAxNDMuMzk1IDEwLjI5MyAxNDMuNTc2IDEwLjI0NjEgMTQzLjhDMTAuMTk5MiAxNDQuMDI0IDEwLjE3NTggMTQ0LjI5NiAxMC4xNzU4IDE0NC42MTZWMTQ1LjcyMUMxMC4xNzU4IDE0NS45NzcgMTAuMTkwMSAxNDYuMjAyIDEwLjIxODggMTQ2LjM5N0MxMC4yNSAxNDYuNTkzIDEwLjI5NTYgMTQ2Ljc2MiAxMC4zNTU1IDE0Ni45MDVDMTAuNDE1NCAxNDcuMDQ2IDEwLjQ4ODMgMTQ3LjE2MiAxMC41NzQyIDE0Ny4yNTNDMTAuNjYwMiAxNDcuMzQ0IDEwLjc1OTEgMTQ3LjQxMiAxMC44NzExIDE0Ny40NTZDMTAuOTg1NyAxNDcuNDk3IDExLjExMiAxNDcuNTE4IDExLjI1IDE0Ny41MThDMTEuNDI3MSAxNDcuNTE4IDExLjU4MiAxNDcuNDg0IDExLjcxNDggMTQ3LjQxN0MxMS44NDc3IDE0Ny4zNDkgMTEuOTU4MyAxNDcuMjQ0IDEyLjA0NjkgMTQ3LjFDMTIuMTM4IDE0Ni45NTUgMTIuMjA1NyAxNDYuNzY4IDEyLjI1IDE0Ni41NDJDMTIuMjk0MyAxNDYuMzEzIDEyLjMxNjQgMTQ2LjAzOSAxMi4zMTY0IDE0NS43MjFaTTE0LjA0MjQgMTQzLjc0MVYxNDMuNDRDMTQuMDQyNCAxNDMuMjI0IDE0LjA4OTIgMTQzLjAyNyAxNC4xODMgMTQyLjg1QzE0LjI3NjcgMTQyLjY3MyAxNC40MTA4IDE0Mi41MzEgMTQuNTg1MyAxNDIuNDI1QzE0Ljc1OTggMTQyLjMxOCAxNC45NjY4IDE0Mi4yNjQgMTUuMjA2NCAxNDIuMjY0QzE1LjQ1MTIgMTQyLjI2NCAxNS42NTk1IDE0Mi4zMTggMTUuODMxNCAxNDIuNDI1QzE2LjAwNTkgMTQyLjUzMSAxNi4xNCAxNDIuNjczIDE2LjIzMzggMTQyLjg1QzE2LjMyNzUgMTQzLjAyNyAxNi4zNzQ0IDE0My4yMjQgMTYuMzc0NCAxNDMuNDRWMTQzLjc0MUMxNi4zNzQ0IDE0My45NTIgMTYuMzI3NSAxNDQuMTQ2IDE2LjIzMzggMTQ0LjMyM0MxNi4xNDI2IDE0NC41IDE2LjAwOTggMTQ0LjY0MiAxNS44MzUzIDE0NC43NDlDMTUuNjYzNSAxNDQuODU2IDE1LjQ1NjQgMTQ0LjkwOSAxNS4yMTQyIDE0NC45MDlDMTQuOTcyIDE0NC45MDkgMTQuNzYyNCAxNDQuODU2IDE0LjU4NTMgMTQ0Ljc0OUMxNC40MTA4IDE0NC42NDIgMTQuMjc2NyAxNDQuNSAxNC4xODMgMTQ0LjMyM0MxNC4wODkyIDE0NC4xNDYgMTQuMDQyNCAxNDMuOTUyIDE0LjA0MjQgMTQzLjc0MVpNMTQuNTg1MyAxNDMuNDRWMTQzLjc0MUMxNC41ODUzIDE0My44NjEgMTQuNjA3NSAxNDMuOTc0IDE0LjY1MTcgMTQ0LjA4MUMxNC42OTg2IDE0NC4xODggMTQuNzY4OSAxNDQuMjc1IDE0Ljg2MjcgMTQ0LjM0M0MxNC45NTY0IDE0NC40MDggMTUuMDczNiAxNDQuNDQgMTUuMjE0MiAxNDQuNDRDMTUuMzU0OSAxNDQuNDQgMTUuNDcwNyAxNDQuNDA4IDE1LjU2MTkgMTQ0LjM0M0MxNS42NTMgMTQ0LjI3NSAxNS43MjA3IDE0NC4xODggMTUuNzY1IDE0NC4wODFDMTUuODA5MyAxNDMuOTc0IDE1LjgzMTQgMTQzLjg2MSAxNS44MzE0IDE0My43NDFWMTQzLjQ0QzE1LjgzMTQgMTQzLjMxOCAxNS44MDggMTQzLjIwMyAxNS43NjExIDE0My4wOTZDMTUuNzE2OCAxNDIuOTg3IDE1LjY0NzggMTQyLjkgMTUuNTU0MSAxNDIuODM1QzE1LjQ2MjkgMTQyLjc2NyAxNS4zNDcgMTQyLjczMyAxNS4yMDY0IDE0Mi43MzNDMTUuMDY4NCAxNDIuNzMzIDE0Ljk1MjUgMTQyLjc2NyAxNC44NTg4IDE0Mi44MzVDMTQuNzY3NiAxNDIuOSAxNC42OTg2IDE0Mi45ODcgMTQuNjUxNyAxNDMuMDk2QzE0LjYwNzUgMTQzLjIwMyAxNC41ODUzIDE0My4zMTggMTQuNTg1MyAxNDMuNDRaTTE2LjgxMTkgMTQ2Ljk0NFYxNDYuNjM5QzE2LjgxMTkgMTQ2LjQyNiAxNi44NTg4IDE0Ni4yMzEgMTYuOTUyNSAxNDYuMDUzQzE3LjA0NjMgMTQ1Ljg3NiAxNy4xODA0IDE0NS43MzQgMTcuMzU0OSAxNDUuNjI4QzE3LjUyOTMgMTQ1LjUyMSAxNy43MzY0IDE0NS40NjggMTcuOTc2IDE0NS40NjhDMTguMjIwNyAxNDUuNDY4IDE4LjQyOTEgMTQ1LjUyMSAxOC42MDEgMTQ1LjYyOEMxOC43NzU0IDE0NS43MzQgMTguOTA5NSAxNDUuODc2IDE5LjAwMzMgMTQ2LjA1M0MxOS4wOTcgMTQ2LjIzMSAxOS4xNDM5IDE0Ni40MjYgMTkuMTQzOSAxNDYuNjM5VjE0Ni45NDRDMTkuMTQzOSAxNDcuMTU4IDE5LjA5NyAxNDcuMzUzIDE5LjAwMzMgMTQ3LjUzQzE4LjkxMjIgMTQ3LjcwNyAxOC43NzkzIDE0Ny44NDkgMTguNjA0OSAxNDcuOTU2QzE4LjQzMyAxNDguMDYzIDE4LjIyNiAxNDguMTE2IDE3Ljk4MzggMTQ4LjExNkMxNy43NDE2IDE0OC4xMTYgMTcuNTMzMiAxNDguMDYzIDE3LjM1ODggMTQ3Ljk1NkMxNy4xODQzIDE0Ny44NDkgMTcuMDQ4OSAxNDcuNzA3IDE2Ljk1MjUgMTQ3LjUzQzE2Ljg1ODggMTQ3LjM1MyAxNi44MTE5IDE0Ny4xNTggMTYuODExOSAxNDYuOTQ0Wk0xNy4zNTQ5IDE0Ni42MzlWMTQ2Ljk0NEMxNy4zNTQ5IDE0Ny4wNjQgMTcuMzc3IDE0Ny4xNzggMTcuNDIxMyAxNDcuMjg4QzE3LjQ2ODEgMTQ3LjM5NSAxNy41Mzg1IDE0Ny40ODIgMTcuNjMyMiAxNDcuNTVDMTcuNzI2IDE0Ny42MTUgMTcuODQzMSAxNDcuNjQ3IDE3Ljk4MzggMTQ3LjY0N0MxOC4xMjQ0IDE0Ny42NDcgMTguMjQwMyAxNDcuNjE1IDE4LjMzMTQgMTQ3LjU1QzE4LjQyNTIgMTQ3LjQ4MiAxOC40OTQyIDE0Ny4zOTUgMTguNTM4NSAxNDcuMjg4QzE4LjU4MjcgMTQ3LjE4MSAxOC42MDQ5IDE0Ny4wNjYgMTguNjA0OSAxNDYuOTQ0VjE0Ni42MzlDMTguNjA0OSAxNDYuNTE3IDE4LjU4MTQgMTQ2LjQwMiAxOC41MzQ1IDE0Ni4yOTZDMTguNDkwMyAxNDYuMTg5IDE4LjQyMTMgMTQ2LjEwMyAxOC4zMjc1IDE0Ni4wMzhDMTguMjM2NCAxNDUuOTcgMTguMTE5MiAxNDUuOTM2IDE3Ljk3NiAxNDUuOTM2QzE3LjgzNzkgMTQ1LjkzNiAxNy43MjIgMTQ1Ljk3IDE3LjYyODMgMTQ2LjAzOEMxNy41MzcyIDE0Ni4xMDMgMTcuNDY4MSAxNDYuMTg5IDE3LjQyMTMgMTQ2LjI5NkMxNy4zNzcgMTQ2LjQwMiAxNy4zNTQ5IDE0Ni41MTcgMTcuMzU0OSAxNDYuNjM5Wk0xOC4xNTU2IDE0My4xNTVMMTUuMzc4MyAxNDcuNkwxNC45NzIgMTQ3LjM0M0wxNy43NDk0IDE0Mi44OTdMMTguMTU1NiAxNDMuMTU1WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8cGF0aCBkPSJNMjUgNC4xNjExM0wyMDAgNC4xNjExNiIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiLz4KPHBhdGggZD0iTTI1IDMzLjE2MTFMMjAwIDMzLjE2MTIiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjEyIi8+CjxwYXRoIGQ9Ik0yNSA2MS4xNjExTDIwMCA2MS4xNjEyIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC4xMiIvPgo8cGF0aCBkPSJNMjUgODkuMTYxMUwyMDAgODkuMTYxMiIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiLz4KPHBhdGggZD0iTTI1IDExOC4xNjFMMjAwIDExOC4xNjEiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjEyIi8+CjxwYXRoIGQ9Ik0zNiA2MkMzNiA2MC44OTU0IDM2Ljg5NTQgNjAgMzggNjBINTJDNTMuMTA0NiA2MCA1NCA2MC44OTU0IDU0IDYyVjE0NkgzNlY2MloiIGZpbGw9IiNGRkMxMDciLz4KPHBhdGggZD0iTTU4IDkyQzU4IDkwLjg5NTQgNTguODk1NCA5MCA2MCA5MEg3NEM3NS4xMDQ2IDkwIDc2IDkwLjg5NTQgNzYgOTJWMTQ2SDU4VjkyWiIgZmlsbD0iIzRDQUY1MCIvPgo8cGF0aCBkPSJNODAgNzlDODAgNzcuODk1NCA4MC44OTU0IDc3IDgyIDc3SDk2Qzk3LjEwNDYgNzcgOTggNzcuODk1NCA5OCA3OVYxNDZIODBWNzlaIiBmaWxsPSIjMjE5NkYzIi8+CjxwYXRoIGQ9Ik0xMjUgNzhDMTI1IDc2Ljg5NTQgMTI1Ljg5NSA3NiAxMjcgNzZIMTQxQzE0Mi4xMDUgNzYgMTQzIDc2Ljg5NTQgMTQzIDc4VjE0NkgxMjVWNzhaIiBmaWxsPSIjRkZDMTA3Ii8+CjxwYXRoIGQ9Ik0xNDcgNTFDMTQ3IDQ5Ljg5NTQgMTQ3Ljg5NSA0OSAxNDkgNDlIMTYzQzE2NC4xMDUgNDkgMTY1IDQ5Ljg5NTQgMTY1IDUxVjE0NkgxNDdWNTFaIiBmaWxsPSIjNENBRjUwIi8+CjxwYXRoIGQ9Ik0xNjkgMzZDMTY5IDM0Ljg5NTQgMTY5Ljg5NSAzNCAxNzEgMzRIMTg1QzE4Ni4xMDUgMzQgMTg3IDM0Ljg5NTQgMTg3IDM2VjE0NkgxNjlWMzZaIiBmaWxsPSIjMjE5NkYzIi8+CjxsaW5lIHgxPSIyMy4yIiB5MT0iMTQ1Ljk2MSIgeDI9IjIwMi44IiB5Mj0iMTQ1Ljk2MSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuNyIgc3Ryb2tlLXdpZHRoPSIwLjQiIHN0cm9rZS1saW5lY2FwPSJzcXVhcmUiLz4KPGxpbmUgeDE9IjY3IiB5MT0iMTQ4LjA3MiIgeDI9IjY3IiB5Mj0iMTQ3LjI1IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8cGF0aCBkPSJNNTkuMDU5MSAxNTIuMDI1VjE1Mi44OTNDNTkuMDU5MSAxNTMuMzU5IDU5LjAxNzQgMTUzLjc1MiA1OC45MzQxIDE1NC4wNzJDNTguODUwNyAxNTQuMzkzIDU4LjczMDkgMTU0LjY1IDU4LjU3NDcgMTU0Ljg0NkM1OC40MTg0IDE1NS4wNDEgNTguMjI5NiAxNTUuMTgzIDU4LjAwODMgMTU1LjI3MUM1Ny43ODk1IDE1NS4zNTcgNTcuNTQyMSAxNTUuNCA1Ny4yNjYxIDE1NS40QzU3LjA0NzMgMTU1LjQgNTYuODQ1NSAxNTUuMzczIDU2LjY2MDYgMTU1LjMxOEM1Ni40NzU3IDE1NS4yNjQgNTYuMzA5MSAxNTUuMTc2IDU2LjE2MDYgMTU1LjA1N0M1Ni4wMTQ4IDE1NC45MzQgNTUuODg5OCAxNTQuNzc1IDU1Ljc4NTYgMTU0LjU4QzU1LjY4MTUgMTU0LjM4NSA1NS42MDIgMTU0LjE0OCA1NS41NDczIDE1My44NjlDNTUuNDkyNyAxNTMuNTkgNTUuNDY1MyAxNTMuMjY1IDU1LjQ2NTMgMTUyLjg5M1YxNTIuMDI1QzU1LjQ2NTMgMTUxLjU1OSA1NS41MDcgMTUxLjE2OSA1NS41OTAzIDE1MC44NTRDNTUuNjc2MiAxNTAuNTM4IDU1Ljc5NzMgMTUwLjI4NiA1NS45NTM2IDE1MC4wOTZDNTYuMTA5OCAxNDkuOTAzIDU2LjI5NzMgMTQ5Ljc2NSA1Ni41MTYxIDE0OS42ODJDNTYuNzM3NCAxNDkuNTk4IDU2Ljk4NDggMTQ5LjU1NyA1Ny4yNTgzIDE0OS41NTdDNTcuNDc5NiAxNDkuNTU3IDU3LjY4MjggMTQ5LjU4NCA1Ny44Njc3IDE0OS42MzlDNTguMDU1MiAxNDkuNjkxIDU4LjIyMTggMTQ5Ljc3NSA1OC4zNjc3IDE0OS44OTNDNTguNTEzNSAxNTAuMDA3IDU4LjYzNzIgMTUwLjE2MSA1OC43Mzg3IDE1MC4zNTRDNTguODQyOSAxNTAuNTQ0IDU4LjkyMjMgMTUwLjc3NyA1OC45NzcgMTUxLjA1M0M1OS4wMzE3IDE1MS4zMjkgNTkuMDU5MSAxNTEuNjUzIDU5LjA1OTEgMTUyLjAyNVpNNTguMzMyNSAxNTMuMDFWMTUxLjkwNEM1OC4zMzI1IDE1MS42NDkgNTguMzE2OSAxNTEuNDI1IDU4LjI4NTYgMTUxLjIzMkM1OC4yNTcgMTUxLjAzNyA1OC4yMTQgMTUwLjg3IDU4LjE1NjcgMTUwLjczMkM1OC4wOTk0IDE1MC41OTQgNTguMDI2NSAxNTAuNDgyIDU3LjkzOCAxNTAuMzk2QzU3Ljg1MiAxNTAuMzExIDU3Ljc1MTggMTUwLjI0OCA1Ny42MzcyIDE1MC4yMDlDNTcuNTI1MiAxNTAuMTY3IDU3LjM5ODkgMTUwLjE0NiA1Ny4yNTgzIDE1MC4xNDZDNTcuMDg2NCAxNTAuMTQ2IDU2LjkzNDEgMTUwLjE3OSA1Ni44MDEyIDE1MC4yNDRDNTYuNjY4NCAxNTAuMzA3IDU2LjU1NjUgMTUwLjQwNyA1Ni40NjUzIDE1MC41NDVDNTYuMzc2OCAxNTAuNjgzIDU2LjMwOTEgMTUwLjg2NCA1Ni4yNjIyIDE1MS4wODhDNTYuMjE1MyAxNTEuMzEyIDU2LjE5MTkgMTUxLjU4NCA1Ni4xOTE5IDE1MS45MDRWMTUzLjAxQzU2LjE5MTkgMTUzLjI2NSA1Ni4yMDYyIDE1My40OSA1Ni4yMzQ4IDE1My42ODZDNTYuMjY2MSAxNTMuODgxIDU2LjMxMTcgMTU0LjA1IDU2LjM3MTYgMTU0LjE5M0M1Ni40MzE1IDE1NC4zMzQgNTYuNTA0NCAxNTQuNDUgNTYuNTkwMyAxNTQuNTQxQzU2LjY3NjIgMTU0LjYzMiA1Ni43NzUyIDE1NC43IDU2Ljg4NzIgMTU0Ljc0NEM1Ny4wMDE4IDE1NC43ODYgNTcuMTI4MSAxNTQuODA3IDU3LjI2NjEgMTU0LjgwN0M1Ny40NDMyIDE1NC44MDcgNTcuNTk4MSAxNTQuNzczIDU3LjczMDkgMTU0LjcwNUM1Ny44NjM3IDE1NC42MzcgNTcuOTc0NCAxNTQuNTMyIDU4LjA2MyAxNTQuMzg5QzU4LjE1NDEgMTU0LjI0MyA1OC4yMjE4IDE1NC4wNTcgNTguMjY2MSAxNTMuODNDNTguMzEwNCAxNTMuNjAxIDU4LjMzMjUgMTUzLjMyNyA1OC4zMzI1IDE1My4wMVpNNjIuNDQ2NCAxNDkuNjA0VjE1NS4zMjJINjEuNzIzN1YxNTAuNTA2TDYwLjI2NjcgMTUxLjAzN1YxNTAuMzg1TDYyLjMzMzEgMTQ5LjYwNEg2Mi40NDY0Wk02Ny42NjI0IDE0OS42MzVWMTU1LjMyMkg2Ni45MDg1VjE0OS42MzVINjcuNjYyNFpNNzAuMDQ1MiAxNTIuMTkzVjE1Mi44MTFINjcuNDk4M1YxNTIuMTkzSDcwLjA0NTJaTTcwLjQzMTkgMTQ5LjYzNVYxNTAuMjUySDY3LjQ5ODNWMTQ5LjYzNUg3MC40MzE5Wk03Mi45NzE2IDE1NS40QzcyLjY3NzMgMTU1LjQgNzIuNDEwNCAxNTUuMzUxIDcyLjE3MDggMTU1LjI1MkM3MS45MzM4IDE1NS4xNSA3MS43Mjk0IDE1NS4wMDggNzEuNTU3NSAxNTQuODI2QzcxLjM4ODMgMTU0LjY0NCA3MS4yNTgxIDE1NC40MjggNzEuMTY2OSAxNTQuMTc4QzcxLjA3NTggMTUzLjkyOCA3MS4wMzAyIDE1My42NTQgNzEuMDMwMiAxNTMuMzU3VjE1My4xOTNDNzEuMDMwMiAxNTIuODUgNzEuMDgxIDE1Mi41NDQgNzEuMTgyNSAxNTIuMjc1QzcxLjI4NDEgMTUyLjAwNSA3MS40MjIxIDE1MS43NzUgNzEuNTk2NiAxNTEuNTg4QzcxLjc3MTEgMTUxLjQgNzEuOTY5IDE1MS4yNTggNzIuMTkwMyAxNTEuMTYyQzcyLjQxMTcgMTUxLjA2NiA3Mi42NDA5IDE1MS4wMTggNzIuODc3OCAxNTEuMDE4QzczLjE3OTkgMTUxLjAxOCA3My40NDAzIDE1MS4wNyA3My42NTkxIDE1MS4xNzRDNzMuODgwNSAxNTEuMjc4IDc0LjA2MTQgMTUxLjQyNCA3NC4yMDIxIDE1MS42MTFDNzQuMzQyNyAxNTEuNzk2IDc0LjQ0NjkgMTUyLjAxNSA3NC41MTQ2IDE1Mi4yNjhDNzQuNTgyMyAxNTIuNTE4IDc0LjYxNjEgMTUyLjc5MSA3NC42MTYxIDE1My4wODhWMTUzLjQxMkg3MS40NTk5VjE1Mi44MjJINzMuODkzNVYxNTIuNzY4QzczLjg4MzEgMTUyLjU4IDczLjg0NCAxNTIuMzk4IDczLjc3NjMgMTUyLjIyMUM3My43MTEyIDE1Mi4wNDQgNzMuNjA3IDE1MS44OTggNzMuNDYzOCAxNTEuNzgzQzczLjMyMDYgMTUxLjY2OSA3My4xMjUyIDE1MS42MTEgNzIuODc3OCAxNTEuNjExQzcyLjcxMzggMTUxLjYxMSA3Mi41NjI3IDE1MS42NDYgNzIuNDI0NyAxNTEuNzE3QzcyLjI4NjcgMTUxLjc4NSA3Mi4xNjgyIDE1MS44ODYgNzIuMDY5MyAxNTIuMDIxQzcxLjk3MDMgMTUyLjE1NyA3MS44OTM1IDE1Mi4zMjIgNzEuODM4OCAxNTIuNTE4QzcxLjc4NDEgMTUyLjcxMyA3MS43NTY4IDE1Mi45MzggNzEuNzU2OCAxNTMuMTkzVjE1My4zNTdDNzEuNzU2OCAxNTMuNTU4IDcxLjc4NDEgMTUzLjc0NyA3MS44Mzg4IDE1My45MjRDNzEuODk2MSAxNTQuMDk4IDcxLjk3ODEgMTU0LjI1MiA3Mi4wODQ5IDE1NC4zODVDNzIuMTk0MyAxNTQuNTE4IDcyLjMyNTggMTU0LjYyMiA3Mi40Nzk0IDE1NC42OTdDNzIuNjM1NyAxNTQuNzczIDcyLjgxMjcgMTU0LjgxMSA3My4wMTA3IDE1NC44MTFDNzMuMjY1OSAxNTQuODExIDczLjQ4MiAxNTQuNzU4IDczLjY1OTEgMTU0LjY1NEM3My44MzYyIDE1NC41NSA3My45OTExIDE1NC40MTEgNzQuMTIzOSAxNTQuMjM2TDc0LjU2MTQgMTU0LjU4NEM3NC40NzAzIDE1NC43MjIgNzQuMzU0NCAxNTQuODU0IDc0LjIxMzggMTU0Ljk3OUM3NC4wNzMyIDE1NS4xMDQgNzMuOSAxNTUuMjA1IDczLjY5NDMgMTU1LjI4M0M3My40OTExIDE1NS4zNjEgNzMuMjUwMiAxNTUuNCA3Mi45NzE2IDE1NS40Wk03NS41Mzg2IDE0OS4zMjJINzYuMjY1MlYxNTQuNTAyTDc2LjIwMjcgMTU1LjMyMkg3NS41Mzg2VjE0OS4zMjJaTTc5LjEyMDYgMTUzLjE3NFYxNTMuMjU2Qzc5LjEyMDYgMTUzLjU2MyA3OS4wODQyIDE1My44NDggNzkuMDExMyAxNTQuMTExQzc4LjkzODMgMTU0LjM3MiA3OC44MzE2IDE1NC41OTggNzguNjkwOSAxNTQuNzkxQzc4LjU1MDMgMTU0Ljk4NCA3OC4zNzg0IDE1NS4xMzMgNzguMTc1MyAxNTUuMjRDNzcuOTcyMiAxNTUuMzQ3IDc3LjczOTEgMTU1LjQgNzcuNDc2MSAxNTUuNEM3Ny4yMDc5IDE1NS40IDc2Ljk3MjIgMTU1LjM1NSA3Ni43NjkxIDE1NS4yNjRDNzYuNTY4NSAxNTUuMTcgNzYuMzk5MyAxNTUuMDM2IDc2LjI2MTMgMTU0Ljg2MUM3Ni4xMjMyIDE1NC42ODcgNzYuMDEyNiAxNTQuNDc2IDc1LjkyOTIgMTU0LjIyOUM3NS44NDg1IDE1My45ODEgNzUuNzkyNSAxNTMuNzAyIDc1Ljc2MTMgMTUzLjM5M1YxNTMuMDMzQzc1Ljc5MjUgMTUyLjcyMSA3NS44NDg1IDE1Mi40NDEgNzUuOTI5MiAxNTIuMTkzQzc2LjAxMjYgMTUxLjk0NiA3Ni4xMjMyIDE1MS43MzUgNzYuMjYxMyAxNTEuNTYxQzc2LjM5OTMgMTUxLjM4MyA3Ni41Njg1IDE1MS4yNDkgNzYuNzY5MSAxNTEuMTU4Qzc2Ljk2OTYgMTUxLjA2NCA3Ny4yMDI3IDE1MS4wMTggNzcuNDY4MyAxNTEuMDE4Qzc3LjczMzkgMTUxLjAxOCA3Ny45Njk2IDE1MS4wNyA3OC4xNzUzIDE1MS4xNzRDNzguMzgxIDE1MS4yNzUgNzguNTUyOSAxNTEuNDIxIDc4LjY5MDkgMTUxLjYxMUM3OC44MzE2IDE1MS44MDEgNzguOTM4MyAxNTIuMDI5IDc5LjAxMTMgMTUyLjI5NUM3OS4wODQyIDE1Mi41NTggNzkuMTIwNiAxNTIuODUxIDc5LjEyMDYgMTUzLjE3NFpNNzguMzk0MSAxNTMuMjU2VjE1My4xNzRDNzguMzk0MSAxNTIuOTYzIDc4LjM3NDUgMTUyLjc2NSA3OC4zMzU1IDE1Mi41OEM3OC4yOTY0IDE1Mi4zOTMgNzguMjMzOSAxNTIuMjI5IDc4LjE0OCAxNTIuMDg4Qzc4LjA2MiAxNTEuOTQ1IDc3Ljk0ODggMTUxLjgzMyA3Ny44MDgxIDE1MS43NTJDNzcuNjY3NSAxNTEuNjY5IDc3LjQ5NDMgMTUxLjYyNyA3Ny4yODg2IDE1MS42MjdDNzcuMTA2MyAxNTEuNjI3IDc2Ljk0NzUgMTUxLjY1OCA3Ni44MTIgMTUxLjcyMUM3Ni42NzkyIDE1MS43ODMgNzYuNTY1OSAxNTEuODY4IDc2LjQ3MjIgMTUxLjk3NUM3Ni4zNzg0IDE1Mi4wNzkgNzYuMzAxNiAxNTIuMTk5IDc2LjI0MTcgMTUyLjMzNEM3Ni4xODQ0IDE1Mi40NjcgNzYuMTQxNSAxNTIuNjA1IDc2LjExMjggMTUyLjc0OFYxNTMuNjg5Qzc2LjE1NDUgMTUzLjg3MiA3Ni4yMjIyIDE1NC4wNDggNzYuMzE1OSAxNTQuMjE3Qzc2LjQxMjMgMTU0LjM4MyA3Ni41Mzk5IDE1NC41MiA3Ni42OTg4IDE1NC42MjdDNzYuODYwMiAxNTQuNzM0IDc3LjA1OTQgMTU0Ljc4NyA3Ny4yOTY0IDE1NC43ODdDNzcuNDkxNyAxNTQuNzg3IDc3LjY1ODQgMTU0Ljc0OCA3Ny43OTY0IDE1NC42N0M3Ny45MzcgMTU0LjU4OSA3OC4wNTAzIDE1NC40NzkgNzguMTM2MyAxNTQuMzM4Qzc4LjIyNDggMTU0LjE5NyA3OC4yODk5IDE1NC4wMzUgNzguMzMxNiAxNTMuODVDNzguMzczMiAxNTMuNjY1IDc4LjM5NDEgMTUzLjQ2NyA3OC4zOTQxIDE1My4yNTZaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjU0Ii8+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMV80MTgzXzkwODc3KSI+CjxsaW5lIHgxPSIxNTUuNSIgeTE9IjE0Ny4wNzIiIHgyPSIxNTUuNSIgeTI9IjE0Ni4yNSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuNSIgc3Ryb2tlLXdpZHRoPSIwLjUiIHN0cm9rZS1saW5lY2FwPSJzcXVhcmUiLz4KPHBhdGggZD0iTTE0Ny41NTkgMTUyLjAyNVYxNTIuODkyQzE0Ny41NTkgMTUzLjM1OCAxNDcuNTE3IDE1My43NTIgMTQ3LjQzNCAxNTQuMDcyQzE0Ny4zNTEgMTU0LjM5MiAxNDcuMjMxIDE1NC42NSAxNDcuMDc1IDE1NC44NDVDMTQ2LjkxOCAxNTUuMDQxIDE0Ni43MyAxNTUuMTgzIDE0Ni41MDggMTU1LjI3MUMxNDYuMjkgMTU1LjM1NyAxNDYuMDQyIDE1NS40IDE0NS43NjYgMTU1LjRDMTQ1LjU0NyAxNTUuNCAxNDUuMzQ2IDE1NS4zNzMgMTQ1LjE2MSAxNTUuMzE4QzE0NC45NzYgMTU1LjI2MyAxNDQuODA5IDE1NS4xNzYgMTQ0LjY2MSAxNTUuMDU2QzE0NC41MTUgMTU0LjkzNCAxNDQuMzkgMTU0Ljc3NSAxNDQuMjg2IDE1NC41OEMxNDQuMTgxIDE1NC4zODUgMTQ0LjEwMiAxNTQuMTQ4IDE0NC4wNDcgMTUzLjg2OUMxNDMuOTkzIDE1My41OSAxNDMuOTY1IDE1My4yNjUgMTQzLjk2NSAxNTIuODkyVjE1Mi4wMjVDMTQzLjk2NSAxNTEuNTU5IDE0NC4wMDcgMTUxLjE2OCAxNDQuMDkgMTUwLjg1M0MxNDQuMTc2IDE1MC41MzggMTQ0LjI5NyAxNTAuMjg2IDE0NC40NTQgMTUwLjA5NUMxNDQuNjEgMTQ5LjkwMyAxNDQuNzk3IDE0OS43NjUgMTQ1LjAxNiAxNDkuNjgxQzE0NS4yMzcgMTQ5LjU5OCAxNDUuNDg1IDE0OS41NTYgMTQ1Ljc1OCAxNDkuNTU2QzE0NS45OCAxNDkuNTU2IDE0Ni4xODMgMTQ5LjU4NCAxNDYuMzY4IDE0OS42MzhDMTQ2LjU1NSAxNDkuNjkxIDE0Ni43MjIgMTQ5Ljc3NSAxNDYuODY4IDE0OS44OTJDMTQ3LjAxMyAxNTAuMDA3IDE0Ny4xMzcgMTUwLjE2MSAxNDcuMjM5IDE1MC4zNTNDMTQ3LjM0MyAxNTAuNTQzIDE0Ny40MjIgMTUwLjc3NiAxNDcuNDc3IDE1MS4wNTJDMTQ3LjUzMiAxNTEuMzI5IDE0Ny41NTkgMTUxLjY1MyAxNDcuNTU5IDE1Mi4wMjVaTTE0Ni44MzIgMTUzLjAxVjE1MS45MDRDMTQ2LjgzMiAxNTEuNjQ5IDE0Ni44MTcgMTUxLjQyNSAxNDYuNzg2IDE1MS4yMzJDMTQ2Ljc1NyAxNTEuMDM3IDE0Ni43MTQgMTUwLjg3IDE0Ni42NTcgMTUwLjczMkMxNDYuNTk5IDE1MC41OTQgMTQ2LjUyNyAxNTAuNDgyIDE0Ni40MzggMTUwLjM5NkMxNDYuMzUyIDE1MC4zMSAxNDYuMjUyIDE1MC4yNDggMTQ2LjEzNyAxNTAuMjA5QzE0Ni4wMjUgMTUwLjE2NyAxNDUuODk5IDE1MC4xNDYgMTQ1Ljc1OCAxNTAuMTQ2QzE0NS41ODYgMTUwLjE0NiAxNDUuNDM0IDE1MC4xNzkgMTQ1LjMwMSAxNTAuMjQ0QzE0NS4xNjggMTUwLjMwNiAxNDUuMDU2IDE1MC40MDcgMTQ0Ljk2NSAxNTAuNTQ1QzE0NC44NzcgMTUwLjY4MyAxNDQuODA5IDE1MC44NjQgMTQ0Ljc2MiAxNTEuMDg4QzE0NC43MTUgMTUxLjMxMiAxNDQuNjkyIDE1MS41ODQgMTQ0LjY5MiAxNTEuOTA0VjE1My4wMUMxNDQuNjkyIDE1My4yNjUgMTQ0LjcwNiAxNTMuNDkgMTQ0LjczNSAxNTMuNjg1QzE0NC43NjYgMTUzLjg4MSAxNDQuODEyIDE1NC4wNSAxNDQuODcyIDE1NC4xOTNDMTQ0LjkzMSAxNTQuMzM0IDE0NS4wMDQgMTU0LjQ1IDE0NS4wOSAxNTQuNTQxQzE0NS4xNzYgMTU0LjYzMiAxNDUuMjc1IDE1NC43IDE0NS4zODcgMTU0Ljc0NEMxNDUuNTAyIDE1NC43ODYgMTQ1LjYyOCAxNTQuODA2IDE0NS43NjYgMTU0LjgwNkMxNDUuOTQzIDE1NC44MDYgMTQ2LjA5OCAxNTQuNzczIDE0Ni4yMzEgMTU0LjcwNUMxNDYuMzY0IDE1NC42MzcgMTQ2LjQ3NCAxNTQuNTMyIDE0Ni41NjMgMTU0LjM4OEMxNDYuNjU0IDE1NC4yNDMgMTQ2LjcyMiAxNTQuMDU2IDE0Ni43NjYgMTUzLjgzQzE0Ni44MSAxNTMuNjAxIDE0Ni44MzIgMTUzLjMyNyAxNDYuODMyIDE1My4wMVpNMTUyLjI5OCAxNTQuNzI4VjE1NS4zMjJIMTQ4LjU3NVYxNTQuODAyTDE1MC40MzkgMTUyLjcyOEMxNTAuNjY4IDE1Mi40NzMgMTUwLjg0NSAxNTIuMjU3IDE1MC45NyAxNTIuMDhDMTUxLjA5NyAxNTEuOSAxNTEuMTg2IDE1MS43NCAxNTEuMjM1IDE1MS41OTlDMTUxLjI4OCAxNTEuNDU2IDE1MS4zMTQgMTUxLjMxIDE1MS4zMTQgMTUxLjE2MkMxNTEuMzE0IDE1MC45NzQgMTUxLjI3NCAxNTAuODA1IDE1MS4xOTYgMTUwLjY1NEMxNTEuMTIxIDE1MC41IDE1MS4wMDkgMTUwLjM3OCAxNTAuODYgMTUwLjI4N0MxNTAuNzEyIDE1MC4xOTYgMTUwLjUzMiAxNTAuMTUgMTUwLjMyMSAxNTAuMTVDMTUwLjA2OSAxNTAuMTUgMTQ5Ljg1OCAxNTAuMiAxNDkuNjg5IDE1MC4yOTlDMTQ5LjUyMiAxNTAuMzk1IDE0OS4zOTcgMTUwLjUzIDE0OS4zMTQgMTUwLjcwNUMxNDkuMjMgMTUwLjg3OSAxNDkuMTg5IDE1MS4wOCAxNDkuMTg5IDE1MS4zMDZIMTQ4LjQ2NkMxNDguNDY2IDE1MC45ODYgMTQ4LjUzNiAxNTAuNjkzIDE0OC42NzcgMTUwLjQyN0MxNDguODE3IDE1MC4xNjIgMTQ5LjAyNiAxNDkuOTUxIDE0OS4zMDIgMTQ5Ljc5NUMxNDkuNTc4IDE0OS42MzYgMTQ5LjkxOCAxNDkuNTU2IDE1MC4zMjEgMTQ5LjU1NkMxNTAuNjgxIDE0OS41NTYgMTUwLjk4OCAxNDkuNjIgMTUxLjI0MyAxNDkuNzQ4QzE1MS40OTggMTQ5Ljg3MyAxNTEuNjk0IDE1MC4wNSAxNTEuODI5IDE1MC4yNzlDMTUxLjk2NyAxNTAuNTA2IDE1Mi4wMzYgMTUwLjc3MSAxNTIuMDM2IDE1MS4wNzZDMTUyLjAzNiAxNTEuMjQzIDE1Mi4wMDggMTUxLjQxMiAxNTEuOTUgMTUxLjU4NEMxNTEuODk2IDE1MS43NTMgMTUxLjgxOSAxNTEuOTIyIDE1MS43MiAxNTIuMDkyQzE1MS42MjMgMTUyLjI2MSAxNTEuNTEgMTUyLjQyNyAxNTEuMzggMTUyLjU5MkMxNTEuMjUyIDE1Mi43NTYgMTUxLjExNiAxNTIuOTE3IDE1MC45NyAxNTMuMDc2TDE0OS40NDYgMTU0LjcyOEgxNTIuMjk4Wk0xNTYuMTYyIDE0OS42MzVWMTU1LjMyMkgxNTUuNDA5VjE0OS42MzVIMTU2LjE2MlpNMTU4LjU0NSAxNTIuMTkzVjE1Mi44MUgxNTUuOTk4VjE1Mi4xOTNIMTU4LjU0NVpNMTU4LjkzMiAxNDkuNjM1VjE1MC4yNTJIMTU1Ljk5OFYxNDkuNjM1SDE1OC45MzJaTTE2MS40NzIgMTU1LjRDMTYxLjE3NyAxNTUuNCAxNjAuOTEgMTU1LjM1MSAxNjAuNjcxIDE1NS4yNTJDMTYwLjQzNCAxNTUuMTUgMTYwLjIyOSAxNTUuMDA4IDE2MC4wNTggMTU0LjgyNkMxNTkuODg4IDE1NC42NDQgMTU5Ljc1OCAxNTQuNDI3IDE1OS42NjcgMTU0LjE3N0MxNTkuNTc2IDE1My45MjcgMTU5LjUzIDE1My42NTQgMTU5LjUzIDE1My4zNTdWMTUzLjE5M0MxNTkuNTMgMTUyLjg0OSAxNTkuNTgxIDE1Mi41NDMgMTU5LjY4MyAxNTIuMjc1QzE1OS43ODQgMTUyLjAwNCAxNTkuOTIyIDE1MS43NzUgMTYwLjA5NyAxNTEuNTg4QzE2MC4yNzEgMTUxLjQgMTYwLjQ2OSAxNTEuMjU4IDE2MC42OSAxNTEuMTYyQzE2MC45MTIgMTUxLjA2NiAxNjEuMTQxIDE1MS4wMTcgMTYxLjM3OCAxNTEuMDE3QzE2MS42OCAxNTEuMDE3IDE2MS45NCAxNTEuMDY5IDE2Mi4xNTkgMTUxLjE3NEMxNjIuMzggMTUxLjI3OCAxNjIuNTYxIDE1MS40MjQgMTYyLjcwMiAxNTEuNjExQzE2Mi44NDMgMTUxLjc5NiAxNjIuOTQ3IDE1Mi4wMTUgMTYzLjAxNSAxNTIuMjY3QzE2My4wODIgMTUyLjUxNyAxNjMuMTE2IDE1Mi43OTEgMTYzLjExNiAxNTMuMDg4VjE1My40MTJIMTU5Ljk2VjE1Mi44MjJIMTYyLjM5M1YxNTIuNzY3QzE2Mi4zODMgMTUyLjU4IDE2Mi4zNDQgMTUyLjM5OCAxNjIuMjc2IDE1Mi4yMkMxNjIuMjExIDE1Mi4wNDMgMTYyLjEwNyAxNTEuODk4IDE2MS45NjQgMTUxLjc4M0MxNjEuODIxIDE1MS42NjggMTYxLjYyNSAxNTEuNjExIDE2MS4zNzggMTUxLjYxMUMxNjEuMjE0IDE1MS42MTEgMTYxLjA2MyAxNTEuNjQ2IDE2MC45MjUgMTUxLjcxN0MxNjAuNzg3IDE1MS43ODQgMTYwLjY2OCAxNTEuODg2IDE2MC41NjkgMTUyLjAyMUMxNjAuNDcgMTUyLjE1NyAxNjAuMzkzIDE1Mi4zMjIgMTYwLjMzOSAxNTIuNTE3QzE2MC4yODQgMTUyLjcxMyAxNjAuMjU3IDE1Mi45MzggMTYwLjI1NyAxNTMuMTkzVjE1My4zNTdDMTYwLjI1NyAxNTMuNTU4IDE2MC4yODQgMTUzLjc0NyAxNjAuMzM5IDE1My45MjRDMTYwLjM5NiAxNTQuMDk4IDE2MC40NzggMTU0LjI1MiAxNjAuNTg1IDE1NC4zODVDMTYwLjY5NCAxNTQuNTE3IDE2MC44MjYgMTU0LjYyMiAxNjAuOTc5IDE1NC42OTdDMTYxLjEzNiAxNTQuNzczIDE2MS4zMTMgMTU0LjgxIDE2MS41MTEgMTU0LjgxQzE2MS43NjYgMTU0LjgxIDE2MS45ODIgMTU0Ljc1OCAxNjIuMTU5IDE1NC42NTRDMTYyLjMzNiAxNTQuNTUgMTYyLjQ5MSAxNTQuNDExIDE2Mi42MjQgMTU0LjIzNkwxNjMuMDYxIDE1NC41ODRDMTYyLjk3IDE1NC43MjIgMTYyLjg1NCAxNTQuODUzIDE2Mi43MTQgMTU0Ljk3OEMxNjIuNTczIDE1NS4xMDMgMTYyLjQgMTU1LjIwNSAxNjIuMTk0IDE1NS4yODNDMTYxLjk5MSAxNTUuMzYxIDE2MS43NSAxNTUuNCAxNjEuNDcyIDE1NS40Wk0xNjQuMDM5IDE0OS4zMjJIMTY0Ljc2NVYxNTQuNTAyTDE2NC43MDMgMTU1LjMyMkgxNjQuMDM5VjE0OS4zMjJaTTE2Ny42MjEgMTUzLjE3NFYxNTMuMjU2QzE2Ny42MjEgMTUzLjU2MyAxNjcuNTg0IDE1My44NDggMTY3LjUxMSAxNTQuMTExQzE2Ny40MzggMTU0LjM3MiAxNjcuMzMyIDE1NC41OTggMTY3LjE5MSAxNTQuNzkxQzE2Ny4wNSAxNTQuOTgzIDE2Ni44NzggMTU1LjEzMyAxNjYuNjc1IDE1NS4yNEMxNjYuNDcyIDE1NS4zNDcgMTY2LjIzOSAxNTUuNCAxNjUuOTc2IDE1NS40QzE2NS43MDggMTU1LjQgMTY1LjQ3MiAxNTUuMzU1IDE2NS4yNjkgMTU1LjI2M0MxNjUuMDY5IDE1NS4xNyAxNjQuODk5IDE1NS4wMzYgMTY0Ljc2MSAxNTQuODYxQzE2NC42MjMgMTU0LjY4NyAxNjQuNTEzIDE1NC40NzYgMTY0LjQyOSAxNTQuMjI4QzE2NC4zNDggMTUzLjk4MSAxNjQuMjkzIDE1My43MDIgMTY0LjI2MSAxNTMuMzkyVjE1My4wMzNDMTY0LjI5MyAxNTIuNzIgMTY0LjM0OCAxNTIuNDQxIDE2NC40MjkgMTUyLjE5M0MxNjQuNTEzIDE1MS45NDYgMTY0LjYyMyAxNTEuNzM1IDE2NC43NjEgMTUxLjU2QzE2NC44OTkgMTUxLjM4MyAxNjUuMDY5IDE1MS4yNDkgMTY1LjI2OSAxNTEuMTU4QzE2NS40NyAxNTEuMDY0IDE2NS43MDMgMTUxLjAxNyAxNjUuOTY4IDE1MS4wMTdDMTY2LjIzNCAxNTEuMDE3IDE2Ni40NyAxNTEuMDY5IDE2Ni42NzUgMTUxLjE3NEMxNjYuODgxIDE1MS4yNzUgMTY3LjA1MyAxNTEuNDIxIDE2Ny4xOTEgMTUxLjYxMUMxNjcuMzMyIDE1MS44MDEgMTY3LjQzOCAxNTIuMDI5IDE2Ny41MTEgMTUyLjI5NUMxNjcuNTg0IDE1Mi41NTggMTY3LjYyMSAxNTIuODUxIDE2Ny42MjEgMTUzLjE3NFpNMTY2Ljg5NCAxNTMuMjU2VjE1My4xNzRDMTY2Ljg5NCAxNTIuOTYzIDE2Ni44NzUgMTUyLjc2NSAxNjYuODM1IDE1Mi41OEMxNjYuNzk2IDE1Mi4zOTIgMTY2LjczNCAxNTIuMjI4IDE2Ni42NDggMTUyLjA4OEMxNjYuNTYyIDE1MS45NDQgMTY2LjQ0OSAxNTEuODMyIDE2Ni4zMDggMTUxLjc1MkMxNjYuMTY4IDE1MS42NjggMTY1Ljk5NCAxNTEuNjI3IDE2NS43ODkgMTUxLjYyN0MxNjUuNjA2IDE1MS42MjcgMTY1LjQ0NyAxNTEuNjU4IDE2NS4zMTIgMTUxLjcyQzE2NS4xNzkgMTUxLjc4MyAxNjUuMDY2IDE1MS44NjggMTY0Ljk3MiAxNTEuOTc0QzE2NC44NzggMTUyLjA3OSAxNjQuODAyIDE1Mi4xOTggMTY0Ljc0MiAxNTIuMzM0QzE2NC42ODQgMTUyLjQ2NyAxNjQuNjQxIDE1Mi42MDUgMTY0LjYxMyAxNTIuNzQ4VjE1My42ODlDMTY0LjY1NCAxNTMuODcyIDE2NC43MjIgMTU0LjA0NyAxNjQuODE2IDE1NC4yMTdDMTY0LjkxMiAxNTQuMzgzIDE2NS4wNCAxNTQuNTIgMTY1LjE5OSAxNTQuNjI3QzE2NS4zNiAxNTQuNzMzIDE2NS41NTkgMTU0Ljc4NyAxNjUuNzk2IDE1NC43ODdDMTY1Ljk5MiAxNTQuNzg3IDE2Ni4xNTggMTU0Ljc0OCAxNjYuMjk2IDE1NC42N0MxNjYuNDM3IDE1NC41ODkgMTY2LjU1IDE1NC40NzggMTY2LjYzNiAxNTQuMzM4QzE2Ni43MjUgMTU0LjE5NyAxNjYuNzkgMTU0LjAzNCAxNjYuODMyIDE1My44NDlDMTY2Ljg3MyAxNTMuNjY0IDE2Ni44OTQgMTUzLjQ2NyAxNjYuODk0IDE1My4yNTZaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjU0Ii8+CjwvZz4KPC9nPgo8ZGVmcz4KPGNsaXBQYXRoIGlkPSJjbGlwMF80MTgzXzkwODc3Ij4KPHJlY3Qgd2lkdGg9IjIwMCIgaGVpZ2h0PSIxNjAiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjxjbGlwUGF0aCBpZD0iY2xpcDFfNDE4M185MDg3NyI+CjxyZWN0IHdpZHRoPSI4OC41IiBoZWlnaHQ9IjEwLjMyMiIgZmlsbD0id2hpdGUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDExMS41IDE0NikiLz4KPC9jbGlwUGF0aD4KPC9kZWZzPgo8L3N2Zz4K", - "description": "Displays changes to time-series data over time—for example, temperature or humidity readings.", + "description": "Displays changes to time series data over time—for example, temperature or humidity readings.", "descriptor": { "type": "timeseries", "sizeX": 8, diff --git a/application/src/main/data/json/system/widget_types/bar_chart_with_labels.json b/application/src/main/data/json/system/widget_types/bar_chart_with_labels.json index 356e91f90e..4b2b1b53c2 100644 --- a/application/src/main/data/json/system/widget_types/bar_chart_with_labels.json +++ b/application/src/main/data/json/system/widget_types/bar_chart_with_labels.json @@ -3,7 +3,7 @@ "name": "Bar chart with labels", "deprecated": false, "image": "tb-image:QmFyLWNoYXJ0LXdpdGgtbGFiZWxzLnN2Zw==:IkJhciBjaGFydCB3aXRoIGxhYmVscyIgc3lzdGVtIHdpZGdldCBpbWFnZQ==;data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMDEiIGhlaWdodD0iMTYwIiBmaWxsPSJub25lIj48cGF0aCBmaWxsPSIjMDAwIiBmaWxsLW9wYWNpdHk9Ii41IiBkPSJNMi44IDQuM1YxMGgtLjdWNS4ybC0xLjQuNXYtLjZsMi0uOGguMVptNS45IDIuNHYuOWwtLjEgMS4yLS40LjdjLS4yLjItLjMuNC0uNi40YTIgMiAwIDAgMS0uNy4ybC0uNi0uMWMtLjIgMC0uNC0uMS0uNS0uMy0uMiAwLS4zLS4yLS40LS40bC0uMi0uOC0uMS0xdi0uOGwuMS0xLjIuNC0uN2MuMS0uMi4zLS40LjUtLjRsLjgtLjIuNi4xYTEuNCAxLjQgMCAwIDEgLjkuN2wuMi43djFabS0uNyAxVjUuOWwtLjItLjVhMSAxIDAgMCAwLS4yLS4zbC0uMy0uMmExIDEgMCAwIDAtLjQgMCAxIDEgMCAwIDAtLjUgMGwtLjMuMy0uMi42djIuNmwuMS41LjIuMy4zLjJoLjlsLjMtLjMuMi0uNnYtLjhabTUuMy0xdjIuMWwtLjUuN2MtLjEuMi0uMy40LS41LjRhMiAyIDAgMCAxLS44LjJMMTEgMTBjLS4yIDAtLjMtLjEtLjUtLjNsLS4zLS40LS4zLS44di0zbC40LS43Yy4yLS4yLjQtLjQuNi0uNGwuNy0uMi42LjFhMS40IDEuNCAwIDAgMSAxIC43bC4xLjcuMSAxWm0tLjcgMVY1LjlsLS4yLS41YTEgMSAwIDAgMC0uMi0uM2wtLjMtLjJhMSAxIDAgMCAwLS40IDAgMSAxIDAgMCAwLS40IDBjLS4yIDAtLjMuMi0uNC4zbC0uMi42djIuNmwuMS41LjMuM2MwIC4xLjEuMi4zLjJoLjhsLjMtLjMuMi0uNi4xLS44Wm0xLjctMnYtLjNjMC0uMiAwLS40LjItLjYgMC0uMi4yLS4zLjQtLjRsLjYtLjJjLjMgMCAuNSAwIC42LjJsLjQuNC4yLjZ2LjNjMCAuMiAwIC40LS4yLjYgMCAuMi0uMi4zLS40LjRsLS42LjJjLS4yIDAtLjQgMC0uNi0uMmExIDEgMCAwIDEtLjQtLjRsLS4yLS42Wm0uNi0uM1Y2bC4zLjMuMy4xaC40TDE2IDZWNS4xYS42LjYgMCAwIDAtLjYtLjQuNi42IDAgMCAwLS41LjRsLS4xLjNaTTE3IDl2LS4zYzAtLjIgMC0uNC4yLS42IDAtLjIuMi0uMy40LS40bC42LS4yYy4yIDAgLjQgMCAuNi4ybC40LjQuMS42VjlsLS4xLjZjMCAuMi0uMi4zLS40LjRsLS42LjJjLS4zIDAtLjUgMC0uNi0uMi0uMiAwLS4zLS4yLS40LS40bC0uMi0uNlptLjYtLjN2LjdsLjIuMi40LjFoLjNsLjItLjMuMS0uNHYtLjZhLjYuNiAwIDAgMC0uNi0uNC42LjYgMCAwIDAtLjYuNHYuM1ptLjgtMy41LTIuOCA0LjUtLjQtLjNMMTggNWwuNC4yWk02IDM2YzAgLjQgMCAuNy0uMiAxbC0uNi41LTEgLjJjLS4zIDAtLjYgMC0uOS0uMi0uMi0uMS0uNS0uMy0uNi0uNi0uMi0uMi0uMy0uNS0uMy0uOGExLjUgMS41IDAgMCAxIDEuMS0xLjVsLjctLjFjLjQgMCAuNyAwIDEgLjIuMi4xLjUuMy42LjYuMi4yLjMuNS4zLjhabS0uNyAwLS4xLS41YTEgMSAwIDAgMC0uNC0uNGwtLjYtLjEtLjUuMWExIDEgMCAwIDAtLjQuNGwtLjEuNXYuNmwuNS40aDEuMWwuNC0uNC4xLS42Wm0uNi0yLjZjMCAuMyAwIC41LS4yLjctLjEuMy0uMy40LS42LjZsLS44LjJhMiAyIDAgMCAxLTEtLjJjLS4yLS4yLS40LS4zLS41LS42LS4yLS4yLS4yLS40LS4yLS43IDAtLjMgMC0uNi4yLS45LjEtLjIuMy0uNC42LS41bC44LS4yIDEgLjIuNS41Yy4yLjMuMi42LjIuOVptLS43IDAtLjEtLjVjLS4xLS4xLS4yLS4zLS40LS4zYTEgMSAwIDAgMC0uNS0uMiAxIDEgMCAwIDAtLjUuMWwtLjMuNC0uMS41LjEuNWMwIC4yLjIuMy40LjRoLjlsLjQtLjQuMS0uNVptNS41Ljl2LjlsLS4xIDEuMS0uNC44Yy0uMi4yLS4zLjQtLjYuNGEyIDIgMCAwIDEtLjcuMmwtLjYtLjFjLS4yIDAtLjQtLjEtLjUtLjMtLjIgMC0uMy0uMi0uNC0uNGwtLjItLjgtLjEtMXYtLjhsLjEtMS4yLjQtLjdjLjEtLjIuMy0uNC41LS40bC44LS4yLjYuMWExLjQgMS40IDAgMCAxIC45LjdsLjIuN3YxWm0tLjcgMXYtMS44bC0uMi0uNWExIDEgMCAwIDAtLjItLjNsLS4zLS4yYTEgMSAwIDAgMC0uNCAwIDEgMSAwIDAgMC0uNSAwbC0uMy4zLS4yLjZWMzZsLjEuNS4yLjMuMy4yaC45bC4zLS4zLjItLjZ2LS44Wm0xLjctMlYzM2wuMS0uNi40LS40LjctLjJjLjIgMCAuNCAwIC42LjJsLjQuNC4xLjZ2LjNsLS4xLjYtLjQuNC0uNi4yYy0uMyAwLS41IDAtLjctLjJhMSAxIDAgMCAxLS40LS40bC0uMS0uNlptLjUtLjN2LjNsLjEuM2MwIC4yLjEuMi4yLjNsLjQuMWguM2wuMi0uNFYzMi43YS42LjYgMCAwIDAtLjUtLjQuNi42IDAgMCAwLS42LjR2LjNabTIuMyAzLjV2LS4zbC4xLS42LjQtLjQuNi0uMmMuMyAwIC41IDAgLjcuMmwuNC40LjEuNnYuM2wtLjEuNi0uNC40LS43LjJjLS4yIDAtLjQgMC0uNi0uMi0uMiAwLS4zLS4yLS40LS40bC0uMS0uNlptLjUtLjN2LjdsLjMuMi4zLjFoLjRjMC0uMi4yLS4yLjItLjN2LTFhLjYuNiAwIDAgMC0uNi0uNC42LjYgMCAwIDAtLjUuNHYuM1ptLjgtMy41TDEzIDM3LjJsLS40LS4zIDIuOC00LjQuNC4yWk01LjIgNTkuNWguMXYuNmMtLjQgMC0uOCAwLTEgLjItLjMuMS0uNS4zLS42LjVhMiAyIDAgMCAwLS40Ljd2Mi4zbC4zLjUuMy4zaC45bC4zLS4zLjItLjRhMS44IDEuOCAwIDAgMCAwLTFjMC0uMiAwLS4zLS4yLS40YTEgMSAwIDAgMC0uNy0uNGwtLjYuMS0uNC40YTEgMSAwIDAgMC0uMi41aC0uNGwuMi0uNy40LS41Yy4xLS4yLjMtLjMuNS0uM2ExLjkgMS45IDAgMCAxIDEuMyAwbC41LjVjLjIuMS4zLjMuMy42QTIuNCAyLjQgMCAwIDEgNiA2NGwtLjMuNi0uNi40LS44LjJjLS4zIDAtLjUgMC0uOC0uMmwtLjUtLjUtLjQtLjd2LTIuM2wuNS0xYy4yLS40LjQtLjYuOC0uOGEzIDMgMCAwIDEgMS4zLS4zWm01LjUgMi40di45bC0uMSAxLjItLjQuN2MtLjIuMi0uMy40LS42LjRhMiAyIDAgMCAxLS43LjJsLS42LS4xYy0uMiAwLS40LS4xLS41LS4zLS4yIDAtLjMtLjItLjQtLjRsLS4yLS44LS4xLTFWNjJsLjEtMS4yLjQtLjdjLjEtLjIuMy0uNC41LS40bC44LS4yLjYuMWExLjQgMS40IDAgMCAxIC45LjdsLjIuN3YxWm0tLjcgMVY2MWwtLjItLjVhMSAxIDAgMCAwLS4yLS4zbC0uMy0uMmExIDEgMCAwIDAtLjQgMCAxIDEgMCAwIDAtLjUgMGwtLjMuMy0uMi42djIuNmwuMS41LjIuMy4zLjJoLjlsLjMtLjMuMi0uNlY2M1ptMS43LTJ2LS4zbC4xLS42LjQtLjQuNy0uMmMuMiAwIC40IDAgLjYuMmwuNC40LjEuNnYuM2wtLjEuNi0uNC40LS42LjJjLS4zIDAtLjUgMC0uNy0uMmExIDEgMCAwIDEtLjQtLjRsLS4xLS42Wm0uNS0uM3YuM2wuMS4zYzAgLjIuMS4yLjIuM2wuNC4xaC4zbC4yLS40VjYwLjNhLjYuNiAwIDAgMC0uNS0uNGwtLjQuMS0uMi4zdi4zWm0yLjMgMy41di0uM2wuMS0uNi40LS40LjYtLjJjLjMgMCAuNSAwIC43LjJsLjQuNC4xLjZ2LjNsLS4xLjYtLjQuNC0uNy4yYy0uMiAwLS40IDAtLjYtLjItLjIgMC0uMy0uMi0uNC0uNGwtLjEtLjZabS41LS4zdi43bC4zLjIuMy4xaC40YzAtLjIuMi0uMi4yLS4zdi0xYS42LjYgMCAwIDAtLjYtLjQuNi42IDAgMCAwLS41LjR2LjNabS44LTMuNUwxMyA2NC44bC0uNC0uMyAyLjgtNC40LjQuMlpNNi4zIDkwLjl2LjZoLTRWOTFsMi41LTRoLjVsLS42IDEuMkwzIDkwLjloMy4zWk01LjUgODd2NS43aC0uN3YtNS43aC43Wm01LjIgMi40di45bC0uMSAxLjEtLjQuOGMtLjIuMi0uMy40LS42LjRhMiAyIDAgMCAxLS43LjJsLS42LS4xYy0uMiAwLS40LS4xLS41LS4zLS4yIDAtLjMtLjItLjQtLjRsLS4yLS44LS4xLTF2LS44bC4xLTEuMi40LS43Yy4xLS4yLjMtLjQuNS0uNEw5IDg3bC42LjFhMS40IDEuNCAwIDAgMSAuOS43bC4yLjd2MVptLS43IDF2LTEuOGwtLjItLjVhMSAxIDAgMCAwLS4yLS4zbC0uMy0uMmExIDEgMCAwIDAtLjQgMCAxIDEgMCAwIDAtLjUgMGwtLjMuMy0uMi42djIuNmwuMS41LjIuMy4zLjJoLjlsLjMtLjMuMi0uNnYtLjhabTEuNy0ydi0uM2wuMS0uNi40LS40LjctLjJjLjIgMCAuNCAwIC42LjJsLjQuNC4xLjZ2LjNsLS4xLjYtLjQuNC0uNi4yYy0uMyAwLS41IDAtLjctLjJhMSAxIDAgMCAxLS40LS40bC0uMS0uNlptLjUtLjN2LjNsLjEuM2MwIC4yLjEuMi4yLjNsLjQuMWguM2wuMi0uNFY4OGEuNi42IDAgMCAwLS41LS40bC0uNC4xLS4yLjN2LjNabTIuMyAzLjV2LS4zbC4xLS42LjQtLjQuNi0uMmMuMyAwIC41IDAgLjcuMmwuNC40LjEuNnYuM2wtLjEuNi0uNC40LS43LjJjLS4yIDAtLjQgMC0uNi0uMi0uMiAwLS4zLS4yLS40LS40bC0uMS0uNlptLjUtLjN2LjdsLjMuMi4zLjFoLjRjMC0uMi4yLS4yLjItLjN2LTFhLjYuNiAwIDAgMC0uNi0uNC42LjYgMCAwIDAtLjUuNHYuM1ptLjgtMy41TDEzIDkyLjRsLS40LS4zIDIuOC00LjQuNC4yWk02LjIgMTE5Ljh2LjZIMi41di0uNWwxLjgtMiAuNi0uNy4yLS41LjEtLjUtLjEtLjUtLjMtLjNhMSAxIDAgMCAwLS42LS4yYy0uMiAwLS40IDAtLjYuMmExIDEgMCAwIDAtLjQuNGwtLjEuNmgtLjdjMC0uMyAwLS42LjItLjkuMS0uMy4zLS41LjYtLjZhMiAyIDAgMCAxIDEtLjNsMSAuMmMuMi4yLjQuMy41LjYuMi4yLjIuNC4yLjh2LjVsLS4zLjVhNy44IDcuOCAwIDAgMS0uOCAxbC0xLjUgMS42aDIuOVptNC41LTIuN3YuOWwtLjEgMS4yLS40LjdjLS4yLjItLjMuNC0uNi40YTIgMiAwIDAgMS0uNy4ybC0uNi0uMWMtLjIgMC0uNC0uMS0uNS0uMy0uMiAwLS4zLS4yLS40LS40bC0uMi0uOC0uMS0xdi0uOGwuMS0xLjIuNC0uN2MuMS0uMi4zLS40LjUtLjRsLjgtLjIuNi4xYTEuNCAxLjQgMCAwIDEgLjkuN2wuMi43djFabS0uNyAxdi0xLjhsLS4yLS41YTEgMSAwIDAgMC0uMi0uM2wtLjMtLjJhMSAxIDAgMCAwLS40IDAgMSAxIDAgMCAwLS41IDBsLS4zLjMtLjIuNnYyLjZsLjEuNS4yLjMuMy4yaC45bC4zLS4zLjItLjZ2LS44Wm0xLjctMnYtLjNsLjEtLjYuNC0uNC43LS4yYy4yIDAgLjQgMCAuNi4ybC40LjQuMS42di4zbC0uMS42LS40LjQtLjYuMmMtLjMgMC0uNSAwLS43LS4yYTEgMSAwIDAgMS0uNC0uNGwtLjEtLjZabS41LS4zdi4zbC4xLjNjMCAuMi4xLjIuMi4zbC40LjFoLjNsLjItLjRWMTE1LjVhLjYuNiAwIDAgMC0uNS0uNC42LjYgMCAwIDAtLjYuNHYuM1ptMi4zIDMuNXYtLjNsLjEtLjYuNC0uNC42LS4yYy4zIDAgLjUgMCAuNy4ybC40LjQuMS42di4zbC0uMS42LS40LjQtLjcuMmMtLjIgMC0uNCAwLS42LS4yLS4yIDAtLjMtLjItLjQtLjRsLS4xLS42Wm0uNS0uM3YuN2wuMy4yLjMuMWguNGMwLS4yLjItLjIuMi0uM3YtMWEuNi42IDAgMCAwLS42LS40LjYuNiAwIDAgMC0uNS40di4zWm0uOC0zLjVMMTMgMTIwbC0uNC0uMyAyLjgtNC40LjQuMlpNOC41IDE0NC43djIuMWwtLjQuN2MtLjIuMi0uNC40LS42LjRhMiAyIDAgMCAxLS44LjJsLS42LS4xLS41LS4zLS4zLS40LS4zLS44di0zbC40LS43Yy4yLS4yLjQtLjQuNi0uNGwuNy0uMi43LjFhMS40IDEuNCAwIDAgMSAuOC43bC4zLjd2MVptLS43IDF2LTEuOGwtLjItLjVhMSAxIDAgMCAwLS4yLS4zbC0uMy0uMmExIDEgMCAwIDAtLjQgMCAxIDEgMCAwIDAtLjQgMGwtLjQuMy0uMi42djIuNmwuMi41YzAgLjEgMCAuMi4yLjNsLjMuMmguOGwuMy0uMy4zLS42di0uOFptMS44LTJ2LS4zbC4xLS42YzAtLjIuMi0uMy40LS40bC42LS4yYy4zIDAgLjUgMCAuNi4yLjIuMS40LjIuNC40bC4yLjZ2LjNjMCAuMiAwIC40LS4yLjYgMCAuMi0uMi4zLS40LjRsLS42LjJjLS4yIDAtLjQgMC0uNi0uMmExIDEgMCAwIDEtLjQtLjRsLS4xLS42Wm0uNS0uM3YuNmwuMy4zLjMuMWguNGwuMi0uNFYxNDMuMWEuNi42IDAgMCAwLS42LS40LjYuNiAwIDAgMC0uNS40bC0uMS4zWm0yLjIgMy41di0uM2MwLS4yIDAtLjQuMi0uNiAwLS4yLjItLjMuNC0uNGwuNi0uMmMuMiAwIC40IDAgLjYuMmwuNC40LjIuNnYuM2MwIC4yIDAgLjQtLjIuNiAwIC4yLS4yLjMtLjQuNGwtLjYuMmMtLjIgMC0uNSAwLS42LS4yLS4yIDAtLjMtLjItLjQtLjRsLS4yLS42Wm0uNi0uM3YuN2wuMi4yLjQuMWguM2wuMy0uM3YtMWEuNi42IDAgMCAwLS42LS40LjYuNiAwIDAgMC0uNi40di4zWm0uOC0zLjUtMi44IDQuNS0uNC0uMyAyLjgtNC40LjQuMloiLz48cGF0aCBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJzcXVhcmUiIHN0cm9rZS1vcGFjaXR5PSIuMSIgc3Ryb2tlLXdpZHRoPSIuNCIgZD0iTTI0LjIgNi44aDE3NS42TTI0LjIgMzQuNmgxNzUuNk0yNC4yIDYyLjRoMTc1LjZNMjQuMiA5MC4yaDE3NS42TTI0LjIgMTE4aDE3NS42Ii8+PHBhdGggc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0ic3F1YXJlIiBzdHJva2Utb3BhY2l0eT0iLjUiIHN0cm9rZS13aWR0aD0iLjQiIGQ9Ik0yNC4yIDE0NS44aDE3NS42Ii8+PHBhdGggZmlsbD0iIzdEOEVGRiIgZD0iTTMwIDI1LjVoMTh2MTIwSDMweiIvPjxwYXRoIGZpbGw9IiMwMDAiIGZpbGwtb3BhY2l0eT0iLjkiIGQ9Ik00MC40IDEzNS40Yy40IDAgLjcgMCAxIC4ybC41LjcuMiAxYzAgLjMgMCAuNi0uMi45LS4xLjMtLjMuNS0uNi43bC0uOS4yYTEuNSAxLjUgMCAwIDEtMS0uNWwtLjQtLjYtLjEtLjhjMC0uMyAwLS43LjItMWwuNS0uNmMuMy0uMi41LS4yLjgtLjJabTAgLjlhMSAxIDAgMCAwLS41LjFsLS4zLjMtLjEuNXYuNWMuMS4yLjMuMy40LjNsLjUuMmMuMiAwIC40IDAgLjUtLjJsLjMtLjMuMS0uNXYtLjVsLS40LS4zYTEgMSAwIDAgMC0uNS0uMVptLTIuNi0uOGMuMyAwIC41IDAgLjguMmwuNS42LjIgMWMwIC4zIDAgLjYtLjIuOGwtLjUuNy0uOC4yYy0uMyAwLS42LS4xLS44LS4zLS4zLS4xLS41LS4zLS42LS42bC0uMi0uOS4yLS45LjYtLjYuOC0uMlptMCAxYTEgMSAwIDAgMC0uNCAwIC43LjcgMCAwIDAtLjQuN3YuNWwuNC4yLjQuMWguNWwuMy0uMy4xLS41LS4xLS40LS4zLS4zYTEgMSAwIDAgMC0uNSAwWm0xLjYtMy0uMi43LTIuOS0uM3YtM2guOHYyLjJsMS40LjJhMS44IDEuOCAwIDAgMS0uMi0xbC4xLS43YzAtLjIuMi0uNC40LS41LjEtLjIuMy0uMy42LS4zYTIuNSAyLjUgMCAwIDEgMS41IDBsLjYuMy40LjYuMi44LS4xLjctLjMuNmExLjYgMS42IDAgMCAxLTEuMi42di0xbC40LS4xYy4yIDAgLjMtLjIuMy0uM2wuMS0uNXYtLjRsLS4zLS4zYTEgMSAwIDAgMC0uNC0uMiAxLjggMS44IDAgMCAwLS45IDAgMSAxIDAgMCAwLS40LjJsLS4yLjR2LjlsLjMuM1oiLz48cGF0aCBmaWxsPSIjMDAwIiBmaWxsLW9wYWNpdHk9Ii41IiBkPSJNMzguOCAxMjMuOGguNnYzaC0uNnYtM1ptLTIuNSAzSDQydi43aC01Ljd2LS43Wm0wLTMuNkg0MnYuN2gtNS43di0uN1ptNC43LTRoLTMuMnYtLjdINDJ2LjdoLTFabS0uOSAwdi0uNGwuOC4xLjYuMy40LjUuMi44LS4xLjVhMS4xIDEuMSAwIDAgMS0uOC44SDM3Ljh2LS43SDQxbC4zLS4yLjEtLjN2LS4ybC0uMS0uOGExIDEgMCAwIDAtLjUtLjRoLS43Wm0tMS41LTIuNkg0MnYuN2gtNC4ydi0uNmguOFptMS4xLjJ2LjNsLS44LS4xYTIgMiAwIDAgMS0uNi0uM2wtLjUtLjYtLjEtLjd2LS42YTEuMSAxLjEgMCAwIDEgLjgtLjdINDJ2LjZoLTIuOGExIDEgMCAwIDAtLjUuMWwtLjMuNC0uMS40LjEuNi4zLjMuNS4yaC41Wm0tLjQtMi44LjIuNS0uNy0uMS0uNi0uMy0uNC0uNi0uMS0uN3YtLjZsLjQtLjQuNC0uM0g0MnYuNmgtMi44bC0uNS4xYy0uMi4xLS4zLjItLjMuNGExLjMgMS4zIDAgMCAwIDAgLjlsLjIuMy4zLjJoLjRabS0xLjUtNC42SDQydi44aC00LjJ2LS44Wm0tMS4xLjhoLS4zbC0uMi0uNGMwLS4xIDAtLjIuMi0uM2wuMy0uMWguMmwuMi40YzAgLjEgMCAuMi0uMi4zbC0uMi4xWm00LjUtNC43SDM2di0uN2g2di43aC0uOFptLTEuMyAyLjlhMyAzIDAgMCAxLTEtLjFsLS42LS40LS40LS41Yy0uMi0uMi0uMi0uNS0uMi0uN2wuMS0uNy40LS41Yy4yLS4xLjQtLjMuNy0uM2wuOC0uMmguNGMuMyAwIC42IDAgLjguMi4zIDAgLjUuMi42LjNsLjQuNWExLjcgMS43IDAgMCAxIDAgMS40YzAgLjItLjIuNC0uNC41bC0uNy40YTMgMyAwIDAgMS0uOSAwWm0wLS44aC42bC41LS4yYy4yLS4xLjMtLjIuMy0uNGwuMi0uNWMwLS4yIDAtLjQtLjItLjZsLS40LS4zLS41LS4zaC0xbC0uNC4yLS4zLjJhMSAxIDAgMCAwLS4zLjNsLS4xLjUuMS41LjQuNC41LjJoLjZabS0yLjEtNC44SDQydi43aC00LjJ2LS43Wm0tMS4xLjgtLjMtLjEtLjItLjNjMC0uMiAwLS4zLjItLjRoLjVsLjIuNHMwIC4yLS4yLjNoLS4yWm0xLTMuOGguNnYyLjNoLS41di0yLjNabS0xIDEuNXYtLjdINDFsLjMtLjEuMS0uMnYtLjJhMS4yIDEuMiAwIDAgMCAwLS4zaC42djFsLS40LjRoLTQuOVptNC45LTMuNi0zLjgtMS4ydi0uOGw0LjkgMS43YTIuNyAyLjcgMCAwIDEgLjYuNCAxLjIgMS4yIDAgMCAxIC40Ljl2LjRINDNhMS43IDEuNyAwIDAgMCAwLS43bC0uMy0uMi0uNC0uMi0uOC0uM1ptLTMuOC44IDMuMy0xIC43LS4zLjMuNi00LjMgMS41di0uOFoiLz48cGF0aCBmaWxsPSIjRjk2RkZGIiBkPSJNNDggNDguNWgxOHY5N0g0OHoiLz48cGF0aCBmaWxsPSIjMDAwIiBmaWxsLW9wYWNpdHk9Ii45IiBkPSJNNTQuMyAxMzUuM2guNWw1LjIgMi4zdjFsLTUtMi4zdjNoLS43di00Wm0yLjQtNC42aDFjLjQgMCAuNyAwIDEgLjIuNCAwIC42LjIuOC4zLjIuMi40LjQuNC42bC4yLjgtLjEuNmExLjUgMS41IDAgMCAxLS44IDFsLS42LjJoLTNhMiAyIDAgMCAxLS44LS41bC0uNC0uNS0uMi0uOC4xLS42YTEuNSAxLjUgMCAwIDEgLjgtMWwuNi0uMmgxWm0xIDFoLTEuMmE0IDQgMCAwIDAtLjYgMGwtLjQuMS0uMy4yLS4yLjN2LjdsLjMuMy41LjJoMi41bC41LS4xLjMtLjIuMi0uM3YtLjdsLS4zLS4zLS41LS4yYTQgNCAwIDAgMC0uOCAwWiIvPjxwYXRoIGZpbGw9IiMwMDAiIGZpbGwtb3BhY2l0eT0iLjUiIGQ9Ik01OC42IDEyNC41YTEgMSAwIDAgMC0uNCAwbC0uMy4zLS4yLjQtLjIuNi0uMy44LS40LjYtLjQuNGgtLjZhMS40IDEuNCAwIDAgMS0xLjEtLjRjLS4yLS4yLS4zLS40LS4zLS43bC0uMi0uN2MwLS41LjEtLjguMy0xLjEuMS0uMy40LS42LjYtLjcuMy0uMi42LS4yLjktLjJ2LjdsLS42LjFhMSAxIDAgMCAwLS40LjVsLS4xLjd2LjZsLjQuNC41LjFoLjNsLjMtLjMuMi0uNC4yLS42LjMtLjhjLjEtLjMuMi0uNS40LS42LjEtLjIuMy0uMy41LS4zbC42LS4yYy4yIDAgLjQgMCAuNi4yLjIgMCAuMy4yLjUuNGwuMy42YTMgMyAwIDAgMSAwIDEuNmwtLjQuNy0uNS41LS43LjJ2LS44aC41YzAtLjIuMi0uMy4zLS40bC4yLS41YTIuMiAyLjIgMCAwIDAgMC0xLjFsLS40LS41YS44LjggMCAwIDAtLjQtLjFabS0uNy0xLjUtLjktLjFhMiAyIDAgMCAxLS43LS40bC0uNC0uNi0uMi0uOGMwLS4zIDAtLjYuMi0uOCAwLS4yLjItLjQuNC0uNmwuNy0uNC44LS4xaC4xbC45LjEuNy40YTEuOCAxLjggMCAwIDEgLjYgMS40YzAgLjMgMCAuNi0uMi44YTEuOCAxLjggMCAwIDEtMS4xIDFsLS45LjFabTAtLjdoLjZsLjUtLjMuNC0uNHYtMWExIDEgMCAwIDAtLjQtLjRsLS41LS4yLS42LS4xaC0uNmwtLjUuM2ExIDEgMCAwIDAtLjUuOWwuMS41LjQuNC41LjIuNS4xWm0tMi4xLTQuOUg2MHYuOGgtNC4ydi0uOFptLTEuMS44aC0uM2wtLjItLjRjMC0uMiAwLS4zLjItLjNsLjMtLjFoLjJsLjIuNGMwIC4xIDAgLjItLjIuM2wtLjIuMVptLS43LTIuOGg2di44aC02di0uOFptMi42LTRINjB2LjhoLTQuMnYtLjdoLjhabTEuMS4ydi4zSDU3YTIgMiAwIDAgMS0uNi0uNGwtLjUtLjUtLjEtLjh2LS41YTEuMSAxLjEgMCAwIDEgLjgtLjdsLjctLjFINjB2LjdoLTIuOGExIDEgMCAwIDAtLjUuMWwtLjMuMy0uMS41LjEuNWMwIC4yLjIuMy4zLjRsLjUuMmguNVptLS40LTIuNy4yLjVjLS4zIDAtLjUgMC0uNy0uMmwtLjYtLjMtLjQtLjUtLjEtLjd2LS42bC40LS41LjQtLjNINjB2LjdoLTIuOGwtLjUuMWMtLjIgMC0uMy4yLS4zLjNhMS4zIDEuMyAwIDAgMCAwIDFsLjIuMi4zLjJoLjRabS42LTMuNi0uOS0uMWEyIDIgMCAwIDEtLjctLjRsLS40LS42LS4yLS44YzAtLjMgMC0uNi4yLS44IDAtLjMuMi0uNS40LS42bC43LS40LjgtLjFoMWwuNy41YTEuOCAxLjggMCAwIDEgLjYgMS40YzAgLjMgMCAuNS0uMi44YTEuOCAxLjggMCAwIDEtMS4xIDFsLS45LjFabTAtLjcuNi0uMS41LS4yLjQtLjR2LTFhMSAxIDAgMCAwLS40LS40bC0uNS0uMi0uNi0uMWgtLjZsLS41LjNhMSAxIDAgMCAwLS41LjlsLjEuNS40LjQuNS4yaC41Wm0tMi4xLTQuOUg2MHYuN2gtNC4ydi0uN1ptLTEuMS44LS4zLS4xLS4yLS4zYzAtLjIgMC0uMy4yLS40aC41bC4yLjRjMCAuMSAwIC4yLS4yLjNoLS4yWm00LjItNC41LS4zLjEtLjMuMy0uMi42LS4xLjYtLjMuNWExIDEgMCAwIDEtLjguNCAxIDEgMCAwIDEtLjQgMEw1NiA5OGMtLjItLjEtLjItLjMtLjMtLjVhMiAyIDAgMCAxLS4xLS42YzAtLjQgMC0uNy4yLTFsLjUtLjUuNi0uMXYuN2gtLjNsLS4zLjQtLjEuNXYuNGwuMy4zYS42LjYgMCAwIDAgLjUgMGguMmwuMS0uNC4yLS41LjItLjguNC0uNS42LS4yYTEuMSAxLjEgMCAwIDEgMSAuNWwuMi41di42bC0uMSAxLS41LjYtLjcuMnYtLjhsLjUtLjEuMi0uNGExLjUgMS41IDAgMCAwIDAtMWwtLjItLjNoLS4zWm0tMy4xLTMuNWguNXYyLjNoLS41di0yLjNabS0xIDEuNXYtLjdoNC41bC4xLS4ydi0uMmExLjIgMS4yIDAgMCAwIDAtLjRoLjZ2MS4xbC0uNC4zLS43LjFoLTQuMlptNC4yLTVoLTMuMnYtLjdINjB2LjdoLTFabS0uOSAwdi0uNGwuOC4xLjYuMy40LjUuMi44LS4xLjVhMS4xIDEuMSAwIDAgMS0uOC44SDU1LjhWOTFINTlsLjMtLjIuMS0uM3YtLjNsLS4xLS43YTEgMSAwIDAgMC0uNS0uNGgtLjdabS0xLjctMi42SDYwdi44aC00LjJ2LS43aC42Wm0tLjYtMS4zaC42di44bC4zLjMuMy4zaC40bC4yLjMtLjgtLjFhMiAyIDAgMCAxLS41LS4zYy0uMiAwLS40LS4yLS41LS40YTEuMSAxLjEgMCAwIDEgMC0uOVptNC4zLTIuNGMwIC4zIDAgLjYtLjIuOGExLjggMS44IDAgMCAxLTEgMWwtLjkuMkg1OGMtLjQgMC0uNyAwLTEtLjJhMiAyIDAgMCAxLS42LS40IDEuOCAxLjggMCAwIDEtLjQtMmMwLS4zLjItLjUuNC0uNmwuNi0uMy45LS4xaC4zdjMuMWgtLjZ2LTIuNGwtLjYuMWExIDEgMCAwIDAtLjQuMyAxIDEgMCAwIDAtLjIuNiAxIDEgMCAwIDAgLjQuOGwuNS4zaDEuNGwuNS0uMy4zLS40VjgybC0uNS0uNC40LS41LjQuNC4zLjV2LjdaIi8+PHBhdGggZmlsbD0iI0ZGQTM4OSIgZD0iTTY2IDM0LjVoMTh2MTExSDY2eiIvPjxwYXRoIGZpbGw9IiMwMDAiIGZpbGwtb3BhY2l0eT0iLjkiIGQ9Ik03Ni40IDEzNS40Yy40IDAgLjcgMCAxIC4ybC41LjcuMiAxYzAgLjMgMCAuNi0uMi45LS4xLjMtLjMuNS0uNi43bC0uOS4yYTEuNSAxLjUgMCAwIDEtMS0uNWwtLjQtLjYtLjEtLjhjMC0uMyAwLS43LjItMWwuNS0uNmMuMy0uMi41LS4yLjgtLjJabTAgLjlhMSAxIDAgMCAwLS41LjFsLS4zLjMtLjEuNXYuNWMuMS4yLjMuMy40LjNsLjUuMmMuMiAwIC40IDAgLjUtLjJsLjMtLjMuMS0uNXYtLjVsLS40LS4zYTEgMSAwIDAgMC0uNS0uMVptLTIuNi0uOGMuMyAwIC41IDAgLjguMmwuNS42LjIgMWMwIC4zIDAgLjYtLjIuOGwtLjUuNy0uOC4yYy0uMyAwLS42LS4xLS44LS4zLS4zLS4xLS41LS4zLS42LS42bC0uMi0uOS4yLS45LjYtLjYuOC0uMlptMCAxYTEgMSAwIDAgMC0uNCAwIC43LjcgMCAwIDAtLjQuN3YuNWwuNC4yLjQuMWguNWwuMy0uMy4xLS41LS4xLS40LS4zLS4zYTEgMSAwIDAgMC0uNSAwWm0uOS01LjhoMWMuNCAwIC43IDAgMSAuMi40IDAgLjYuMi44LjMuMi4yLjQuNC40LjZsLjIuOC0uMS42YTEuNSAxLjUgMCAwIDEtLjggMWwtLjYuMmgtM2EyIDIgMCAwIDEtLjgtLjVsLS40LS41LS4yLS44LjEtLjZhMS41IDEuNSAwIDAgMSAuOC0xbC42LS4yaDFabTEgMWgtMS4yYTQgNCAwIDAgMC0uNiAwbC0uNC4xLS4zLjItLjIuM3YuN2wuMy4zLjUuMmgyLjVsLjUtLjEuMy0uMi4yLS4zdi0uN2wtLjMtLjMtLjUtLjJhNCA0IDAgMCAwLS44IDBaIi8+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1vcGFjaXR5PSIuNSIgZD0iTTc3LjQgMTI0aC42djNoLS42di0zWm0tNSAyLjhINzh2LjdoLTUuN3YtLjdabTUuNy01LjNjMCAuMyAwIC42LS4yLjhhMS44IDEuOCAwIDAgMS0xIDFsLS45LjJINzZjLS40IDAtLjcgMC0xLS4yYTIgMiAwIDAgMS0uNi0uNCAxLjggMS44IDAgMCAxLS40LTJjMC0uMy4yLS41LjQtLjZsLjYtLjMuOS0uMWguM3YzLjFoLS42di0yLjRsLS42LjFhMSAxIDAgMCAwLS40LjMgMSAxIDAgMCAwLS4yLjYgMSAxIDAgMCAwIC40LjhsLjUuMi43LjFoLjdsLjUtLjNhMS4yIDEuMiAwIDAgMCAuNC0xYzAtLjIgMC0uNC0uMi0uNmwtLjQtLjQuNC0uNS40LjQuMy41di43Wm0tLjgtNWgtMi42Yy0uMi4xLS4zLjItLjMuNGwtLjEuNXYuNWwuMy4zaC4zdi44YTEgMSAwIDAgMS0uNC0uMWMtLjIgMC0uMy0uMi0uNC0uM2wtLjMtLjZhMiAyIDAgMCAxLS4xLS43YzAtLjMgMC0uNS4yLS44IDAtLjIuMi0uNC40LS41LjItLjIuNS0uMi44LS4yaDIuNGwuNC0uMmguMXYuOGgtLjdabS0xLjktLjFoLjV2LjdsLjEuNS4xLjQuMy4zaC43bC4zLS4zdi0uNGExLjIgMS4yIDAgMCAwLS40LTEgLjcuNyAwIDAgMC0uNC0uMmwuMy0uMy40LjJhMS43IDEuNyAwIDAgMSAuOCAxLjRjMCAuMyAwIC41LS4yLjdsLS40LjUtLjcuMi0uNi0uMS0uNC0uNC0uMy0uNnYtMS42Wm0yLjYtM3YuOGgtNC43Yy0uMyAwLS41IDAtLjctLjItLjMgMC0uNC0uMi0uNS0uNGwtLjItLjhhMiAyIDAgMCAxIDAtLjVoLjd2LjlsLjMuMkg3OFptLTQuMi0uOGguNXYyLjNoLS41di0yLjNabTMuNS00LTMuNS0xdi0uNWguN2wzLjUgMS4ydi40aC0uOFptLTMuNS44IDMuNS0xaC43di40bC00LjIgMS4zdi0uN1ptMy41LTMuNC0zLjUtLjl2LS43bDQuMiAxLjJ2LjVoLS43Wm0tMy41IDEgMy40LTEuMS44LS4xdi40bC0zLjUgMS4yaC0uN3YtLjRabTQuMy01LjFjMCAuMyAwIC41LS4yLjhhMS44IDEuOCAwIDAgMS0xIDFsLS45LjFINzZsLTEtLjFhMiAyIDAgMCAxLS42LS41IDEuOCAxLjggMCAwIDEtLjQtMmMwLS4yLjItLjQuNC0uNmwuNi0uM2gxLjJ2M2gtLjZWMTAxbC0uNi4yYTEgMSAwIDAgMC0uNC4zIDEgMSAwIDAgMC0uMi42IDEgMSAwIDAgMCAuNC44bC41LjJoMS40bC41LS4yLjMtLjR2LTEuMmwtLjUtLjUuNC0uNC40LjNjMCAuMi4yLjMuMy41di44Wm0tNC4zLTQuM2guNXYyLjJoLS41di0yLjJabS0xIDEuNXYtLjhoNC41bC4xLS4yVjk4YTEuMiAxLjIgMCAwIDAgMC0uNGguNnYxLjFjLS4xLjEtLjIuMy0uNC4zbC0uNy4yaC00LjJabTEuOS0zLjJINzh2LjhoLTQuMlY5NmguOVptMSAuMnYuM2wtLjgtLjFhMiAyIDAgMCAxLS42LS40IDEuNiAxLjYgMCAwIDEtLjYtMS4yVjk0bC4zLS40LjUtLjNINzh2LjdoLTMuM2wtLjMuMy0uMS41YTEgMSAwIDAgMCAuNC45IDEuNiAxLjYgMCAwIDAgMSAuM1ptMi40LTUuN2MwIC4zIDAgLjYtLjIuOGExLjggMS44IDAgMCAxLTEgMWwtLjkuMkg3NmMtLjQgMC0uNyAwLTEtLjJhMiAyIDAgMCAxLS42LS40IDEuOCAxLjggMCAwIDEtLjQtMmMwLS4zLjItLjUuNC0uNmwuNi0uMy45LS4xaC4zdjMuMWgtLjZ2LTIuNGwtLjYuMWExIDEgMCAwIDAtLjQuMyAxIDEgMCAwIDAtLjIuNiAxIDEgMCAwIDAgLjQuOGwuNS4zaDEuNGwuNS0uMy4zLS40di0xLjJsLS41LS40LjQtLjUuNC40LjMuNXYuN1ptLTEuMi01aC0uM2wtLjMuNC0uMi42LS4xLjYtLjMuNWExIDEgMCAwIDEtLjguNCAxIDEgMCAwIDEtLjQgMGwtLjQtLjRjLS4yLS4xLS4yLS4zLS4zLS41YTIgMiAwIDAgMS0uMS0uNmMwLS40IDAtLjcuMi0xIDAtLjIuMy0uMy41LS41bC42LS4ydi44aC0uM2wtLjMuNC0uMS41di40bC4zLjNhLjYuNiAwIDAgMCAuNSAwbC4yLS4xLjEtLjMuMi0uNS4yLS44LjQtLjUuNi0uMmExLjEgMS4xIDAgMCAxIDEgLjRsLjIuNnYuNmwtLjEgMS0uNS42LS43LjJ2LS44bC41LS4xLjItLjRhMS41IDEuNSAwIDAgMCAwLTFsLS4yLS4zaC0uM1ptMC00LjItLjMuMS0uMy4zLS4yLjYtLjEuNi0uMy41YTEgMSAwIDAgMS0uOC40IDEgMSAwIDAgMS0uNCAwbC0uNC0uNGMtLjItLjEtLjItLjMtLjMtLjVhMiAyIDAgMCAxLS4xLS42YzAtLjQgMC0uNy4yLS45IDAtLjIuMy0uNC41LS42bC42LS4xdi43aC0uM2wtLjMuNC0uMS41di41bC4zLjJhLjYuNiAwIDAgMCAuNSAwaC4ybC4xLS40LjItLjUuMi0uOC40LS41LjYtLjJhMS4xIDEuMSAwIDAgMSAxIC41bC4yLjV2LjZsLS4xIDEtLjUuNi0uNy4yVjgzbC41LS4xLjItLjRhMS41IDEuNSAwIDAgMCAwLTFsLS4yLS4zaC0uM1oiLz48cGF0aCBmaWxsPSIjRkZFRDUzIiBkPSJNODQgNTYuNWgxOHY4OUg4NHoiLz48cGF0aCBmaWxsPSIjMDAwIiBmaWxsLW9wYWNpdHk9Ii45IiBkPSJNOTAuMyAxMzYuMmguN2wuMi44YTEuNiAxLjYgMCAwIDAgMSAxbC44LjFoMS41bC41LS4zLjItLjMuMS0uNHYtLjNsLS4zLS4zLS40LS4yYTEuNyAxLjcgMCAwIDAtLjkgMGMtLjEgMC0uMyAwLS40LjJsLS4yLjItLjEuNC4xLjYuNC4zLjQuMi0uMS4zYTIgMiAwIDAgMS0uNy0uMWwtLjUtLjQtLjMtLjVWMTM2LjJsLjUtLjUuNi0uM2EyLjUgMi41IDAgMCAxIDEuNSAwYy4yIDAgLjQuMi42LjQuMi4xLjMuMy40LjZsLjIuN2MwIC4zIDAgLjYtLjIuOC0uMS4zLS4zLjUtLjUuNmwtLjcuNC0uOC4yaC0uNGwtMS4zLS4yYTMgMyAwIDAgMS0xLS41Yy0uMy0uMi0uNS0uNS0uNy0uOWEzIDMgMCAwIDEtLjItMS4zWm01LTUuNmguN3YzLjhoLS42bC0yLTEuOC0uNy0uNWEyIDIgMCAwIDAtLjQtLjIgMS4yIDEuMiAwIDAgMC0uOSAwbC0uMy4zLS4xLjQuMS42LjQuMy41LjF2MWwtLjktLjMtLjYtLjZhMiAyIDAgMCAxLS4zLTFsLjItMWMuMi0uMy4zLS41LjYtLjYuMi0uMi41LS4zLjgtLjNsLjUuMS42LjMuNS4zLjUuNSAxLjMgMS4ydi0yLjZaIi8+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1vcGFjaXR5PSIuNSIgZD0iTTk0LjIgMTI0di0uN2MuNCAwIC43LjIgMSAuNC4yLjEuNS40LjYuNy4yLjMuMy42LjMgMS4xIDAgLjMgMCAuNi0uMiAxYTIgMiAwIDAgMS0uNS42Yy0uMy4yLS41LjQtLjkuNWwtMSAuMWgtLjZsLTEuMS0uMS0uOC0uNWEyIDIgMCAwIDEtLjYtLjdsLS4yLTFjMC0uNC4xLS44LjMtMSAuMS0uNC40LS42LjYtLjdsMS0uNHYuOGwtLjYuMmExIDEgMCAwIDAtLjUuNGwtLjEuNy4xLjdjMCAuMi4yLjQuNC41bC42LjNoMi4yYy4zIDAgLjUtLjIuNy0uM2wuNC0uNC4yLS43YzAtLjMgMC0uNi0uMi0uOGExIDEgMCAwIDAtLjQtLjRsLS43LS4yWm0tNC4yLTIuNGg2di43aC02di0uN1ptNC0xaC0uMmMtLjMgMC0uNSAwLS44LS4yYTIgMiAwIDAgMS0uNy0uNGMtLjItLjEtLjMtLjMtLjQtLjYtLjItLjItLjItLjUtLjItLjggMC0uMyAwLS41LjItLjggMC0uMi4yLS40LjQtLjZsLjctLjRoMS44bC43LjRhMS44IDEuOCAwIDAgMSAuNiAxLjRjMCAuMyAwIC42LS4yLjhhMS44IDEuOCAwIDAgMS0xLjEgMWwtLjkuMlptLS4yLS44aC43Yy4yIDAgLjQtLjIuNS0uMy4yIDAgLjMtLjIuNC0uM1YxMThhMSAxIDAgMCAwLS40LS40bC0uNS0uMmgtMS4ybC0uNS4yYTEgMSAwIDAgMC0uNS45bC4xLjYuNC4zLjUuM2guNVptMS4yLTYuNmgtMy4ydi0uOEg5NnYuN2gtMVptLS45LS4ydi0uM2wuOC4xLjYuMy40LjUuMi44LS4xLjVhMS4xIDEuMSAwIDAgMS0uOC44SDkxLjh2LS43SDk1bC4zLS4yLjEtLjJ2LS4zbC0uMS0uOGExIDEgMCAwIDAtLjUtLjNsLS43LS4yWm0xLTQuNEg5MHYtLjdoNnYuN2gtLjhabS0xLjIgMi45YTMgMyAwIDAgMS0xLS4ybC0uNi0uMy0uNC0uNWMtLjItLjItLjItLjUtLjItLjdsLjEtLjcuNC0uNWMuMi0uMS40LS4zLjctLjNsLjgtLjJoLjRjLjMgMCAuNiAwIC44LjIuMyAwIC41LjIuNi4zbC40LjVhMS43IDEuNyAwIDAgMSAwIDEuNGMwIC4yLS4yLjQtLjQuNS0uMi4yLS41LjMtLjcuM2EzIDMgMCAwIDEtLjkuMlptMC0uOGguNmwuNS0uMmMuMi0uMS4zLS4yLjMtLjRsLjItLjVjMC0uMiAwLS40LS4yLS42bC0uNC0uMy0uNS0uM2gtMWEyIDIgMCAwIDAtLjQuMmwtLjMuMmExIDEgMCAwIDAtLjMuM2wtLjEuNS4xLjUuNC40LjUuMmguNlptMS42LTcuNy0uMS0uNWExIDEgMCAwIDAtLjMtLjMuOC44IDAgMCAwLS40LS4ydi0uN2wuNy4zYTEuNyAxLjcgMCAwIDEgLjcgMS40YzAgLjMgMCAuNi0uMi44LS4xLjMtLjMuNC0uNS42YTIgMiAwIDAgMS0uNi40SDkzYTIgMiAwIDAgMS0uNy0uNGwtLjQtLjZhMiAyIDAgMCAxLS4yLS44YzAtLjMgMC0uNi4yLS45bC41LS42LjgtLjJ2LjdhMSAxIDAgMCAwLS40LjEgMSAxIDAgMCAwLS41LjhsLjEuNi40LjQuNS4yaDEuMmwuNS0uMi40LS40di0uNVptLTEuNi0yLjQtLjktLjFhMiAyIDAgMCAxLS43LS40bC0uNC0uNmMtLjItLjItLjItLjUtLjItLjggMC0uMyAwLS42LjItLjggMC0uMi4yLS40LjQtLjZsLjctLjQuOC0uMWguMWwuOS4xLjcuNGExLjggMS44IDAgMCAxIC42IDEuNGMwIC4zIDAgLjYtLjIuOGExLjggMS44IDAgMCAxLTEuMSAxbC0uOS4xWm0wLS43aC42bC41LS4zYy4yIDAgLjMtLjIuNC0uNHYtMWExIDEgMCAwIDAtLjQtLjRsLS41LS4yaC0xLjJsLS41LjJhMSAxIDAgMCAwLS41LjlsLjEuNS40LjQuNS4yLjUuMVptMS40LTUuMy0zLjUtMS4ydi0uN2w0LjIgMS41di41bC0uNy0uMVptLTMuNSAxIDMuNi0xLjIuNi0uMXYuNWwtNC4yIDEuNXYtLjdabTQuMy01LjNjMCAuMiAwIC41LS4yLjhhMS44IDEuOCAwIDAgMS0xIDFsLS45LjFIOTRjLS40IDAtLjcgMC0xLS4yYTIgMiAwIDAgMS0uNi0uNCAxLjggMS44IDAgMCAxLS40LTJjMC0uMy4yLS40LjQtLjZsLjYtLjNoMS4ydjNoLS42di0yLjRsLS42LjFhMSAxIDAgMCAwLS40LjQgMSAxIDAgMCAwLS4yLjUgMSAxIDAgMCAwIC40LjlsLjUuMmgxLjRjLjIgMCAuMy0uMi41LS4zbC4zLS40di0xLjFsLS41LS41LjQtLjQuNC4zYzAgLjIuMi4zLjMuNXYuOFpNOTIuNCA4N0g5NnYuN2gtNC4yVjg3aC42Wm0tLjctMS4zaC43di44bC4zLjMuMy4yLjQuMS4yLjJoLS44YTIgMiAwIDAgMS0uNS0uM2wtLjUtLjRhMS4xIDEuMSAwIDAgMSAwLTFaIi8+PHBhdGggZmlsbD0iIzdEOEVGRiIgZD0iTTEyMyAzNi41aDE4djEwOWgtMTh6Ii8+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1vcGFjaXR5PSIuOSIgZD0iTTEyOS4zIDEzNS4zaC41bDUuMiAyLjN2MWwtNS0yLjN2M2gtLjd2LTRabTUtMS42di0uMWMwLS40IDAtLjctLjItLjlhMS40IDEuNCAwIDAgMC0xLS45aC0yLjNsLS41LjItLjIuMy0uMS40di4zbC4zLjMuNC4yYTEuOCAxLjggMCAwIDAgMSAwYy4xIDAgLjIgMCAuMy0uMmwuMy0uMi4xLS40YTEgMSAwIDAgMC0uMy0uOCAxLjEgMS4xIDAgMCAwLS43LS4zbC4xLS4zaC42YTEuOCAxLjggMCAwIDEgLjkgMWwuMS42LS4xLjctLjQuNS0uNy4zYTIuNSAyLjUgMCAwIDEtMS40IDAgMiAyIDAgMCAxLS43LS40Yy0uMi0uMS0uMy0uMy0uNC0uNmwtLjItLjdjMC0uNCAwLS42LjItLjlsLjUtLjUuNy0uNCAxLS4xaDEuM2wuOC40LjcuNS41LjguMSAxdi4yaC0uN1oiLz48cGF0aCBmaWxsPSIjMDAwIiBmaWxsLW9wYWNpdHk9Ii41IiBkPSJNMTMxLjggMTIzLjhoLjZ2M2gtLjZ2LTNabS0yLjUgM2g1Ljd2LjdoLTUuN3YtLjdabTAtMy42aDUuN3YuN2gtNS43di0uN1ptNC43LTRoLTMuMnYtLjdoNC4ydi43aC0xWm0tLjkgMHYtLjRsLjguMS42LjMuNC41LjIuOC0uMS41YTEuMSAxLjEgMCAwIDEtLjguOEgxMzAuOHYtLjdoMy4ybC4zLS4yLjEtLjN2LS4ybC0uMS0uOGExIDEgMCAwIDAtLjUtLjRoLS43Wm0tMS41LTIuNmgzLjR2LjdoLTQuMnYtLjZoLjhabTEuMS4ydi4zbC0uOC0uMS0uNi0uMy0uNS0uNi0uMS0uN3YtLjZhMS4xIDEuMSAwIDAgMSAuOC0uN2gzLjV2LjZoLTIuOGExIDEgMCAwIDAtLjUuMWwtLjMuNHYxbC4zLjMuNS4yaC41Wm0tLjQtMi44LjIuNS0uNy0uMS0uNi0uMy0uNC0uNi0uMS0uN3YtLjZsLjQtLjQuNC0uM2gzLjV2LjZoLTIuOGwtLjUuMWMtLjIuMS0uMy4yLS4zLjRhMS4yIDEuMiAwIDAgMCAwIC45bC4yLjNhMSAxIDAgMCAwIC43LjJabS0xLjUtNC42aDQuMnYuOGgtNC4ydi0uOFptLTEuMS44aC0uM2wtLjItLjRjMC0uMSAwLS4yLjItLjNsLjMtLjFoLjJsLjIuNGMwIC4xIDAgLjItLjIuM2wtLjIuMVptNC41LTQuN0gxMjl2LS43aDZ2LjdoLS44Wm0tMS4zIDIuOWEzIDMgMCAwIDEtMS0uMWwtLjYtLjRhMS41IDEuNSAwIDAgMS0uNi0xLjJsLjEtLjcuNC0uNWMuMi0uMS40LS4zLjctLjNsLjgtLjJoLjRjLjMgMCAuNiAwIC44LjIuMyAwIC41LjIuNi4zbC40LjVhMS43IDEuNyAwIDAgMSAwIDEuNGMwIC4yLS4yLjQtLjQuNWwtLjcuNGEzIDMgMCAwIDEtLjkgMFptMC0uOGguNmwuNS0uMmMuMi0uMS4zLS4yLjMtLjRsLjItLjVhMSAxIDAgMCAwLS42LTFsLS41LS4yaC0xbC0uNC4yLS4zLjJhMSAxIDAgMCAwLS4zLjN2MWwuNC40LjUuMmguNlptLTIuMS00LjhoNC4ydi43aC00LjJ2LS43Wm0tMS4xLjgtLjMtLjEtLjItLjNjMC0uMiAwLS4zLjItLjRoLjVsLjIuNHMwIC4yLS4yLjNoLS4yWm0xLTMuOGguNnYyLjNoLS41di0yLjNabS0xIDEuNXYtLjdoNC4zbC4zLS4xLjEtLjJ2LS41aC42YTEuOCAxLjggMCAwIDEgMCAuNXYuNWwtLjQuNGgtNC45Wm00LjktMy42LTMuOC0xLjJ2LS44bDQuOSAxLjdhMi43IDIuNyAwIDAgMSAuNi40bC4zLjRhMSAxIDAgMCAxIDAgLjd2LjJoLS41YTEuOSAxLjkgMCAwIDAgMC0uN2wtLjMtLjItLjQtLjItLjgtLjNabS0zLjguOCAzLjMtMSAuNy0uMy4zLjYtNC4zIDEuNXYtLjhaIi8+PHBhdGggZmlsbD0iI0Y5NkZGRiIgZD0iTTE0MSA3Ny41aDE4djY4aC0xOHoiLz48cGF0aCBmaWxsPSIjMDAwIiBmaWxsLW9wYWNpdHk9Ii45IiBkPSJtMTUwLjQgMTM4LS4yLjgtMi45LS4zdi0zaC44djIuMmwxLjQuMmExLjggMS44IDAgMCAxLS4yLS45bC4xLS43YzAtLjIuMi0uNC40LS42bC42LS4zYTIuNSAyLjUgMCAwIDEgMS41IDBsLjYuMy40LjYuMi44LS4xLjctLjMuNi0uNS40LS43LjJ2LTFoLjRsLjMtLjQuMS0uNXYtLjRsLS4zLS4yYTEgMSAwIDAgMC0uNC0uMiAxLjggMS44IDAgMCAwLTEgMCAxIDEgMCAwIDAtLjMuMmwtLjIuM3YxbC4zLjNabS0uNy03LjNoMWMuNCAwIC43IDAgMSAuMi40IDAgLjYuMi44LjNsLjQuNi4yLjgtLjEuNmExLjUgMS41IDAgMCAxLS44IDFsLS42LjJoLTNhMiAyIDAgMCAxLS44LS41bC0uNC0uNS0uMi0uOC4xLS42YTEuNSAxLjUgMCAwIDEgLjgtMWwuNi0uMmgxWm0xIDFoLTEuMmE0IDQgMCAwIDAtLjYgMGwtLjQuMS0uMy4yLS4yLjN2LjdsLjMuMy41LjJoMi41bC41LS4xLjMtLjIuMi0uM3YtLjdsLS4zLS4zLS41LS4yYTQgNCAwIDAgMC0uOCAwWiIvPjxwYXRoIGZpbGw9IiMwMDAiIGZpbGwtb3BhY2l0eT0iLjUiIGQ9Ik0xNTEuNiAxMjQuNWExIDEgMCAwIDAtLjQgMGwtLjMuMy0uMi40LS4yLjYtLjMuOC0uNC42LS40LjRoLS42YTEuNCAxLjQgMCAwIDEtMS4xLS40Yy0uMi0uMi0uMy0uNC0uMy0uN2wtLjItLjdjMC0uNS4xLS44LjMtMS4xLjEtLjMuNC0uNi42LS43LjMtLjIuNi0uMi45LS4ydi43bC0uNi4xYTEgMSAwIDAgMC0uNC41bC0uMS43di42bC40LjQuNS4xaC4zbC4zLS4zLjItLjQuMi0uNi4zLS44Yy4xLS4zLjItLjUuNC0uNi4xLS4yLjMtLjMuNS0uM2wuNi0uMmMuMiAwIC40IDAgLjYuMi4yIDAgLjMuMi41LjRsLjMuNmEzIDMgMCAwIDEgMCAxLjZsLS40LjctLjUuNS0uNy4ydi0uOGguNWMwLS4yLjItLjMuMy0uNGwuMi0uNWEyLjIgMi4yIDAgMCAwIDAtMS4xbC0uNC0uNWEuOC44IDAgMCAwLS40LS4xWm0tLjctMS41LS45LS4xYTIgMiAwIDAgMS0uNy0uNGwtLjQtLjZjLS4yLS4yLS4yLS41LS4yLS44IDAtLjMgMC0uNi4yLS44IDAtLjIuMi0uNC40LS42bC43LS40LjgtLjFoLjFsLjkuMS43LjRhMS44IDEuOCAwIDAgMSAuNiAxLjRjMCAuMyAwIC42LS4yLjhhMS44IDEuOCAwIDAgMS0xLjEgMWwtLjkuMVptMC0uN2guNmwuNS0uMy40LS40di0xYTEgMSAwIDAgMC0uNC0uNGwtLjUtLjItLjYtLjFoLS42bC0uNS4zYTEgMSAwIDAgMC0uNS45bC4xLjUuNC40LjUuMi41LjFabS0yLjEtNC45aDQuMnYuOGgtNC4ydi0uOFptLTEuMS44aC0uM2wtLjItLjRjMC0uMiAwLS4zLjItLjNsLjMtLjFoLjJsLjIuNGMwIC4xIDAgLjItLjIuM2wtLjIuMVptLS43LTIuOGg2di44aC02di0uOFptMi42LTRoMy40di44aC00LjJ2LS43aC44Wm0xLjEuMnYuM2gtLjhsLS42LS40LS41LS41LS4xLS44di0uNWExLjEgMS4xIDAgMCAxIC44LS43bC43LS4xaDIuOHYuN2gtMi44YTEgMSAwIDAgMC0uNS4xbC0uMy4zdjFjMCAuMi4yLjMuMy40bC41LjJoLjVabS0uNC0yLjcuMi41Yy0uMyAwLS41IDAtLjctLjJsLS42LS4zLS40LS41LS4xLS43di0uNmwuNC0uNS40LS4zaDMuNXYuN2gtMi44bC0uNS4xYy0uMiAwLS4zLjItLjMuM2ExLjIgMS4yIDAgMCAwIDAgMWwuMi4yLjMuMmguNFptLjYtMy42LS45LS4xYTIgMiAwIDAgMS0uNy0uNGwtLjQtLjZjLS4yLS4zLS4yLS41LS4yLS44IDAtLjMgMC0uNi4yLS44IDAtLjMuMi0uNS40LS42bC43LS40LjgtLjFoMWwuNy41YTEuOCAxLjggMCAwIDEgLjYgMS40YzAgLjMgMCAuNS0uMi44YTEuOCAxLjggMCAwIDEtMS4xIDFsLS45LjFabTAtLjcuNi0uMS41LS4yLjQtLjR2LTFhMSAxIDAgMCAwLS40LS40bC0uNS0uMi0uNi0uMWgtLjZsLS41LjNhMSAxIDAgMCAwLS41LjlsLjEuNS40LjQuNS4yaC41Wm0tMi4xLTQuOWg0LjJ2LjdoLTQuMnYtLjdabS0xLjEuOC0uMy0uMS0uMi0uM2MwLS4yIDAtLjMuMi0uNGguNWwuMi40YzAgLjEgMCAuMi0uMi4zaC0uMlptNC4yLTQuNS0uMy4xLS4zLjMtLjIuNi0uMS42YzAgLjItLjIuNC0uMy41YTEgMSAwIDAgMS0uOC40IDEgMSAwIDAgMS0uNCAwbC0uNC0uNGMtLjItLjEtLjItLjMtLjMtLjVhMiAyIDAgMCAxLS4xLS42YzAtLjQgMC0uNy4yLTFsLjUtLjUuNi0uMXYuN2gtLjNsLS4zLjQtLjEuNXYuNGwuMy4zYS42LjYgMCAwIDAgLjUgMGguMmwuMS0uNC4yLS41LjItLjguNC0uNS42LS4yYTEuMSAxLjEgMCAwIDEgMSAuNWwuMi41di42bC0uMSAxLS41LjYtLjcuMnYtLjhsLjUtLjEuMi0uNGExLjUgMS41IDAgMCAwIDAtMWwtLjItLjNoLS4zWm0tMy4xLTMuNWguNXYyLjNoLS41di0yLjNabS0xIDEuNXYtLjdoNC41bC4xLS4yVjkyLjVoLjZhMS44IDEuOCAwIDAgMSAwIC42di41bC0uNC4zLS43LjFoLTQuMlptNC4yLTVoLTMuMnYtLjdoNC4ydi43aC0xWm0tLjkgMHYtLjRsLjguMS42LjMuNC41LjIuOC0uMS41YTEuMSAxLjEgMCAwIDEtLjguOEgxNDguOFY5MWgzLjJsLjMtLjIuMS0uM3YtLjNsLS4xLS43YTEgMSAwIDAgMC0uNS0uNGgtLjdabS0xLjctMi42aDMuNnYuOGgtNC4ydi0uN2guNlptLS43LTEuM2guN3YuOGwuMy4zLjMuM2guNGwuMi4zLS44LS4xYTIgMiAwIDAgMS0uNi0uM2wtLjQtLjRhMS4xIDEuMSAwIDAgMSAwLS45Wm00LjQtMi40YzAgLjMgMCAuNi0uMi44YTEuOCAxLjggMCAwIDEtMSAxbC0uOS4yaC0uMWMtLjQgMC0uNyAwLTEtLjJhMiAyIDAgMCAxLS42LS40IDEuOCAxLjggMCAwIDEtLjYtMS4zYzAtLjMgMC0uNS4yLS44IDAtLjIuMi0uNC40LS41bC42LS4zLjktLjFoLjN2My4xaC0uNnYtMi40bC0uNi4xYTEgMSAwIDAgMC0uNC4zIDEgMSAwIDAgMC0uMi42IDEgMSAwIDAgMCAuNC44bC41LjNoMS40bC41LS4zLjMtLjRWODJsLS41LS40LjQtLjUuNC40LjMuNXYuN1oiLz48cGF0aCBmaWxsPSIjRkZBMzg5IiBkPSJNMTU5IDQ3LjVoMTh2OThoLTE4eiIvPjxwYXRoIGZpbGw9IiMwMDAiIGZpbGwtb3BhY2l0eT0iLjkiIGQ9Ik0xNjUuMyAxMzUuM2guNWw1LjIgMi4zdjFsLTUtMi4zdjNoLS43di00Wm0yLjQtNC42aDFjLjQgMCAuNyAwIDEgLjIuNCAwIC42LjIuOC4zbC40LjYuMi44LS4xLjZhMS41IDEuNSAwIDAgMS0uOCAxbC0uNi4yaC0zYTIgMiAwIDAgMS0uOC0uNWwtLjQtLjUtLjItLjguMS0uNmExLjUgMS41IDAgMCAxIC44LTFsLjYtLjJoMVptMSAxaC0xLjJhNCA0IDAgMCAwLS42IDBsLS40LjEtLjMuMi0uMi4zdi43bC4zLjMuNS4yaDIuNWwuNS0uMS4zLS4yLjItLjN2LS43bC0uMy0uMy0uNS0uMmE0IDQgMCAwIDAtLjggMFoiLz48cGF0aCBmaWxsPSIjMDAwIiBmaWxsLW9wYWNpdHk9Ii41IiBkPSJNMTcwLjQgMTI0aC42djNoLS42di0zWm0tNSAyLjhoNS42di43aC01Ljd2LS43Wm01LjctNS4zYzAgLjMgMCAuNi0uMi44YTEuOCAxLjggMCAwIDEtMSAxbC0uOS4yaC0uMWMtLjQgMC0uNyAwLTEtLjJhMiAyIDAgMCAxLS42LS40IDEuOCAxLjggMCAwIDEtLjQtMmMwLS4zLjItLjUuNC0uNmwuNi0uMy45LS4xaC4zdjMuMWgtLjZ2LTIuNGwtLjYuMWExIDEgMCAwIDAtLjQuMyAxIDEgMCAwIDAtLjIuNiAxIDEgMCAwIDAgLjQuOGwuNS4yLjcuMWguN2wuNS0uMy4zLS40di0xLjJsLS41LS40LjQtLjUuNC40LjMuNXYuN1ptLS44LTVoLTIuNmMtLjIuMS0uMy4yLS4zLjRsLS4xLjV2LjVsLjMuM2guM3YuOGExIDEgMCAwIDEtLjQtLjFjLS4yIDAtLjMtLjItLjQtLjNsLS4zLS42YTIgMiAwIDAgMS0uMS0uN2MwLS4zIDAtLjUuMi0uOCAwLS4yLjItLjQuNC0uNS4yLS4yLjUtLjIuOC0uMmgyLjRsLjQtLjJoLjF2LjhoLS43Wm0tMS45LS4xaC41di43bC4xLjUuMS40LjMuM2guN2wuMy0uM3YtLjRhMS4yIDEuMiAwIDAgMC0uNC0xIC43LjcgMCAwIDAtLjQtLjJsLjMtLjMuNC4yYTEuNyAxLjcgMCAwIDEgLjYgMi4xbC0uNC41LS43LjItLjYtLjEtLjQtLjQtLjMtLjZ2LTEuNlptMi42LTN2LjhoLTQuN2MtLjMgMC0uNSAwLS43LS4yLS4zIDAtLjQtLjItLjUtLjRsLS4yLS44YTIgMiAwIDAgMSAwLS41aC43di45bC4zLjJIMTcxWm0tNC4yLS44aC41djIuM2gtLjV2LTIuM1ptMy40LTQtMy40LTF2LS41aC43bDMuNSAxLjJ2LjRoLS44Wm0tMy40LjggMy41LTFoLjd2LjRsLTQuMiAxLjN2LS43Wm0zLjUtMy40LTMuNS0uOXYtLjdsNC4yIDEuMnYuNWgtLjdabS0zLjUgMSAzLjQtMS4xLjgtLjF2LjRsLTMuNSAxLjJoLS43di0uNFptNC4zLTUuMWMwIC4zIDAgLjUtLjIuOGExLjggMS44IDAgMCAxLTEgMWwtLjkuMWgtLjFsLTEtLjFhMiAyIDAgMCAxLS42LS41IDEuOCAxLjggMCAwIDEtLjQtMmMwLS4yLjItLjQuNC0uNmwuNi0uM2gxLjJ2M2gtLjZWMTAxbC0uNi4yYTEgMSAwIDAgMC0uNC4zIDEgMSAwIDAgMC0uMi42IDEgMSAwIDAgMCAuNC44bC41LjJoMS40bC41LS4yYzAtLjIuMi0uMy4zLS40di0xLjJsLS41LS41LjQtLjQuNC4zYzAgLjIuMi4zLjMuNXYuOFptLTQuMy00LjNoLjV2Mi4yaC0uNXYtMi4yWm0tMSAxLjV2LS44aDQuNWwuMS0uMlY5Ny41aC42YTEuOCAxLjggMCAwIDEgMCAuNnYuNWMtLjEuMS0uMi4zLS40LjNsLS43LjJoLTQuMlptMS45LTMuMmgzLjN2LjhoLTQuMlY5NmguOVptMSAuMnYuM2wtLjgtLjFhMiAyIDAgMCAxLS42LS40IDEuNiAxLjYgMCAwIDEtLjYtMS4yVjk0bC4zLS40LjUtLjNoMy41di43aC0zLjNsLS4zLjN2LjVhMSAxIDAgMCAwIC4zLjkgMS42IDEuNiAwIDAgMCAxIC4zWm0yLjQtNS43YzAgLjMgMCAuNi0uMi44YTEuOCAxLjggMCAwIDEtMSAxbC0uOS4yaC0uMWMtLjQgMC0uNyAwLTEtLjJhMiAyIDAgMCAxLS42LS40IDEuOCAxLjggMCAwIDEtLjYtMS4zYzAtLjMgMC0uNS4yLS44IDAtLjIuMi0uNC40LS41bC42LS4zLjktLjFoLjN2My4xaC0uNnYtMi40bC0uNi4xYTEgMSAwIDAgMC0uNC4zIDEgMSAwIDAgMC0uMi42IDEgMSAwIDAgMCAuNC44bC41LjNoMS40bC41LS4zLjMtLjR2LTEuMmwtLjUtLjQuNC0uNS40LjQuMy41di43Wm0tMS4yLTVoLS4zbC0uMy40LS4yLjYtLjEuNmMwIC4yLS4yLjQtLjMuNWExIDEgMCAwIDEtLjguNCAxIDEgMCAwIDEtLjQgMGwtLjQtLjRjLS4yLS4xLS4yLS4zLS4zLS41YTIgMiAwIDAgMS0uMS0uNmMwLS40IDAtLjcuMi0xIDAtLjIuMy0uMy41LS41bC42LS4ydi44aC0uM2wtLjMuNC0uMS41di40bC4zLjNhLjYuNiAwIDAgMCAuNSAwbC4yLS4xLjEtLjMuMi0uNS4yLS44LjQtLjUuNi0uMmExLjEgMS4xIDAgMCAxIDEgLjRsLjIuNnYuNmwtLjEgMS0uNS42LS43LjJ2LS44bC41LS4xLjItLjRhMS41IDEuNSAwIDAgMCAwLTFsLS4yLS4zaC0uM1ptMC00LjItLjMuMS0uMy4zLS4yLjYtLjEuNmMwIC4yLS4yLjQtLjMuNWExIDEgMCAwIDEtLjguNCAxIDEgMCAwIDEtLjQgMGwtLjQtLjRjLS4yLS4xLS4yLS4zLS4zLS41YTIgMiAwIDAgMS0uMS0uNmMwLS40IDAtLjcuMi0uOSAwLS4yLjMtLjQuNS0uNmwuNi0uMXYuN2gtLjNsLS4zLjQtLjEuNXYuNWwuMy4yYS42LjYgMCAwIDAgLjUgMGguMmwuMS0uNC4yLS41LjItLjguNC0uNS42LS4yYTEuMSAxLjEgMCAwIDEgMSAuNWwuMi41di42bC0uMSAxLS41LjYtLjcuMlY4M2wuNS0uMS4yLS40YTEuNSAxLjUgMCAwIDAgMC0xbC0uMi0uM2gtLjNaIi8+PHBhdGggZmlsbD0iI0ZGRUQ1MyIgZD0iTTE3NyA2Mi41aDE4djgzaC0xOHoiLz48cGF0aCBmaWxsPSIjMDAwIiBmaWxsLW9wYWNpdHk9Ii45IiBkPSJNMTgzLjMgMTM2LjJoLjdsLjIuOGExLjYgMS42IDAgMCAwIDEgMWwuOC4xaDEuNWwuNS0uMy4yLS4zLjEtLjR2LS4zbC0uMy0uMy0uNC0uMmExLjcgMS43IDAgMCAwLS45IDBjLS4xIDAtLjMgMC0uNC4ybC0uMi4yLS4xLjQuMS42LjQuMy40LjItLjEuM2EyIDIgMCAwIDEtLjctLjFsLS41LS40LS4zLS41VjEzNi4ybC41LS41LjYtLjNhMi41IDIuNSAwIDAgMSAxLjUgMGMuMiAwIC40LjIuNi40LjIuMS4zLjMuNC42bC4yLjdjMCAuMyAwIC42LS4yLjgtLjEuMy0uMy41LS41LjZsLS43LjQtLjguMmgtLjRsLTEuMy0uMmEzIDMgMCAwIDEtMS0uNWMtLjMtLjItLjUtLjUtLjctLjlhMyAzIDAgMCAxLS4yLTEuM1ptMi40LTUuNWgxYy40IDAgLjcgMCAxIC4yLjQgMCAuNi4yLjguM2wuNC42LjIuOC0uMS42YTEuNSAxLjUgMCAwIDEtLjggMWwtLjYuMmgtM2EyIDIgMCAwIDEtLjgtLjVsLS40LS41LS4yLS44LjEtLjZhMS41IDEuNSAwIDAgMSAuOC0xbC42LS4yaDFabTEgMWgtMS4yYTQgNCAwIDAgMC0uNiAwbC0uNC4xLS4zLjItLjIuM3YuN2wuMy4zLjUuMmgyLjVsLjUtLjEuMy0uMi4yLS4zdi0uN2wtLjMtLjMtLjUtLjJhNCA0IDAgMCAwLS44IDBaIi8+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1vcGFjaXR5PSIuNSIgZD0iTTE4Ny4yIDEyNHYtLjdjLjQgMCAuNy4yIDEgLjQuMi4xLjUuNC42LjcuMi4zLjMuNi4zIDEuMSAwIC4zIDAgLjYtLjIgMWEyIDIgMCAwIDEtLjUuNmwtLjkuNS0xIC4xaC0uNmwtMS4xLS4xLS44LS41YTIgMiAwIDAgMS0uNi0uN2wtLjItMWMwLS40LjEtLjguMy0xIC4xLS40LjQtLjYuNi0uN2wxLS40di44bC0uNi4yYTEgMSAwIDAgMC0uNS40bC0uMS43LjEuN2MwIC4yLjIuNC40LjVsLjYuM2gyLjJjLjMgMCAuNS0uMi43LS4zbC40LS40LjItLjdjMC0uMyAwLS42LS4yLS44YTEgMSAwIDAgMC0uNC0uNGwtLjctLjJabS00LjItMi40aDZ2LjdoLTZ2LS43Wm00LTFoLS4yYy0uMyAwLS41IDAtLjgtLjJhMiAyIDAgMCAxLS43LS40Yy0uMi0uMS0uMy0uMy0uNC0uNi0uMi0uMi0uMi0uNS0uMi0uOCAwLS4zIDAtLjUuMi0uOCAwLS4yLjItLjQuNC0uNmwuNy0uNGgxLjhsLjcuNGExLjggMS44IDAgMCAxIC42IDEuNGMwIC4zIDAgLjYtLjIuOGExLjggMS44IDAgMCAxLTEuMSAxbC0uOS4yWm0tLjItLjhoLjdjLjIgMCAuNC0uMi41LS4zLjIgMCAuMy0uMi40LS4zVjExOGExIDEgMCAwIDAtLjQtLjRsLS41LS4yaC0xLjJsLS41LjJhMSAxIDAgMCAwLS41LjlsLjEuNi40LjMuNS4zaC41Wm0xLjItNi42aC0zLjJ2LS44aDQuMnYuN2gtMVptLS45LS4ydi0uM2wuOC4xLjYuMy40LjUuMi44LS4xLjVhMS4xIDEuMSAwIDAgMS0uOC44SDE4NC44di0uN2gzLjJsLjMtLjIuMS0uMnYtLjNsLS4xLS44YTEgMSAwIDAgMC0uNS0uM2wtLjctLjJabTEtNC40SDE4M3YtLjdoNnYuN2gtLjhabS0xLjIgMi45YTMgMyAwIDAgMS0xLS4ybC0uNi0uM2ExLjUgMS41IDAgMCAxLS42LTEuMmwuMS0uNy40LS41Yy4yLS4xLjQtLjMuNy0uM2wuOC0uMmguNGMuMyAwIC42IDAgLjguMi4zIDAgLjUuMi42LjNsLjQuNWExLjcgMS43IDAgMCAxIDAgMS40YzAgLjItLjIuNC0uNC41LS4yLjItLjUuMy0uNy4zYTMgMyAwIDAgMS0uOS4yWm0wLS44aC42bC41LS4yYy4yLS4xLjMtLjIuMy0uNGwuMi0uNWExIDEgMCAwIDAtLjYtMWwtLjUtLjJoLTFhMiAyIDAgMCAwLS40LjJsLS4zLjJhMSAxIDAgMCAwLS40LjhsLjEuNS40LjQuNS4yaC42Wm0xLjYtNy43LS4xLS41YTEgMSAwIDAgMC0uMy0uMy44LjggMCAwIDAtLjQtLjJ2LS43bC43LjNhMS43IDEuNyAwIDAgMSAuNyAxLjRjMCAuMyAwIC42LS4yLjgtLjEuMy0uMy40LS41LjZhMiAyIDAgMCAxLS42LjRIMTg2YTIgMiAwIDAgMS0uNy0uNGwtLjQtLjZhMiAyIDAgMCAxLS4yLS44YzAtLjMgMC0uNi4yLS45bC41LS42LjgtLjJ2LjdhMSAxIDAgMCAwLS40LjEgMSAxIDAgMCAwLS41LjhsLjEuNi40LjQuNS4yaDEuMmwuNS0uMmMuMiAwIC4zLS4yLjQtLjR2LS41Wm0tMS42LTIuNC0uOS0uMWEyIDIgMCAwIDEtLjctLjRsLS40LS42Yy0uMi0uMi0uMi0uNS0uMi0uOCAwLS4zIDAtLjYuMi0uOCAwLS4yLjItLjQuNC0uNmwuNy0uNC44LS4xaC4xbC45LjEuNy40YTEuOCAxLjggMCAwIDEgLjYgMS40YzAgLjMgMCAuNi0uMi44IDAgLjItLjIuNC0uNC42YTIgMiAwIDAgMS0uNy40bC0uOS4xWm0wLS43aC42bC41LS4zYy4yIDAgLjMtLjIuNC0uNHYtMWExIDEgMCAwIDAtLjQtLjRsLS41LS4yaC0xLjJsLS41LjJhMSAxIDAgMCAwLS41LjlsLjEuNS40LjQuNS4yLjUuMVptMS40LTUuMy0zLjUtMS4ydi0uN2w0LjIgMS41di41bC0uNy0uMVptLTMuNSAxIDMuNi0xLjIuNi0uMXYuNWwtNC4yIDEuNXYtLjdabTQuMy01LjNjMCAuMiAwIC41LS4yLjhhMS44IDEuOCAwIDAgMS0xIDFsLS45LjFoLS4xYy0uNCAwLS43IDAtMS0uMmEyIDIgMCAwIDEtLjYtLjQgMS44IDEuOCAwIDAgMS0uNi0xLjNjMC0uMyAwLS41LjItLjcgMC0uMy4yLS40LjQtLjZsLjYtLjNoMS4ydjNoLS42di0yLjRsLS42LjFhMSAxIDAgMCAwLS40LjQgMSAxIDAgMCAwLS4yLjUgMSAxIDAgMCAwIC40LjlsLjUuMmgxLjRjLjIgMCAuMy0uMi41LS4zbC4zLS40di0xLjFsLS41LS41LjQtLjQuNC4zYzAgLjIuMi4zLjMuNXYuOFptLTMuNy0zLjNoMy42di43aC00LjJWODdoLjZabS0uNy0xLjNoLjd2LjhsLjMuMy4zLjIuNC4xLjIuMmgtLjhhMiAyIDAgMCAxLS42LS4zbC0uNC0uNGExLjEgMS4xIDAgMCAxIDAtMVpNNjEuNSAxNTQuM3YtNGguOHY0YzAgLjQgMCAuNy0uMiAxbC0uNy42YTIgMiAwIDAgMS0uOS4yYy0uMyAwLS43IDAtMS0uMmwtLjYtLjUtLjItMWguOHYuNmwuNS40YTEuMyAxLjMgMCAwIDAgMSAwbC40LS40LjEtLjdabTQuNiAxVjE1M2wtLjEtLjRjMC0uMi0uMi0uMy0uMy0uM2ExIDEgMCAwIDAtLjUtLjFoLS41bC0uMy4zLS4xLjNoLS43di0uNGwuNC0uNC42LS4zLjctLjFjLjMgMCAuNSAwIC44LjIuMiAwIC40LjIuNS40LjIuMi4yLjUuMi44djIuNGwuMi40di4xaC0uOHYtLjNsLS4xLS40Wm0uMS0xLjl2LjVoLS43bC0uNS4xLS41LjEtLjIuM3YuN2wuMy4zaC40YTEuMiAxLjIgMCAwIDAgMS0uNGwuMi0uNC4zLjMtLjIuNGExLjcgMS43IDAgMCAxLS44LjdINjQuMmwtLjUtLjVjLS4yLS4yLS4yLS40LS4yLS43bC4xLS42LjQtLjQuNi0uM2gxLjZabTIuNi0uN3YzLjNoLS43di00LjJoLjd2LjlabS0uMSAxaC0uM3YtLjhsLjQtLjZhMS42IDEuNiAwIDAgMSAxLjMtLjZoLjVsLjUuMy4yLjUuMS43djIuOGgtLjd2LTMuM2wtLjQtLjNhMSAxIDAgMCAwLS40IDAgMSAxIDAgMCAwLTEgLjMgMS42IDEuNiAwIDAgMC0uMiAxWk0xNTQgMTUwLjN2NS43aC0uOHYtNS43aC43Wm0yLjMgMi42di42aC0yLjZ2LS42aDIuNlptLjQtMi42di42aC0zdi0uNmgzWm0yLjYgNS44YTEuOCAxLjggMCAwIDEtMS44LTEuMmwtLjItLjl2LS4xYzAtLjQgMC0uNy4yLTFsLjQtLjZhMS44IDEuOCAwIDAgMSAyLS40Yy4zIDAgLjUuMi42LjRsLjMuNi4xLjl2LjNoLTMuMXYtLjZoMi40bC0uMS0uNmExIDEgMCAwIDAtLjMtLjQgMSAxIDAgMCAwLS42LS4yIDEgMSAwIDAgMC0uOC40bC0uMi41LS4xLjd2LjdsLjMuNS40LjNoMS4ybC40LS41LjUuNC0uNC40LS41LjNhMiAyIDAgMCAxLS43IDBabTIuNi02LjFoLjd2NmgtLjd2LTZabTMuNiAzLjktLjEuOS0uMy43LS41LjQtLjcuMmMtLjMgMC0uNiAwLS44LS4yLS4yIDAtLjMtLjItLjUtLjRhMiAyIDAgMCAxLS4zLS42IDQgNCAwIDAgMS0uMi0uOHYtLjRsLjItLjhjMC0uMy4yLS41LjMtLjdsLjUtLjQuNy0uMWMuMyAwIC41IDAgLjguMi4yIDAgLjMuMi41LjRsLjMuNy4xLjlabS0uNyAwdi0uNmwtLjMtLjVhMSAxIDAgMCAwLS44LS41bC0uNS4xYTEgMSAwIDAgMC0uNC4zbC0uMi4zLS4xLjR2MWwuMi41YzAgLjIuMi4zLjQuNGwuNi4yYy4yIDAgLjMgMCAuNS0uMmwuMy0uMy4yLS41di0uNloiLz48L3N2Zz4=", - "description": "Displays changes to time-series data over time visualized with value bars and labels — for example, daily water consumption for the last month.", + "description": "Displays changes to time series data over time visualized with value bars and labels — for example, daily water consumption for the last month.", "descriptor": { "type": "timeseries", "sizeX": 8, diff --git a/application/src/main/data/json/system/widget_types/bars.json b/application/src/main/data/json/system/widget_types/bars.json index 69c9ae208a..abc2ae1b38 100644 --- a/application/src/main/data/json/system/widget_types/bars.json +++ b/application/src/main/data/json/system/widget_types/bars.json @@ -3,7 +3,7 @@ "name": "Bars", "deprecated": false, "image": "tb-image:YmFycy5zdmc=:IkJhcnMiIHN5c3RlbSB3aWRnZXQgaW1hZ2U=;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF80NjI0XzM4MDIyKSI+CjxwYXRoIGQ9Ik0yLjg0NzY2IDEuMjgxMjVWN0gyLjEyNVYyLjE4MzU5TDAuNjY3OTY5IDIuNzE0ODRWMi4wNjI1TDIuNzM0MzggMS4yODEyNUgyLjg0NzY2Wk04LjY3NTE3IDMuNzAzMTJWNC41NzAzMUM4LjY3NTE3IDUuMDM2NDYgOC42MzM1MSA1LjQyOTY5IDguNTUwMTcgNS43NUM4LjQ2Njg0IDYuMDcwMzEgOC4zNDcwNSA2LjMyODEyIDguMTkwOCA2LjUyMzQ0QzguMDM0NTUgNi43MTg3NSA3Ljg0NTc1IDYuODYwNjggNy42MjQzOSA2Ljk0OTIyQzcuNDA1NjQgNy4wMzUxNiA3LjE1ODI1IDcuMDc4MTIgNi44ODIyIDcuMDc4MTJDNi42NjM0NSA3LjA3ODEyIDYuNDYxNjMgNy4wNTA3OCA2LjI3NjczIDYuOTk2MDlDNi4wOTE4NCA2Ljk0MTQxIDUuOTI1MTcgNi44NTQxNyA1Ljc3NjczIDYuNzM0MzhDNS42MzA5IDYuNjExOTggNS41MDU5IDYuNDUzMTIgNS40MDE3MyA2LjI1NzgxQzUuMjk3NTcgNi4wNjI1IDUuMjE4MTQgNS44MjU1MiA1LjE2MzQ1IDUuNTQ2ODhDNS4xMDg3NyA1LjI2ODIzIDUuMDgxNDIgNC45NDI3MSA1LjA4MTQyIDQuNTcwMzFWMy43MDMxMkM1LjA4MTQyIDMuMjM2OTggNS4xMjMwOSAyLjg0NjM1IDUuMjA2NDIgMi41MzEyNUM1LjI5MjM2IDIuMjE2MTUgNS40MTM0NSAxLjk2MzU0IDUuNTY5NyAxLjc3MzQ0QzUuNzI1OTUgMS41ODA3MyA1LjkxMzQ1IDEuNDQyNzEgNi4xMzIyIDEuMzU5MzhDNi4zNTM1NiAxLjI3NjA0IDYuNjAwOTUgMS4yMzQzOCA2Ljg3NDM5IDEuMjM0MzhDNy4wOTU3NSAxLjIzNDM4IDcuMjk4ODcgMS4yNjE3MiA3LjQ4Mzc3IDEuMzE2NDFDNy42NzEyNyAxLjM2ODQ5IDcuODM3OTMgMS40NTMxMiA3Ljk4Mzc3IDEuNTcwMzFDOC4xMjk2IDEuNjg0OSA4LjI1MzMgMS44Mzg1NCA4LjM1NDg2IDIuMDMxMjVDOC40NTkwMyAyLjIyMTM1IDguNTM4NDUgMi40NTQ0MyA4LjU5MzE0IDIuNzMwNDdDOC42NDc4MyAzLjAwNjUxIDguNjc1MTcgMy4zMzA3MyA4LjY3NTE3IDMuNzAzMTJaTTcuOTQ4NjEgNC42ODc1VjMuNTgyMDNDNy45NDg2MSAzLjMyNjgyIDcuOTMyOTggMy4xMDI4NiA3LjkwMTczIDIuOTEwMTZDNy44NzMwOSAyLjcxNDg0IDcuODMwMTIgMi41NDgxOCA3Ljc3MjgzIDIuNDEwMTZDNy43MTU1NCAyLjI3MjE0IDcuNjQyNjIgMi4xNjAxNiA3LjU1NDA4IDIuMDc0MjJDNy40NjgxNCAxLjk4ODI4IDcuMzY3ODggMS45MjU3OCA3LjI1MzMgMS44ODY3MkM3LjE0MTMyIDEuODQ1MDUgNy4wMTUwMiAxLjgyNDIyIDYuODc0MzkgMS44MjQyMkM2LjcwMjUyIDEuODI0MjIgNi41NTAxNyAxLjg1Njc3IDYuNDE3MzYgMS45MjE4OEM2LjI4NDU1IDEuOTg0MzggNi4xNzI1NyAyLjA4NDY0IDYuMDgxNDIgMi4yMjI2NkM1Ljk5Mjg4IDIuMzYwNjggNS45MjUxNyAyLjU0MTY3IDUuODc4MyAyLjc2NTYyQzUuODMxNDIgMi45ODk1OCA1LjgwNzk4IDMuMjYxNzIgNS44MDc5OCAzLjU4MjAzVjQuNjg3NUM1LjgwNzk4IDQuOTQyNzEgNS44MjIzMSA1LjE2Nzk3IDUuODUwOTUgNS4zNjMyOEM1Ljg4MjIgNS41NTg1OSA1LjkyNzc4IDUuNzI3ODYgNS45ODc2NyA1Ljg3MTA5QzYuMDQ3NTcgNi4wMTE3MiA2LjEyMDQ4IDYuMTI3NiA2LjIwNjQyIDYuMjE4NzVDNi4yOTIzNiA2LjMwOTkgNi4zOTEzMiA2LjM3NzYgNi41MDMzIDYuNDIxODhDNi42MTc4OCA2LjQ2MzU0IDYuNzQ0MTggNi40ODQzOCA2Ljg4MjIgNi40ODQzOEM3LjA1OTI5IDYuNDg0MzggNy4yMTQyMyA2LjQ1MDUyIDcuMzQ3MDUgNi4zODI4MUM3LjQ3OTg2IDYuMzE1MSA3LjU5MDU0IDYuMjA5NjQgNy42NzkwOCA2LjA2NjQxQzcuNzcwMjIgNS45MjA1NyA3LjgzNzkzIDUuNzM0MzggNy44ODIyIDUuNTA3ODFDNy45MjY0NyA1LjI3ODY1IDcuOTQ4NjEgNS4wMDUyMSA3Ljk0ODYxIDQuNjg3NVpNMTMuMzA3NCAzLjcwMzEyVjQuNTcwMzFDMTMuMzA3NCA1LjAzNjQ2IDEzLjI2NTcgNS40Mjk2OSAxMy4xODI0IDUuNzVDMTMuMDk5IDYuMDcwMzEgMTIuOTc5MyA2LjMyODEyIDEyLjgyMyA2LjUyMzQ0QzEyLjY2NjggNi43MTg3NSAxMi40Nzc5IDYuODYwNjggMTIuMjU2NiA2Ljk0OTIyQzEyLjAzNzggNy4wMzUxNiAxMS43OTA0IDcuMDc4MTIgMTEuNTE0NCA3LjA3ODEyQzExLjI5NTcgNy4wNzgxMiAxMS4wOTM4IDcuMDUwNzggMTAuOTA4OSA2Ljk5NjA5QzEwLjcyNCA2Ljk0MTQxIDEwLjU1NzQgNi44NTQxNyAxMC40MDg5IDYuNzM0MzhDMTAuMjYzMSA2LjYxMTk4IDEwLjEzODEgNi40NTMxMiAxMC4wMzM5IDYuMjU3ODFDOS45Mjk3NyA2LjA2MjUgOS44NTAzNCA1LjgyNTUyIDkuNzk1NjYgNS41NDY4OEM5Ljc0MDk3IDUuMjY4MjMgOS43MTM2MyA0Ljk0MjcxIDkuNzEzNjMgNC41NzAzMVYzLjcwMzEyQzkuNzEzNjMgMy4yMzY5OCA5Ljc1NTI5IDIuODQ2MzUgOS44Mzg2MyAyLjUzMTI1QzkuOTI0NTYgMi4yMTYxNSAxMC4wNDU3IDEuOTYzNTQgMTAuMjAxOSAxLjc3MzQ0QzEwLjM1ODIgMS41ODA3MyAxMC41NDU3IDEuNDQyNzEgMTAuNzY0NCAxLjM1OTM4QzEwLjk4NTggMS4yNzYwNCAxMS4yMzMyIDEuMjM0MzggMTEuNTA2NiAxLjIzNDM4QzExLjcyNzkgMS4yMzQzOCAxMS45MzExIDEuMjYxNzIgMTIuMTE2IDEuMzE2NDFDMTIuMzAzNSAxLjM2ODQ5IDEyLjQ3MDEgMS40NTMxMiAxMi42MTYgMS41NzAzMUMxMi43NjE4IDEuNjg0OSAxMi44ODU1IDEuODM4NTQgMTIuOTg3MSAyLjAzMTI1QzEzLjA5MTIgMi4yMjEzNSAxMy4xNzA3IDIuNDU0NDMgMTMuMjI1MyAyLjczMDQ3QzEzLjI4IDMuMDA2NTEgMTMuMzA3NCAzLjMzMDczIDEzLjMwNzQgMy43MDMxMlpNMTIuNTgwOCA0LjY4NzVWMy41ODIwM0MxMi41ODA4IDMuMzI2ODIgMTIuNTY1MiAzLjEwMjg2IDEyLjUzMzkgMi45MTAxNkMxMi41MDUzIDIuNzE0ODQgMTIuNDYyMyAyLjU0ODE4IDEyLjQwNSAyLjQxMDE2QzEyLjM0NzcgMi4yNzIxNCAxMi4yNzQ4IDIuMTYwMTYgMTIuMTg2MyAyLjA3NDIyQzEyLjEwMDMgMS45ODgyOCAxMi4wMDAxIDEuOTI1NzggMTEuODg1NSAxLjg4NjcyQzExLjc3MzUgMS44NDUwNSAxMS42NDcyIDEuODI0MjIgMTEuNTA2NiAxLjgyNDIyQzExLjMzNDcgMS44MjQyMiAxMS4xODI0IDEuODU2NzcgMTEuMDQ5NiAxLjkyMTg4QzEwLjkxNjggMS45ODQzOCAxMC44MDQ4IDIuMDg0NjQgMTAuNzEzNiAyLjIyMjY2QzEwLjYyNTEgMi4zNjA2OCAxMC41NTc0IDIuNTQxNjcgMTAuNTEwNSAyLjc2NTYyQzEwLjQ2MzYgMi45ODk1OCAxMC40NDAyIDMuMjYxNzIgMTAuNDQwMiAzLjU4MjAzVjQuNjg3NUMxMC40NDAyIDQuOTQyNzEgMTAuNDU0NSA1LjE2Nzk3IDEwLjQ4MzIgNS4zNjMyOEMxMC41MTQ0IDUuNTU4NTkgMTAuNTYgNS43Mjc4NiAxMC42MTk5IDUuODcxMDlDMTAuNjc5OCA2LjAxMTcyIDEwLjc1MjcgNi4xMjc2IDEwLjgzODYgNi4yMTg3NUMxMC45MjQ2IDYuMzA5OSAxMS4wMjM1IDYuMzc3NiAxMS4xMzU1IDYuNDIxODhDMTEuMjUwMSA2LjQ2MzU0IDExLjM3NjQgNi40ODQzOCAxMS41MTQ0IDYuNDg0MzhDMTEuNjkxNSA2LjQ4NDM4IDExLjg0NjQgNi40NTA1MiAxMS45NzkzIDYuMzgyODFDMTIuMTEyMSA2LjMxNTEgMTIuMjIyNyA2LjIwOTY0IDEyLjMxMTMgNi4wNjY0MUMxMi40MDI0IDUuOTIwNTcgMTIuNDcwMSA1LjczNDM4IDEyLjUxNDQgNS41MDc4MUMxMi41NTg3IDUuMjc4NjUgMTIuNTgwOCA1LjAwNTIxIDEyLjU4MDggNC42ODc1WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC43NiIvPgo8cGF0aCBkPSJNOC4wNTg1OSAzMy42Njc3QzguMDU4NTkgMzQuMDE0MSA3Ljk3Nzg2IDM0LjMwODMgNy44MTY0MSAzNC41NTA1QzcuNjU3NTUgMzQuNzkwMSA3LjQ0MTQxIDM0Ljk3MjQgNy4xNjc5NyAzNS4wOTc0QzYuODk3MTQgMzUuMjIyNCA2LjU5MTE1IDM1LjI4NDkgNi4yNSAzNS4yODQ5QzUuOTA4ODUgMzUuMjg0OSA1LjYwMTU2IDM1LjIyMjQgNS4zMjgxMiAzNS4wOTc0QzUuMDU0NjkgMzQuOTcyNCA0LjgzODU0IDM0Ljc5MDEgNC42Nzk2OSAzNC41NTA1QzQuNTIwODMgMzQuMzA4MyA0LjQ0MTQxIDM0LjAxNDEgNC40NDE0MSAzMy42Njc3QzQuNDQxNDEgMzMuNDQxMiA0LjQ4NDM4IDMzLjIzNDEgNC41NzAzMSAzMy4wNDY2QzQuNjU4ODUgMzIuODU2NSA0Ljc4MjU1IDMyLjY5MTIgNC45NDE0MSAzMi41NTA1QzUuMTAyODYgMzIuNDA5OSA1LjI5Mjk3IDMyLjMwMTggNS41MTE3MiAzMi4yMjYzQzUuNzMzMDcgMzIuMTQ4MiA1Ljk3NjU2IDMyLjEwOTEgNi4yNDIxOSAzMi4xMDkxQzYuNTkxMTUgMzIuMTA5MSA2LjkwMjM0IDMyLjE3NjggNy4xNzU3OCAzMi4zMTIzQzcuNDQ5MjIgMzIuNDQ1MSA3LjY2NDA2IDMyLjYyODcgNy44MjAzMSAzMi44NjNDNy45NzkxNyAzMy4wOTc0IDguMDU4NTkgMzMuMzY1NiA4LjA1ODU5IDMzLjY2NzdaTTcuMzMyMDMgMzMuNjUyMUM3LjMzMjAzIDMzLjQ0MTIgNy4yODY0NiAzMy4yNTUgNy4xOTUzMSAzMy4wOTM1QzcuMTA0MTcgMzIuOTI5NCA2Ljk3NjU2IDMyLjgwMTggNi44MTI1IDMyLjcxMDdDNi42NDg0NCAzMi42MTk1IDYuNDU4MzMgMzIuNTc0IDYuMjQyMTkgMzIuNTc0QzYuMDIwODMgMzIuNTc0IDUuODI5NDMgMzIuNjE5NSA1LjY2Nzk3IDMyLjcxMDdDNS41MDkxMSAzMi44MDE4IDUuMzg1NDIgMzIuOTI5NCA1LjI5Njg4IDMzLjA5MzVDNS4yMDgzMyAzMy4yNTUgNS4xNjQwNiAzMy40NDEyIDUuMTY0MDYgMzMuNjUyMUM1LjE2NDA2IDMzLjg3MDggNS4yMDcwMyAzNC4wNTgzIDUuMjkyOTcgMzQuMjE0NkM1LjM4MTUxIDM0LjM2ODIgNS41MDY1MSAzNC40ODY3IDUuNjY3OTcgMzQuNTcwMUM1LjgzMjAzIDM0LjY1MDggNi4wMjYwNCAzNC42OTEyIDYuMjUgMzQuNjkxMkM2LjQ3Mzk2IDM0LjY5MTIgNi42NjY2NyAzNC42NTA4IDYuODI4MTIgMzQuNTcwMUM2Ljk4OTU4IDM0LjQ4NjcgNy4xMTMyOCAzNC4zNjgyIDcuMTk5MjIgMzQuMjE0NkM3LjI4Nzc2IDM0LjA1ODMgNy4zMzIwMyAzMy44NzA4IDcuMzMyMDMgMzMuNjUyMVpNNy45MjU3OCAzMC45OTk4QzcuOTI1NzggMzEuMjc1OCA3Ljg1Mjg2IDMxLjUyNDUgNy43MDcwMyAzMS43NDU4QzcuNTYxMiAzMS45NjcyIDcuMzYxOTggMzIuMTQxNyA3LjEwOTM4IDMyLjI2OTNDNi44NTY3NyAzMi4zOTY5IDYuNTcwMzEgMzIuNDYwNyA2LjI1IDMyLjQ2MDdDNS45MjQ0OCAzMi40NjA3IDUuNjM0MTEgMzIuMzk2OSA1LjM3ODkxIDMyLjI2OTNDNS4xMjYzIDMyLjE0MTcgNC45MjgzOSAzMS45NjcyIDQuNzg1MTYgMzEuNzQ1OEM0LjY0MTkzIDMxLjUyNDUgNC41NzAzMSAzMS4yNzU4IDQuNTcwMzEgMzAuOTk5OEM0LjU3MDMxIDMwLjY2OSA0LjY0MTkzIDMwLjM4NzggNC43ODUxNiAzMC4xNTZDNC45MzA5OSAyOS45MjQyIDUuMTMwMjEgMjkuNzQ3MiA1LjM4MjgxIDI5LjYyNDhDNS42MzU0MiAyOS41MDI0IDUuOTIzMTggMjkuNDQxMiA2LjI0NjA5IDI5LjQ0MTJDNi41NzE2MSAyOS40NDEyIDYuODYwNjggMjkuNTAyNCA3LjExMzI4IDI5LjYyNDhDNy4zNjU4OSAyOS43NDcyIDcuNTYzOCAyOS45MjQyIDcuNzA3MDMgMzAuMTU2QzcuODUyODYgMzAuMzg3OCA3LjkyNTc4IDMwLjY2OSA3LjkyNTc4IDMwLjk5OThaTTcuMjAzMTIgMzEuMDExNUM3LjIwMzEyIDMwLjgyMTQgNy4xNjI3NiAzMC42NTM0IDcuMDgyMDMgMzAuNTA3NkM3LjAwMTMgMzAuMzYxNyA2Ljg4OTMyIDMwLjI0NzIgNi43NDYwOSAzMC4xNjM4QzYuNjAyODYgMzAuMDc3OSA2LjQzNjIgMzAuMDM0OSA2LjI0NjA5IDMwLjAzNDlDNi4wNTU5OSAzMC4wMzQ5IDUuODg5MzIgMzAuMDc1MyA1Ljc0NjA5IDMwLjE1NkM1LjYwNTQ3IDMwLjIzNDEgNS40OTQ3OSAzMC4zNDYxIDUuNDE0MDYgMzAuNDkxOUM1LjMzNTk0IDMwLjYzNzggNS4yOTY4OCAzMC44MTEgNS4yOTY4OCAzMS4wMTE1QzUuMjk2ODggMzEuMjA2OCA1LjMzNTk0IDMxLjM3NzQgNS40MTQwNiAzMS41MjMyQzUuNDk0NzkgMzEuNjY5IDUuNjA2NzcgMzEuNzgyMyA1Ljc1IDMxLjg2M0M1Ljg5MzIzIDMxLjk0MzggNi4wNTk5IDMxLjk4NDEgNi4yNSAzMS45ODQxQzYuNDQwMSAzMS45ODQxIDYuNjA1NDcgMzEuOTQzOCA2Ljc0NjA5IDMxLjg2M0M2Ljg4OTMyIDMxLjc4MjMgNy4wMDEzIDMxLjY2OSA3LjA4MjAzIDMxLjUyMzJDNy4xNjI3NiAzMS4zNzc0IDcuMjAzMTIgMzEuMjA2OCA3LjIwMzEyIDMxLjAxMTVaTTEyLjY3NTIgMzEuOTA5OVYzMi43NzcxQzEyLjY3NTIgMzMuMjQzMiAxMi42MzM1IDMzLjYzNjUgMTIuNTUwMiAzMy45NTY4QzEyLjQ2NjggMzQuMjc3MSAxMi4zNDcgMzQuNTM0OSAxMi4xOTA4IDM0LjczMDJDMTIuMDM0NSAzNC45MjU1IDExLjg0NTcgMzUuMDY3NSAxMS42MjQ0IDM1LjE1NkMxMS40MDU2IDM1LjI0MTkgMTEuMTU4MiAzNS4yODQ5IDEwLjg4MjIgMzUuMjg0OUMxMC42NjM1IDM1LjI4NDkgMTAuNDYxNiAzNS4yNTc2IDEwLjI3NjcgMzUuMjAyOUMxMC4wOTE4IDM1LjE0ODIgOS45MjUxNyAzNS4wNjEgOS43NzY3MyAzNC45NDEyQzkuNjMwOSAzNC44MTg4IDkuNTA1OSAzNC42NTk5IDkuNDAxNzMgMzQuNDY0NkM5LjI5NzU3IDM0LjI2OTMgOS4yMTgxNCAzNC4wMzIzIDkuMTYzNDUgMzMuNzUzN0M5LjEwODc3IDMzLjQ3NSA5LjA4MTQyIDMzLjE0OTUgOS4wODE0MiAzMi43NzcxVjMxLjkwOTlDOS4wODE0MiAzMS40NDM4IDkuMTIzMDkgMzEuMDUzMSA5LjIwNjQyIDMwLjczOEM5LjI5MjM2IDMwLjQyMjkgOS40MTM0NSAzMC4xNzAzIDkuNTY5NyAyOS45ODAyQzkuNzI1OTUgMjkuNzg3NSA5LjkxMzQ1IDI5LjY0OTUgMTAuMTMyMiAyOS41NjYyQzEwLjM1MzYgMjkuNDgyOCAxMC42MDEgMjkuNDQxMiAxMC44NzQ0IDI5LjQ0MTJDMTEuMDk1NyAyOS40NDEyIDExLjI5ODkgMjkuNDY4NSAxMS40ODM4IDI5LjUyMzJDMTEuNjcxMyAyOS41NzUzIDExLjgzNzkgMjkuNjU5OSAxMS45ODM4IDI5Ljc3NzFDMTIuMTI5NiAyOS44OTE3IDEyLjI1MzMgMzAuMDQ1MyAxMi4zNTQ5IDMwLjIzOEMxMi40NTkgMzAuNDI4MSAxMi41Mzg1IDMwLjY2MTIgMTIuNTkzMSAzMC45MzczQzEyLjY0NzggMzEuMjEzMyAxMi42NzUyIDMxLjUzNzUgMTIuNjc1MiAzMS45MDk5Wk0xMS45NDg2IDMyLjg5NDNWMzEuNzg4OEMxMS45NDg2IDMxLjUzMzYgMTEuOTMzIDMxLjMwOTcgMTEuOTAxNyAzMS4xMTY5QzExLjg3MzEgMzAuOTIxNiAxMS44MzAxIDMwLjc1NSAxMS43NzI4IDMwLjYxNjlDMTEuNzE1NSAzMC40Nzg5IDExLjY0MjYgMzAuMzY2OSAxMS41NTQxIDMwLjI4MUMxMS40NjgxIDMwLjE5NTEgMTEuMzY3OSAzMC4xMzI2IDExLjI1MzMgMzAuMDkzNUMxMS4xNDEzIDMwLjA1MTggMTEuMDE1IDMwLjAzMSAxMC44NzQ0IDMwLjAzMUMxMC43MDI1IDMwLjAzMSAxMC41NTAyIDMwLjA2MzYgMTAuNDE3NCAzMC4xMjg3QzEwLjI4NDUgMzAuMTkxMiAxMC4xNzI2IDMwLjI5MTQgMTAuMDgxNCAzMC40Mjk0QzkuOTkyODggMzAuNTY3NSA5LjkyNTE3IDMwLjc0ODUgOS44NzgzIDMwLjk3MjRDOS44MzE0MiAzMS4xOTY0IDkuODA3OTggMzEuNDY4NSA5LjgwNzk4IDMxLjc4ODhWMzIuODk0M0M5LjgwNzk4IDMzLjE0OTUgOS44MjIzMSAzMy4zNzQ4IDkuODUwOTUgMzMuNTcwMUM5Ljg4MjIgMzMuNzY1NCA5LjkyNzc4IDMzLjkzNDcgOS45ODc2NyAzNC4wNzc5QzEwLjA0NzYgMzQuMjE4NSAxMC4xMjA1IDM0LjMzNDQgMTAuMjA2NCAzNC40MjU1QzEwLjI5MjQgMzQuNTE2NyAxMC4zOTEzIDM0LjU4NDQgMTAuNTAzMyAzNC42Mjg3QzEwLjYxNzkgMzQuNjcwMyAxMC43NDQyIDM0LjY5MTIgMTAuODgyMiAzNC42OTEyQzExLjA1OTMgMzQuNjkxMiAxMS4yMTQyIDM0LjY1NzMgMTEuMzQ3IDM0LjU4OTZDMTEuNDc5OSAzNC41MjE5IDExLjU5MDUgMzQuNDE2NCAxMS42NzkxIDM0LjI3MzJDMTEuNzcwMiAzNC4xMjc0IDExLjgzNzkgMzMuOTQxMiAxMS44ODIyIDMzLjcxNDZDMTEuOTI2NSAzMy40ODU0IDExLjk0ODYgMzMuMjEyIDExLjk0ODYgMzIuODk0M1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTcuMjQ2MDkgNTcuNzE4M0g3LjMwODU5VjU4LjMzMTVINy4yNDYwOUM2Ljg2MzI4IDU4LjMzMTUgNi41NDI5NyA1OC4zOTQgNi4yODUxNiA1OC41MTlDNi4wMjczNCA1OC42NDE0IDUuODIyOTIgNTguODA2OCA1LjY3MTg4IDU5LjAxNTFDNS41MjA4MyA1OS4yMjA5IDUuNDExNDYgNTkuNDUyNiA1LjM0Mzc1IDU5LjcxMDRDNS4yNzg2NSA1OS45NjgzIDUuMjQ2MDkgNjAuMjMgNS4yNDYwOSA2MC40OTU2VjYxLjMzMTVDNS4yNDYwOSA2MS41ODQxIDUuMjc2MDQgNjEuODA4MSA1LjMzNTk0IDYyLjAwMzRDNS4zOTU4MyA2Mi4xOTYxIDUuNDc3ODYgNjIuMzU4OSA1LjU4MjAzIDYyLjQ5MTdDNS42ODYyIDYyLjYyNDUgNS44MDMzOSA2Mi43MjQ4IDUuOTMzNTkgNjIuNzkyNUM2LjA2NjQxIDYyLjg2MDIgNi4yMDQ0MyA2Mi44OTQgNi4zNDc2NiA2Mi44OTRDNi41MTQzMiA2Mi44OTQgNi42NjI3NiA2Mi44NjI4IDYuNzkyOTcgNjIuODAwM0M2LjkyMzE4IDYyLjczNTIgNy4wMzI1NSA2Mi42NDUzIDcuMTIxMDkgNjIuNTMwOEM3LjIxMjI0IDYyLjQxMzYgNy4yODEyNSA2Mi4yNzU2IDcuMzI4MTIgNjIuMTE2N0M3LjM3NSA2MS45NTc4IDcuMzk4NDQgNjEuNzgzNCA3LjM5ODQ0IDYxLjU5MzNDNy4zOTg0NCA2MS40MjQgNy4zNzc2IDYxLjI2MTIgNy4zMzU5NCA2MS4xMDVDNy4yOTQyNyA2MC45NDYxIDcuMjMwNDcgNjAuODA1NSA3LjE0NDUzIDYwLjY4MzFDNy4wNTg1OSA2MC41NTgxIDYuOTUwNTIgNjAuNDYwNCA2LjgyMDMxIDYwLjM5MDFDNi42OTI3MSA2MC4zMTcyIDYuNTQwMzYgNjAuMjgwOCA2LjM2MzI4IDYwLjI4MDhDNi4xNjI3NiA2MC4yODA4IDUuOTc1MjYgNjAuMzMwMiA1LjgwMDc4IDYwLjQyOTJDNS42Mjg5MSA2MC41MjU2IDUuNDg2OTggNjAuNjUzMiA1LjM3NSA2MC44MTJDNS4yNjU2MiA2MC45NjgzIDUuMjAzMTIgNjEuMTM4OCA1LjE4NzUgNjEuMzIzN0w0LjgwNDY5IDYxLjMxOThDNC44NDExNSA2MS4wMjgyIDQuOTA4ODUgNjAuNzc5NSA1LjAwNzgxIDYwLjU3MzdDNS4xMDkzOCA2MC4zNjU0IDUuMjM0MzggNjAuMTk2MSA1LjM4MjgxIDYwLjA2NTlDNS41MzM4NSA1OS45MzMxIDUuNzAxODIgNTkuODM2OCA1Ljg4NjcyIDU5Ljc3NjlDNi4wNzQyMiA1OS43MTQ0IDYuMjcyMTQgNTkuNjgzMSA2LjQ4MDQ3IDU5LjY4MzFDNi43NjQzMiA1OS42ODMxIDcuMDA5MTEgNTkuNzM2NSA3LjIxNDg0IDU5Ljg0MzNDNy40MjA1NyA1OS45NSA3LjU4OTg0IDYwLjA5MzMgNy43MjI2NiA2MC4yNzI5QzcuODU1NDcgNjAuNDUgNy45NTMxMiA2MC42NTA2IDguMDE1NjIgNjAuODc0NUM4LjA4MDczIDYxLjA5NTkgOC4xMTMyOCA2MS4zMjM3IDguMTEzMjggNjEuNTU4MUM4LjExMzI4IDYxLjgyNjMgOC4wNzU1MiA2Mi4wNzc2IDggNjIuMzEyQzcuOTI0NDggNjIuNTQ2NCA3LjgxMTIgNjIuNzUyMSA3LjY2MDE2IDYyLjkyOTJDNy41MTE3MiA2My4xMDYzIDcuMzI4MTIgNjMuMjQ0MyA3LjEwOTM4IDYzLjM0MzNDNi44OTA2MiA2My40NDIyIDYuNjM2NzIgNjMuNDkxNyA2LjM0NzY2IDYzLjQ5MTdDNi4wNDAzNiA2My40OTE3IDUuNzcyMTQgNjMuNDI5MiA1LjU0Mjk3IDYzLjMwNDJDNS4zMTM4IDYzLjE3NjYgNS4xMjM3IDYzLjAwNzMgNC45NzI2NiA2Mi43OTY0QzQuODIxNjEgNjIuNTg1NCA0LjcwODMzIDYyLjM1MTEgNC42MzI4MSA2Mi4wOTMzQzQuNTU3MjkgNjEuODM1NCA0LjUxOTUzIDYxLjU3MzcgNC41MTk1MyA2MS4zMDgxVjYwLjk2ODNDNC41MTk1MyA2MC41NjcyIDQuNTU5OSA2MC4xNzQgNC42NDA2MiA1OS43ODg2QzQuNzIxMzUgNTkuNDAzMiA0Ljg2MDY4IDU5LjA1NDIgNS4wNTg1OSA1OC43NDE3QzUuMjU5MTEgNTguNDI5MiA1LjUzNjQ2IDU4LjE4MDUgNS44OTA2MiA1Ny45OTU2QzYuMjQ0NzkgNTcuODEwNyA2LjY5NjYxIDU3LjcxODMgNy4yNDYwOSA1Ny43MTgzWk0xMi42NzUyIDYwLjExNjdWNjAuOTgzOUMxMi42NzUyIDYxLjQ1IDEyLjYzMzUgNjEuODQzMyAxMi41NTAyIDYyLjE2MzZDMTIuNDY2OCA2Mi40ODM5IDEyLjM0NyA2Mi43NDE3IDEyLjE5MDggNjIuOTM3QzEyLjAzNDUgNjMuMTMyMyAxMS44NDU3IDYzLjI3NDMgMTEuNjI0NCA2My4zNjI4QzExLjQwNTYgNjMuNDQ4NyAxMS4xNTgyIDYzLjQ5MTcgMTAuODgyMiA2My40OTE3QzEwLjY2MzUgNjMuNDkxNyAxMC40NjE2IDYzLjQ2NDQgMTAuMjc2NyA2My40MDk3QzEwLjA5MTggNjMuMzU1IDkuOTI1MTcgNjMuMjY3NyA5Ljc3NjczIDYzLjE0NzlDOS42MzA5IDYzLjAyNTYgOS41MDU5IDYyLjg2NjcgOS40MDE3MyA2Mi42NzE0QzkuMjk3NTcgNjIuNDc2MSA5LjIxODE0IDYyLjIzOTEgOS4xNjM0NSA2MS45NjA0QzkuMTA4NzcgNjEuNjgxOCA5LjA4MTQyIDYxLjM1NjMgOS4wODE0MiA2MC45ODM5VjYwLjExNjdDOS4wODE0MiA1OS42NTA2IDkuMTIzMDkgNTkuMjU5OSA5LjIwNjQyIDU4Ljk0NDhDOS4yOTIzNiA1OC42Mjk3IDkuNDEzNDUgNTguMzc3MSA5LjU2OTcgNTguMTg3QzkuNzI1OTUgNTcuOTk0MyA5LjkxMzQ1IDU3Ljg1NjMgMTAuMTMyMiA1Ny43NzI5QzEwLjM1MzYgNTcuNjg5NiAxMC42MDEgNTcuNjQ3OSAxMC44NzQ0IDU3LjY0NzlDMTEuMDk1NyA1Ny42NDc5IDExLjI5ODkgNTcuNjc1MyAxMS40ODM4IDU3LjczQzExLjY3MTMgNTcuNzgyMSAxMS44Mzc5IDU3Ljg2NjcgMTEuOTgzOCA1Ny45ODM5QzEyLjEyOTYgNTguMDk4NSAxMi4yNTMzIDU4LjI1MjEgMTIuMzU0OSA1OC40NDQ4QzEyLjQ1OSA1OC42MzQ5IDEyLjUzODUgNTguODY4IDEyLjU5MzEgNTkuMTQ0QzEyLjY0NzggNTkuNDIwMSAxMi42NzUyIDU5Ljc0NDMgMTIuNjc1MiA2MC4xMTY3Wk0xMS45NDg2IDYxLjEwMTFWNTkuOTk1NkMxMS45NDg2IDU5Ljc0MDQgMTEuOTMzIDU5LjUxNjQgMTEuOTAxNyA1OS4zMjM3QzExLjg3MzEgNTkuMTI4NCAxMS44MzAxIDU4Ljk2MTggMTEuNzcyOCA1OC44MjM3QzExLjcxNTUgNTguNjg1NyAxMS42NDI2IDU4LjU3MzcgMTEuNTU0MSA1OC40ODc4QzExLjQ2ODEgNTguNDAxOSAxMS4zNjc5IDU4LjMzOTQgMTEuMjUzMyA1OC4zMDAzQzExLjE0MTMgNTguMjU4NiAxMS4wMTUgNTguMjM3OCAxMC44NzQ0IDU4LjIzNzhDMTAuNzAyNSA1OC4yMzc4IDEwLjU1MDIgNTguMjcwMyAxMC40MTc0IDU4LjMzNTRDMTAuMjg0NSA1OC4zOTc5IDEwLjE3MjYgNTguNDk4MiAxMC4wODE0IDU4LjYzNjJDOS45OTI4OCA1OC43NzQzIDkuOTI1MTcgNTguOTU1MiA5Ljg3ODMgNTkuMTc5MkM5LjgzMTQyIDU5LjQwMzIgOS44MDc5OCA1OS42NzUzIDkuODA3OTggNTkuOTk1NlY2MS4xMDExQzkuODA3OTggNjEuMzU2MyA5LjgyMjMxIDYxLjU4MTUgOS44NTA5NSA2MS43NzY5QzkuODgyMiA2MS45NzIyIDkuOTI3NzggNjIuMTQxNCA5Ljk4NzY3IDYyLjI4NDdDMTAuMDQ3NiA2Mi40MjUzIDEwLjEyMDUgNjIuNTQxMiAxMC4yMDY0IDYyLjYzMjNDMTAuMjkyNCA2Mi43MjM1IDEwLjM5MTMgNjIuNzkxMiAxMC41MDMzIDYyLjgzNTRDMTAuNjE3OSA2Mi44NzcxIDEwLjc0NDIgNjIuODk3OSAxMC44ODIyIDYyLjg5NzlDMTEuMDU5MyA2Mi44OTc5IDExLjIxNDIgNjIuODY0MSAxMS4zNDcgNjIuNzk2NEMxMS40Nzk5IDYyLjcyODcgMTEuNTkwNSA2Mi42MjMyIDExLjY3OTEgNjIuNDhDMTEuNzcwMiA2Mi4zMzQxIDExLjgzNzkgNjIuMTQ3OSAxMS44ODIyIDYxLjkyMTRDMTEuOTI2NSA2MS42OTIyIDExLjk0ODYgNjEuNDE4OCAxMS45NDg2IDYxLjEwMTFaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik04LjMxNjQxIDg5LjcwNjNWOTAuM0g0LjIwNzAzVjg5Ljg3NDNMNi43NTM5MSA4NS45MzI5SDcuMzQzNzVMNi43MTA5NCA4Ny4wNzM1TDUuMDI3MzQgODkuNzA2M0g4LjMxNjQxWk03LjUyMzQ0IDg1LjkzMjlWOTEuNjIwNEg2LjgwMDc4Vjg1LjkzMjlINy41MjM0NFpNMTIuNjc1MiA4OC4zMjM1Vjg5LjE5MDdDMTIuNjc1MiA4OS42NTY4IDEyLjYzMzUgOTAuMDUgMTIuNTUwMiA5MC4zNzA0QzEyLjQ2NjggOTAuNjkwNyAxMi4zNDcgOTAuOTQ4NSAxMi4xOTA4IDkxLjE0MzhDMTIuMDM0NSA5MS4zMzkxIDExLjg0NTcgOTEuNDgxIDExLjYyNDQgOTEuNTY5NkMxMS40MDU2IDkxLjY1NTUgMTEuMTU4MiA5MS42OTg1IDEwLjg4MjIgOTEuNjk4NUMxMC42NjM1IDkxLjY5ODUgMTAuNDYxNiA5MS42NzExIDEwLjI3NjcgOTEuNjE2NUMxMC4wOTE4IDkxLjU2MTggOS45MjUxNyA5MS40NzQ1IDkuNzc2NzMgOTEuMzU0N0M5LjYzMDkgOTEuMjMyMyA5LjUwNTkgOTEuMDczNSA5LjQwMTczIDkwLjg3ODJDOS4yOTc1NyA5MC42ODI5IDkuMjE4MTQgOTAuNDQ1OSA5LjE2MzQ1IDkwLjE2NzJDOS4xMDg3NyA4OS44ODg2IDkuMDgxNDIgODkuNTYzMSA5LjA4MTQyIDg5LjE5MDdWODguMzIzNUM5LjA4MTQyIDg3Ljg1NzMgOS4xMjMwOSA4Ny40NjY3IDkuMjA2NDIgODcuMTUxNkM5LjI5MjM2IDg2LjgzNjUgOS40MTM0NSA4Ni41ODM5IDkuNTY5NyA4Ni4zOTM4QzkuNzI1OTUgODYuMjAxMSA5LjkxMzQ1IDg2LjA2MzEgMTAuMTMyMiA4NS45Nzk3QzEwLjM1MzYgODUuODk2NCAxMC42MDEgODUuODU0NyAxMC44NzQ0IDg1Ljg1NDdDMTEuMDk1NyA4NS44NTQ3IDExLjI5ODkgODUuODgyMSAxMS40ODM4IDg1LjkzNjhDMTEuNjcxMyA4NS45ODg5IDExLjgzNzkgODYuMDczNSAxMS45ODM4IDg2LjE5MDdDMTIuMTI5NiA4Ni4zMDUzIDEyLjI1MzMgODYuNDU4OSAxMi4zNTQ5IDg2LjY1MTZDMTIuNDU5IDg2Ljg0MTcgMTIuNTM4NSA4Ny4wNzQ4IDEyLjU5MzEgODcuMzUwOEMxMi42NDc4IDg3LjYyNjkgMTIuNjc1MiA4Ny45NTExIDEyLjY3NTIgODguMzIzNVpNMTEuOTQ4NiA4OS4zMDc5Vjg4LjIwMjRDMTEuOTQ4NiA4Ny45NDcyIDExLjkzMyA4Ny43MjMyIDExLjkwMTcgODcuNTMwNUMxMS44NzMxIDg3LjMzNTIgMTEuODMwMSA4Ny4xNjg1IDExLjc3MjggODcuMDMwNUMxMS43MTU1IDg2Ljg5MjUgMTEuNjQyNiA4Ni43ODA1IDExLjU1NDEgODYuNjk0NkMxMS40NjgxIDg2LjYwODYgMTEuMzY3OSA4Ni41NDYxIDExLjI1MzMgODYuNTA3MUMxMS4xNDEzIDg2LjQ2NTQgMTEuMDE1IDg2LjQ0NDYgMTAuODc0NCA4Ni40NDQ2QzEwLjcwMjUgODYuNDQ0NiAxMC41NTAyIDg2LjQ3NzEgMTAuNDE3NCA4Ni41NDIyQzEwLjI4NDUgODYuNjA0NyAxMC4xNzI2IDg2LjcwNSAxMC4wODE0IDg2Ljg0M0M5Ljk5Mjg4IDg2Ljk4MSA5LjkyNTE3IDg3LjE2MiA5Ljg3ODMgODcuMzg2QzkuODMxNDIgODcuNjA5OSA5LjgwNzk4IDg3Ljg4MjEgOS44MDc5OCA4OC4yMDI0Vjg5LjMwNzlDOS44MDc5OCA4OS41NjMxIDkuODIyMzEgODkuNzg4MyA5Ljg1MDk1IDg5Ljk4MzZDOS44ODIyIDkwLjE3OSA5LjkyNzc4IDkwLjM0ODIgOS45ODc2NyA5MC40OTE1QzEwLjA0NzYgOTAuNjMyMSAxMC4xMjA1IDkwLjc0OCAxMC4yMDY0IDkwLjgzOTFDMTAuMjkyNCA5MC45MzAzIDEwLjM5MTMgOTAuOTk4IDEwLjUwMzMgOTEuMDQyMkMxMC42MTc5IDkxLjA4MzkgMTAuNzQ0MiA5MS4xMDQ3IDEwLjg4MjIgOTEuMTA0N0MxMS4wNTkzIDkxLjEwNDcgMTEuMjE0MiA5MS4wNzA5IDExLjM0NyA5MS4wMDMyQzExLjQ3OTkgOTAuOTM1NSAxMS41OTA1IDkwLjgzIDExLjY3OTEgOTAuNjg2OEMxMS43NzAyIDkwLjU0MDkgMTEuODM3OSA5MC4zNTQ3IDExLjg4MjIgOTAuMTI4MkMxMS45MjY1IDg5Ljg5OSAxMS45NDg2IDg5LjYyNTYgMTEuOTQ4NiA4OS4zMDc5WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC43NiIvPgo8cGF0aCBkPSJNOC4xOTkyMiAxMTkuMjMzVjExOS44MjdINC40NzY1NlYxMTkuMzA4TDYuMzM5ODQgMTE3LjIzM0M2LjU2OTAxIDExNi45NzggNi43NDYwOSAxMTYuNzYyIDYuODcxMDkgMTE2LjU4NUM2Ljk5ODcgMTE2LjQwNSA3LjA4NzI0IDExNi4yNDUgNy4xMzY3MiAxMTYuMTA0QzcuMTg4OCAxMTUuOTYxIDcuMjE0ODQgMTE1LjgxNSA3LjIxNDg0IDExNS42NjdDNy4yMTQ4NCAxMTUuNDc5IDcuMTc1NzggMTE1LjMxIDcuMDk3NjYgMTE1LjE1OUM3LjAyMjE0IDExNS4wMDYgNi45MTAxNiAxMTQuODgzIDYuNzYxNzIgMTE0Ljc5MkM2LjYxMzI4IDExNC43MDEgNi40MzM1OSAxMTQuNjU1IDYuMjIyNjYgMTE0LjY1NUM1Ljk3MDA1IDExNC42NTUgNS43NTkxMSAxMTQuNzA1IDUuNTg5ODQgMTE0LjgwNEM1LjQyMzE4IDExNC45IDUuMjk4MTggMTE1LjAzNSA1LjIxNDg0IDExNS4yMUM1LjEzMTUxIDExNS4zODQgNS4wODk4NCAxMTUuNTg1IDUuMDg5ODQgMTE1LjgxMkg0LjM2NzE5QzQuMzY3MTkgMTE1LjQ5MSA0LjQzNzUgMTE1LjE5OCA0LjU3ODEyIDExNC45MzNDNC43MTg3NSAxMTQuNjY3IDQuOTI3MDggMTE0LjQ1NiA1LjIwMzEyIDExNC4zQzUuNDc5MTcgMTE0LjE0MSA1LjgxOTAxIDExNC4wNjIgNi4yMjI2NiAxMTQuMDYyQzYuNTgyMDMgMTE0LjA2MiA2Ljg4OTMyIDExNC4xMjUgNy4xNDQ1MyAxMTQuMjUzQzcuMzk5NzQgMTE0LjM3OCA3LjU5NTA1IDExNC41NTUgNy43MzA0NyAxMTQuNzg0QzcuODY4NDkgMTE1LjAxMSA3LjkzNzUgMTE1LjI3NiA3LjkzNzUgMTE1LjU4MUM3LjkzNzUgMTE1Ljc0OCA3LjkwODg1IDExNS45MTcgNy44NTE1NiAxMTYuMDg5QzcuNzk2ODggMTE2LjI1OCA3LjcyMDA1IDExNi40MjcgNy42MjEwOSAxMTYuNTk3QzcuNTI0NzQgMTE2Ljc2NiA3LjQxMTQ2IDExNi45MzMgNy4yODEyNSAxMTcuMDk3QzcuMTUzNjUgMTE3LjI2MSA3LjAxNjkzIDExNy40MjIgNi44NzEwOSAxMTcuNTgxTDUuMzQ3NjYgMTE5LjIzM0g4LjE5OTIyWk0xMi42NzUyIDExNi41M1YxMTcuMzk3QzEyLjY3NTIgMTE3Ljg2NCAxMi42MzM1IDExOC4yNTcgMTIuNTUwMiAxMTguNTc3QzEyLjQ2NjggMTE4Ljg5NyAxMi4zNDcgMTE5LjE1NSAxMi4xOTA4IDExOS4zNTFDMTIuMDM0NSAxMTkuNTQ2IDExLjg0NTcgMTE5LjY4OCAxMS42MjQ0IDExOS43NzZDMTEuNDA1NiAxMTkuODYyIDExLjE1ODIgMTE5LjkwNSAxMC44ODIyIDExOS45MDVDMTAuNjYzNSAxMTkuOTA1IDEwLjQ2MTYgMTE5Ljg3OCAxMC4yNzY3IDExOS44MjNDMTAuMDkxOCAxMTkuNzY5IDkuOTI1MTcgMTE5LjY4MSA5Ljc3NjczIDExOS41NjJDOS42MzA5IDExOS40MzkgOS41MDU5IDExOS4yOCA5LjQwMTczIDExOS4wODVDOS4yOTc1NyAxMTguODkgOS4yMTgxNCAxMTguNjUzIDkuMTYzNDUgMTE4LjM3NEM5LjEwODc3IDExOC4wOTUgOS4wODE0MiAxMTcuNzcgOS4wODE0MiAxMTcuMzk3VjExNi41M0M5LjA4MTQyIDExNi4wNjQgOS4xMjMwOSAxMTUuNjc0IDkuMjA2NDIgMTE1LjM1OEM5LjI5MjM2IDExNS4wNDMgOS40MTM0NSAxMTQuNzkxIDkuNTY5NyAxMTQuNjAxQzkuNzI1OTUgMTE0LjQwOCA5LjkxMzQ1IDExNC4yNyAxMC4xMzIyIDExNC4xODdDMTAuMzUzNiAxMTQuMTAzIDEwLjYwMSAxMTQuMDYyIDEwLjg3NDQgMTE0LjA2MkMxMS4wOTU3IDExNC4wNjIgMTEuMjk4OSAxMTQuMDg5IDExLjQ4MzggMTE0LjE0NEMxMS42NzEzIDExNC4xOTYgMTEuODM3OSAxMTQuMjggMTEuOTgzOCAxMTQuMzk3QzEyLjEyOTYgMTE0LjUxMiAxMi4yNTMzIDExNC42NjYgMTIuMzU0OSAxMTQuODU4QzEyLjQ1OSAxMTUuMDQ5IDEyLjUzODUgMTE1LjI4MiAxMi41OTMxIDExNS41NThDMTIuNjQ3OCAxMTUuODM0IDEyLjY3NTIgMTE2LjE1OCAxMi42NzUyIDExNi41M1pNMTEuOTQ4NiAxMTcuNTE1VjExNi40MDlDMTEuOTQ4NiAxMTYuMTU0IDExLjkzMyAxMTUuOTMgMTEuOTAxNyAxMTUuNzM3QzExLjg3MzEgMTE1LjU0MiAxMS44MzAxIDExNS4zNzUgMTEuNzcyOCAxMTUuMjM3QzExLjcxNTUgMTE1LjA5OSAxMS42NDI2IDExNC45ODcgMTEuNTU0MSAxMTQuOTAxQzExLjQ2ODEgMTE0LjgxNSAxMS4zNjc5IDExNC43NTMgMTEuMjUzMyAxMTQuNzE0QzExLjE0MTMgMTE0LjY3MiAxMS4wMTUgMTE0LjY1MSAxMC44NzQ0IDExNC42NTFDMTAuNzAyNSAxMTQuNjUxIDEwLjU1MDIgMTE0LjY4NCAxMC40MTc0IDExNC43NDlDMTAuMjg0NSAxMTQuODEyIDEwLjE3MjYgMTE0LjkxMiAxMC4wODE0IDExNS4wNUM5Ljk5Mjg4IDExNS4xODggOS45MjUxNyAxMTUuMzY5IDkuODc4MyAxMTUuNTkzQzkuODMxNDIgMTE1LjgxNyA5LjgwNzk4IDExNi4wODkgOS44MDc5OCAxMTYuNDA5VjExNy41MTVDOS44MDc5OCAxMTcuNzcgOS44MjIzMSAxMTcuOTk1IDkuODUwOTUgMTE4LjE5QzkuODgyMiAxMTguMzg2IDkuOTI3NzggMTE4LjU1NSA5Ljk4NzY3IDExOC42OThDMTAuMDQ3NiAxMTguODM5IDEwLjEyMDUgMTE4Ljk1NSAxMC4yMDY0IDExOS4wNDZDMTAuMjkyNCAxMTkuMTM3IDEwLjM5MTMgMTE5LjIwNSAxMC41MDMzIDExOS4yNDlDMTAuNjE3OSAxMTkuMjkxIDEwLjc0NDIgMTE5LjMxMiAxMC44ODIyIDExOS4zMTJDMTEuMDU5MyAxMTkuMzEyIDExLjIxNDIgMTE5LjI3OCAxMS4zNDcgMTE5LjIxQzExLjQ3OTkgMTE5LjE0MiAxMS41OTA1IDExOS4wMzcgMTEuNjc5MSAxMTguODk0QzExLjc3MDIgMTE4Ljc0OCAxMS44Mzc5IDExOC41NjIgMTEuODgyMiAxMTguMzM1QzExLjkyNjUgMTE4LjEwNiAxMS45NDg2IDExNy44MzIgMTEuOTQ4NiAxMTcuNTE1WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC43NiIvPgo8cGF0aCBkPSJNMTMuMDQzIDE0NC43MzdWMTQ1LjYwNEMxMy4wNDMgMTQ2LjA3IDEzLjAwMTMgMTQ2LjQ2NCAxMi45MTggMTQ2Ljc4NEMxMi44MzQ2IDE0Ny4xMDQgMTIuNzE0OCAxNDcuMzYyIDEyLjU1ODYgMTQ3LjU1N0MxMi40MDIzIDE0Ny43NTMgMTIuMjEzNSAxNDcuODk1IDExLjk5MjIgMTQ3Ljk4M0MxMS43NzM0IDE0OC4wNjkgMTEuNTI2IDE0OC4xMTIgMTEuMjUgMTQ4LjExMkMxMS4wMzEyIDE0OC4xMTIgMTAuODI5NCAxNDguMDg1IDEwLjY0NDUgMTQ4LjAzQzEwLjQ1OTYgMTQ3Ljk3NSAxMC4yOTMgMTQ3Ljg4OCAxMC4xNDQ1IDE0Ny43NjhDOS45OTg3IDE0Ny42NDYgOS44NzM3IDE0Ny40ODcgOS43Njk1MyAxNDcuMjkyQzkuNjY1MzYgMTQ3LjA5NiA5LjU4NTk0IDE0Ni44NTkgOS41MzEyNSAxNDYuNTgxQzkuNDc2NTYgMTQ2LjMwMiA5LjQ0OTIyIDE0NS45NzcgOS40NDkyMiAxNDUuNjA0VjE0NC43MzdDOS40NDkyMiAxNDQuMjcxIDkuNDkwODkgMTQzLjg4IDkuNTc0MjIgMTQzLjU2NUM5LjY2MDE2IDE0My4yNSA5Ljc4MTI1IDE0Mi45OTcgOS45Mzc1IDE0Mi44MDdDMTAuMDkzOCAxNDIuNjE1IDEwLjI4MTIgMTQyLjQ3NyAxMC41IDE0Mi4zOTNDMTAuNzIxNCAxNDIuMzEgMTAuOTY4OCAxNDIuMjY4IDExLjI0MjIgMTQyLjI2OEMxMS40NjM1IDE0Mi4yNjggMTEuNjY2NyAxNDIuMjk2IDExLjg1MTYgMTQyLjM1QzEyLjAzOTEgMTQyLjQwMiAxMi4yMDU3IDE0Mi40ODcgMTIuMzUxNiAxNDIuNjA0QzEyLjQ5NzQgMTQyLjcxOSAxMi42MjExIDE0Mi44NzIgMTIuNzIyNyAxNDMuMDY1QzEyLjgyNjggMTQzLjI1NSAxMi45MDYyIDE0My40ODggMTIuOTYwOSAxNDMuNzY0QzEzLjAxNTYgMTQ0LjA0IDEzLjA0MyAxNDQuMzY1IDEzLjA0MyAxNDQuNzM3Wk0xMi4zMTY0IDE0NS43MjFWMTQ0LjYxNkMxMi4zMTY0IDE0NC4zNjEgMTIuMzAwOCAxNDQuMTM3IDEyLjI2OTUgMTQzLjk0NEMxMi4yNDA5IDE0My43NDkgMTIuMTk3OSAxNDMuNTgyIDEyLjE0MDYgMTQzLjQ0NEMxMi4wODMzIDE0My4zMDYgMTIuMDEwNCAxNDMuMTk0IDExLjkyMTkgMTQzLjEwOEMxMS44MzU5IDE0My4wMjIgMTEuNzM1NyAxNDIuOTYgMTEuNjIxMSAxNDIuOTIxQzExLjUwOTEgMTQyLjg3OSAxMS4zODI4IDE0Mi44NTggMTEuMjQyMiAxNDIuODU4QzExLjA3MDMgMTQyLjg1OCAxMC45MTggMTQyLjg5MSAxMC43ODUyIDE0Mi45NTZDMTAuNjUyMyAxNDMuMDE4IDEwLjU0MDQgMTQzLjExOSAxMC40NDkyIDE0My4yNTdDMTAuMzYwNyAxNDMuMzk1IDEwLjI5MyAxNDMuNTc2IDEwLjI0NjEgMTQzLjhDMTAuMTk5MiAxNDQuMDI0IDEwLjE3NTggMTQ0LjI5NiAxMC4xNzU4IDE0NC42MTZWMTQ1LjcyMUMxMC4xNzU4IDE0NS45NzcgMTAuMTkwMSAxNDYuMjAyIDEwLjIxODggMTQ2LjM5N0MxMC4yNSAxNDYuNTkzIDEwLjI5NTYgMTQ2Ljc2MiAxMC4zNTU1IDE0Ni45MDVDMTAuNDE1NCAxNDcuMDQ2IDEwLjQ4ODMgMTQ3LjE2MiAxMC41NzQyIDE0Ny4yNTNDMTAuNjYwMiAxNDcuMzQ0IDEwLjc1OTEgMTQ3LjQxMiAxMC44NzExIDE0Ny40NTZDMTAuOTg1NyAxNDcuNDk3IDExLjExMiAxNDcuNTE4IDExLjI1IDE0Ny41MThDMTEuNDI3MSAxNDcuNTE4IDExLjU4MiAxNDcuNDg0IDExLjcxNDggMTQ3LjQxN0MxMS44NDc3IDE0Ny4zNDkgMTEuOTU4MyAxNDcuMjQ0IDEyLjA0NjkgMTQ3LjFDMTIuMTM4IDE0Ni45NTUgMTIuMjA1NyAxNDYuNzY4IDEyLjI1IDE0Ni41NDJDMTIuMjk0MyAxNDYuMzEzIDEyLjMxNjQgMTQ2LjAzOSAxMi4zMTY0IDE0NS43MjFaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0xOSA0LjE2MTEzTDE5NCA0LjE2MTE2IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC4xMiIvPgo8cGF0aCBkPSJNMTkgMzMuMTYxMUwxOTQgMzMuMTYxMiIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiLz4KPHBhdGggZD0iTTE5IDYxLjE2MTFMMTk0IDYxLjE2MTIiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjEyIi8+CjxwYXRoIGQ9Ik0xOSA4OS4xNjExTDE5NCA4OS4xNjEyIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC4xMiIvPgo8cGF0aCBkPSJNMTkgMTE4LjE2MUwxOTQgMTE4LjE2MSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiLz4KPHBhdGggZD0iTTI4IDI3QzI4IDI1Ljg5NTQgMjguODk1NCAyNSAzMCAyNUg1NkM1Ny4xMDQ2IDI1IDU4IDI1Ljg5NTQgNTggMjdWMTQ2SDI4VjI3WiIgZmlsbD0iIzA4ODcyQiIvPgo8cGF0aCBkPSJNNzAgNjlDNzAgNjcuODk1NCA3MC44OTU0IDY3IDcyIDY3SDk4Qzk5LjEwNDYgNjcgMTAwIDY3Ljg5NTQgMTAwIDY5VjE0Nkg3MFY2OVoiIGZpbGw9IiM0QjkzRkYiLz4KPHBhdGggZD0iTTExMiA5MkMxMTIgOTAuODk1NCAxMTIuODk1IDkwIDExNCA5MEgxNDBDMTQxLjEwNSA5MCAxNDIgOTAuODk1NCAxNDIgOTJWMTQ2SDExMlY5MloiIGZpbGw9IiNGRjRENUEiLz4KPHBhdGggZD0iTTE1NCA3OUMxNTQgNzcuODk1NCAxNTQuODk1IDc3IDE1NiA3N0gxODJDMTgzLjEwNSA3NyAxODQgNzcuODk1NCAxODQgNzlWMTQ2SDE1NFY3OVoiIGZpbGw9IiNGRkMxMDciLz4KPGxpbmUgeDE9IjE3LjIiIHkxPSIxNDUuOTYxIiB4Mj0iMTk2LjgiIHkyPSIxNDUuOTYxIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC43IiBzdHJva2Utd2lkdGg9IjAuNCIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8cGF0aCBkPSJNNDMuMTMyOCAxNC4xNzU4TDQxLjUxMTcgMTlINDAuMzc1TDQyLjUgMTMuMzEyNUg0My4yMjY2TDQzLjEzMjggMTQuMTc1OFpNNDQuNDg4MyAxOUw0Mi44NTk0IDE0LjE3NThMNDIuNzYxNyAxMy4zMTI1SDQzLjQ5MjJMNDUuNjI4OSAxOUg0NC40ODgzWk00NC40MTQxIDE2Ljg4NjdWMTcuNzM0NEg0MS4zNjMzVjE2Ljg4NjdINDQuNDE0MVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTg1LjE4MzYgNTguNDY0OEg4My43MzA1TDgzLjcyMjcgNTcuNzA3SDg0Ljk2ODhDODUuMTgyMyA1Ny43MDcgODUuMzYwNyA1Ny42NzcxIDg1LjUwMzkgNTcuNjE3MkM4NS42NDcxIDU3LjU1NzMgODUuNzU1MiA1Ny40NzAxIDg1LjgyODEgNTcuMzU1NUM4NS45MDM2IDU3LjI0MDkgODUuOTQxNCA1Ny4xMDE2IDg1Ljk0MTQgNTYuOTM3NUM4NS45NDE0IDU2Ljc1NTIgODUuOTA2MiA1Ni42MDY4IDg1LjgzNTkgNTYuNDkyMkM4NS43NjgyIDU2LjM3NzYgODUuNjYxNSA1Ni4yOTQzIDg1LjUxNTYgNTYuMjQyMkM4NS4zNjk4IDU2LjE4NzUgODUuMTg0OSA1Ni4xNjAyIDg0Ljk2MDkgNTYuMTYwMkg4NC4wNzgxVjYxSDgzLjAwMzlWNTUuMzEyNUg4NC45NjA5Qzg1LjI4MzkgNTUuMzEyNSA4NS41NzE2IDU1LjM0MzggODUuODI0MiA1NS40MDYyQzg2LjA3OTQgNTUuNDY2MSA4Ni4yOTU2IDU1LjU1OTkgODYuNDcyNyA1NS42ODc1Qzg2LjY0OTcgNTUuODE1MSA4Ni43ODM5IDU1Ljk3NTMgODYuODc1IDU2LjE2OEM4Ni45Njg4IDU2LjM2MDcgODcuMDE1NiA1Ni41ODk4IDg3LjAxNTYgNTYuODU1NUM4Ny4wMTU2IDU3LjA4OTggODYuOTYwOSA1Ny4zMDYgODYuODUxNiA1Ny41MDM5Qzg2Ljc0NDggNTcuNjk5MiA4Ni41Nzk0IDU3Ljg1ODEgODYuMzU1NSA1Ny45ODA1Qzg2LjEzNDEgNTguMTAyOSA4NS44NTI5IDU4LjE3MzIgODUuNTExNyA1OC4xOTE0TDg1LjE4MzYgNTguNDY0OFpNODUuMTM2NyA2MUg4My40MTQxTDgzLjg2MzMgNjAuMTU2Mkg4NS4xMzY3Qzg1LjM1MDMgNjAuMTU2MiA4NS41MjYgNjAuMTIxMSA4NS42NjQxIDYwLjA1MDhDODUuODA0NyA1OS45ODA1IDg1LjkwODkgNTkuODg0MSA4NS45NzY2IDU5Ljc2MTdDODYuMDQ2OSA1OS42MzY3IDg2LjA4MiA1OS40OTM1IDg2LjA4MiA1OS4zMzJDODYuMDgyIDU5LjE1NDkgODYuMDUwOCA1OS4wMDEzIDg1Ljk4ODMgNTguODcxMUM4NS45Mjg0IDU4Ljc0MDkgODUuODMyIDU4LjY0MDYgODUuNjk5MiA1OC41NzAzQzg1LjU2OSA1OC41IDg1LjM5NzEgNTguNDY0OCA4NS4xODM2IDU4LjQ2NDhIODQuMDY2NEw4NC4wNzQyIDU3LjcwN0g4NS40OTYxTDg1Ljc0MjIgNThDODYuMDcwMyA1OC4wMDI2IDg2LjMzNzIgNTguMDY3NyA4Ni41NDMgNTguMTk1M0M4Ni43NTEzIDU4LjMyMjkgODYuOTA0OSA1OC40ODcgODcuMDAzOSA1OC42ODc1Qzg3LjEwMjkgNTguODg4IDg3LjE1MjMgNTkuMTA0MiA4Ny4xNTIzIDU5LjMzNTlDODcuMTUyMyA1OS43MDA1IDg3LjA3MjkgNjAuMDA2NSA4Ni45MTQxIDYwLjI1MzlDODYuNzU3OCA2MC41MDEzIDg2LjUyODYgNjAuNjg3NSA4Ni4yMjY2IDYwLjgxMjVDODUuOTI3MSA2MC45Mzc1IDg1LjU2MzggNjEgODUuMTM2NyA2MVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTE2OC42MzcgNzJIMTY3LjQxTDE2Ny40MTggNzEuMTU2MkgxNjguNjM3QzE2OC45NjcgNzEuMTU2MiAxNjkuMjQ1IDcxLjA4MzMgMTY5LjQ2OSA3MC45Mzc1QzE2OS42OTMgNzAuNzg5MSAxNjkuODYyIDcwLjU3NjggMTY5Ljk3NyA3MC4zMDA4QzE3MC4wOTEgNzAuMDIyMSAxNzAuMTQ4IDY5LjY4ODggMTcwLjE0OCA2OS4zMDA4VjY5LjAwNzhDMTcwLjE0OCA2OC43MDgzIDE3MC4xMTYgNjguNDQ0IDE3MC4wNTEgNjguMjE0OEMxNjkuOTg2IDY3Ljk4NTcgMTY5Ljg4OSA2Ny43OTMgMTY5Ljc2MiA2Ny42MzY3QzE2OS42MzcgNjcuNDgwNSAxNjkuNDgyIDY3LjM2MiAxNjkuMjk3IDY3LjI4MTJDMTY5LjExMiA2Ny4yMDA1IDE2OC45IDY3LjE2MDIgMTY4LjY2IDY3LjE2MDJIMTY3LjM4N1Y2Ni4zMTI1SDE2OC42NkMxNjkuMDQgNjYuMzEyNSAxNjkuMzg3IDY2LjM3NjMgMTY5LjY5OSA2Ni41MDM5QzE3MC4wMTQgNjYuNjMxNSAxNzAuMjg2IDY2LjgxNTEgMTcwLjUxNiA2Ny4wNTQ3QzE3MC43NDcgNjcuMjkxNyAxNzAuOTI0IDY3LjU3NTUgMTcxLjA0NyA2Ny45MDYyQzE3MS4xNzIgNjguMjM3IDE3MS4yMzQgNjguNjA2OCAxNzEuMjM0IDY5LjAxNTZWNjkuMzAwOEMxNzEuMjM0IDY5LjcwNyAxNzEuMTcyIDcwLjA3NjggMTcxLjA0NyA3MC40MTAyQzE3MC45MjQgNzAuNzQwOSAxNzAuNzQ3IDcxLjAyNDcgMTcwLjUxNiA3MS4yNjE3QzE3MC4yODYgNzEuNDk4NyAxNzAuMDEzIDcxLjY4MSAxNjkuNjk1IDcxLjgwODZDMTY5LjM3OCA3MS45MzYyIDE2OS4wMjUgNzIgMTY4LjYzNyA3MlpNMTY4LjAxMiA2Ni4zMTI1VjcySDE2Ni45MzhWNjYuMzEyNUgxNjguMDEyWiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC43NiIvPgo8cGF0aCBkPSJNMTI4LjI1NCA4Mi4xMjg5SDEyOS4zMjhDMTI5LjI5OSA4Mi41MDkxIDEyOS4xOTQgODIuODQ2NCAxMjkuMDEyIDgzLjE0MDZDMTI4LjgyOSA4My40MzIzIDEyOC41NzYgODMuNjYxNSAxMjguMjUgODMuODI4MUMxMjcuOTI0IDgzLjk5NDggMTI3LjUzIDg0LjA3ODEgMTI3LjA2NiA4NC4wNzgxQzEyNi43MSA4NC4wNzgxIDEyNi4zODggODQuMDE1NiAxMjYuMTAyIDgzLjg5MDZDMTI1LjgxOCA4My43NjMgMTI1LjU3NCA4My41ODIgMTI1LjM3MSA4My4zNDc3QzEyNS4xNzEgODMuMTEwNyAxMjUuMDE3IDgyLjgyNjggMTI0LjkxIDgyLjQ5NjFDMTI0LjgwMyA4Mi4xNjI4IDEyNC43NSA4MS43ODkxIDEyNC43NSA4MS4zNzVWODAuOTQxNEMxMjQuNzUgODAuNTI3MyAxMjQuODA1IDgwLjE1MzYgMTI0LjkxNCA3OS44MjAzQzEyNS4wMjMgNzkuNDg3IDEyNS4xOCA3OS4yMDMxIDEyNS4zODMgNzguOTY4OEMxMjUuNTg5IDc4LjczMTggMTI1LjgzNSA3OC41NDk1IDEyNi4xMjEgNzguNDIxOUMxMjYuNDEgNzguMjk0MyAxMjYuNzMzIDc4LjIzMDUgMTI3LjA5IDc4LjIzMDVDMTI3LjU1MyA3OC4yMzA1IDEyNy45NDUgNzguMzE2NCAxMjguMjY2IDc4LjQ4ODNDMTI4LjU4NiA3OC42NTc2IDEyOC44MzUgNzguODkwNiAxMjkuMDEyIDc5LjE4NzVDMTI5LjE4OSA3OS40ODQ0IDEyOS4yOTYgNzkuODI1NSAxMjkuMzMyIDgwLjIxMDlIMTI4LjI1OEMxMjguMjM3IDc5Ljk3MTQgMTI4LjE4NSA3OS43NjgyIDEyOC4xMDIgNzkuNjAxNkMxMjguMDIxIDc5LjQzNDkgMTI3Ljg5OCA3OS4zMDg2IDEyNy43MzQgNzkuMjIyN0MxMjcuNTczIDc5LjEzNDEgMTI3LjM1OCA3OS4wODk4IDEyNy4wOSA3OS4wODk4QzEyNi44ODIgNzkuMDg5OCAxMjYuNjk4IDc5LjEyODkgMTI2LjUzOSA3OS4yMDdDMTI2LjM4MyA3OS4yODUyIDEyNi4yNTMgNzkuNDAyMyAxMjYuMTQ4IDc5LjU1ODZDMTI2LjA0NCA3OS43MTIyIDEyNS45NjYgNzkuOTA0OSAxMjUuOTE0IDgwLjEzNjdDMTI1Ljg2MiA4MC4zNjU5IDEyNS44MzYgODAuNjMxNSAxMjUuODM2IDgwLjkzMzZWODEuMzc1QzEyNS44MzYgODEuNjY0MSAxMjUuODU5IDgxLjkyMzIgMTI1LjkwNiA4Mi4xNTIzQzEyNS45NTMgODIuMzgxNSAxMjYuMDI2IDgyLjU3NTUgMTI2LjEyNSA4Mi43MzQ0QzEyNi4yMjQgODIuODkzMiAxMjYuMzUyIDgzLjAxNDMgMTI2LjUwOCA4My4wOTc3QzEyNi42NjQgODMuMTgxIDEyNi44NSA4My4yMjI3IDEyNy4wNjYgODMuMjIyN0MxMjcuMzI5IDgzLjIyMjcgMTI3LjU0MyA4My4xODEgMTI3LjcwNyA4My4wOTc3QzEyNy44NzQgODMuMDE0MyAxMjggODIuODkxOSAxMjguMDg2IDgyLjczMDVDMTI4LjE3NCA4Mi41NjkgMTI4LjIzIDgyLjM2ODUgMTI4LjI1NCA4Mi4xMjg5WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC43NiIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzQ2MjRfMzgwMjIiPgo8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgZmlsbD0id2hpdGUiLz4KPC9jbGlwUGF0aD4KPC9kZWZzPgo8L3N2Zz4K", - "description": "Displays the latest values of the attributes or time-series data in a bar chart. Supports numeric values only.", + "description": "Displays the latest values of the attributes or time series data in a bar chart. Supports numeric values only.", "descriptor": { "type": "latest", "sizeX": 5, diff --git a/application/src/main/data/json/system/widget_types/bars_deprecated.json b/application/src/main/data/json/system/widget_types/bars_deprecated.json index 6324f9d7b2..b3d4273439 100644 --- a/application/src/main/data/json/system/widget_types/bars_deprecated.json +++ b/application/src/main/data/json/system/widget_types/bars_deprecated.json @@ -3,7 +3,7 @@ "name": "Bars", "deprecated": true, "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAA8FBMVEUhlvNMr1Bqamp5eXl7e3t8fHx9fX1+fn5/f3+AgICCgoKDg4OEhISGhoaHh4eKioqMjIyNjY2Ojo6QkJCRkZGSkpKWlpaXl5ebm5udnZ2enp6goKChoaGkpKSnp6epqamsrKyurq6xsbGzs7O1tbW2tra3t7e4uLi7u7u9vb3BwcHCwsLDw8PGxsbKysrNzc3Ozs7R0dHS0tLT09PZ2dna2trc3Nzd3d3e3t7g4ODh4eHj4+Pk5OTm5ubn5+fo6Ojp6enu7u7w8PDz8/P0Qzb09PT29vb39/f5+fn6+vr7+/v8/Pz9/f3+/v7/wQf///+dc+aLAAAAAWJLR0RPbmZBSQAAAcFJREFUeNrt3ds2AgEYhuHsaSOZbAvZi0r2YYjCJOW7/7txZhkcDNbM6h/vdwfPmlX/ybtmEorJErGCeJLadz3rkKPpZamaLTp925DHdFvSpKelU9uQ/cLKhtcdk7YqtiHruevtojch7ZZtQ0o1dcdfRqXNqm1IbVU3OaVamm/YhvQW5zIXOknnC5JUt7qEpE5fUv/5HVePy2UHAgQIECBAgAABAgQIECBxgrwGHBAgQIAAAQIECBAgQIAAAQIECJC/QRIBN0iQ+66voDMLuRp2fQWdVUhvNun6CjqrkJ0Dx/UVdEYhzXzfcX0FnVFIrlSZ2mx/LOiMQuqHh6k972NBZ/fv13G/KeiCQkIu4358EL8UdBafSGwuOxAgQIAAAQIECJDYQB4CDggQIECAAAECBAgQIECAAAECBAgQIECA/BrSufn0DjqjkEZmLXkWaUEXEmThXMeFSAu68H4j5b1IC7rQILfZTqQFXViQ1nRTL1EWdCFBnmYuJUVZ0IUEWR1xHKcXZUEXFDLwBR2XHQgQIECAAAEC5H9ChgIOCBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgxiBmv+L6Bl9pkxYph15gAAAAAElFTkSuQmCC", - "description": "Displays latest values of the attributes or time-series data for multiple entities as separate bars.", + "description": "Displays latest values of the attributes or time series data for multiple entities as separate bars.", "descriptor": { "type": "latest", "sizeX": 7, diff --git a/application/src/main/data/json/system/widget_types/command_button.json b/application/src/main/data/json/system/widget_types/command_button.json index dcfeb108f2..1b3082d8ae 100644 --- a/application/src/main/data/json/system/widget_types/command_button.json +++ b/application/src/main/data/json/system/widget_types/command_button.json @@ -3,7 +3,7 @@ "name": "Command button", "deprecated": false, "image": "tb-image:Y29tbWFuZC1idXR0b24uc3Zn:IkNvbW1hbmQgYnV0dG9uIiBzeXN0ZW0gd2lkZ2V0IGltYWdl;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHg9IjAuNzUiIHk9IjUwLjc1IiB3aWR0aD0iMTk4LjUiIGhlaWdodD0iNTguNSIgcng9IjMuMjUiIGZpbGw9IndoaXRlIi8+CjxyZWN0IHg9IjAuNzUiIHk9IjUwLjc1IiB3aWR0aD0iMTk4LjUiIGhlaWdodD0iNTguNSIgcng9IjMuMjUiIHN0cm9rZT0iIzNGNTJERCIgc3Ryb2tlLXdpZHRoPSIxLjUiLz4KPHBhdGggZD0iTTY1LjQ5OTcgNzNWNzUuMzMzM0g3NS41MjEzTDY0LjMzMyA4Ni41MjE3TDY1Ljk3OCA4OC4xNjY3TDc3LjE2NjMgNzYuOTc4M1Y4N0g3OS40OTk3VjczSDY1LjQ5OTdaIiBmaWxsPSIjM0Y1MkREIi8+CjxwYXRoIGQ9Ik0xMDAuNTY0IDgzLjk3MTdDMTAwLjU2NCA4My42NDk0IDEwMC41MTQgODMuMzYzIDEwMC40MTQgODMuMTEyM0MxMDAuMzIxIDgyLjg2MTcgMTAwLjE1MyA4Mi42MzI1IDk5LjkwOTIgODIuNDI0OEM5OS42NjU3IDgyLjIxNzEgOTkuMzIxOSA4Mi4wMTY2IDk4Ljg3NzkgODEuODIzMkM5OC40NDExIDgxLjYyMjcgOTcuODgyNSA4MS40MTg2IDk3LjIwMjEgODEuMjEwOUM5Ni40NTc0IDgwLjk4MTggOTUuNzY5OSA4MC43Mjc1IDk1LjEzOTYgODAuNDQ4MkM5NC41MTY2IDgwLjE2MTggOTMuOTcyMyA3OS44MzI0IDkzLjUwNjggNzkuNDZDOTMuMDQxMyA3OS4wODA0IDkyLjY3OTcgNzguNjQ3MSA5Mi40MjE5IDc4LjE2MDJDOTIuMTY0MSA3Ny42NjYgOTIuMDM1MiA3Ny4wOTY3IDkyLjAzNTIgNzYuNDUyMUM5Mi4wMzUyIDc1LjgxNDggOTIuMTY3NiA3NS4yMzQ3IDkyLjQzMjYgNzQuNzExOUM5Mi43MDQ4IDc0LjE4OTEgOTMuMDg3OSA3My43MzggOTMuNTgyIDczLjM1ODRDOTQuMDgzMyA3Mi45NzE3IDk0LjY3NDIgNzIuNjc0NSA5NS4zNTQ1IDcyLjQ2NjhDOTYuMDM0OCA3Mi4yNTIgOTYuNzg2OCA3Mi4xNDQ1IDk3LjYxMDQgNzIuMTQ0NUM5OC43NzA1IDcyLjE0NDUgOTkuNzY5NSA3Mi4zNTk0IDEwMC42MDcgNzIuNzg5MUMxMDEuNDUyIDczLjIxODggMTAyLjEwMSA3My43OTUyIDEwMi41NTIgNzQuNTE4NkMxMDMuMDEgNzUuMjQxOSAxMDMuMjM5IDc2LjA0MDQgMTAzLjIzOSA3Ni45MTQxSDEwMC41NjRDMTAwLjU2NCA3Ni4zOTg0IDEwMC40NTMgNzUuOTQzNyAxMDAuMjMxIDc1LjU0OThDMTAwLjAxNyA3NS4xNDg4IDk5LjY4NzIgNzQuODMzNyA5OS4yNDMyIDc0LjYwNDVDOTguODA2MyA3NC4zNzUzIDk4LjI1MTMgNzQuMjYwNyA5Ny41NzgxIDc0LjI2MDdDOTYuOTQwOCA3NC4yNjA3IDk2LjQxMDggNzQuMzU3NCA5NS45ODgzIDc0LjU1MDhDOTUuNTY1OCA3NC43NDQxIDk1LjI1MDcgNzUuMDA1NSA5NS4wNDMgNzUuMzM1Qzk0LjgzNTMgNzUuNjY0NCA5NC43MzE0IDc2LjAzNjggOTQuNzMxNCA3Ni40NTIxQzk0LjczMTQgNzYuNzQ1OCA5NC43OTk1IDc3LjAxNDMgOTQuOTM1NSA3Ny4yNTc4Qzk1LjA3MTYgNzcuNDk0MSA5NS4yNzkzIDc3LjcxNjEgOTUuNTU4NiA3Ny45MjM4Qzk1LjgzNzkgNzguMTI0MyA5Ni4xODg4IDc4LjMxNDEgOTYuNjExMyA3OC40OTMyQzk3LjAzMzkgNzguNjcyMiA5Ny41MzE2IDc4Ljg0NDEgOTguMTA0NSA3OS4wMDg4Qzk4Ljk3MSA3OS4yNjY2IDk5LjcyNjYgNzkuNTUzMSAxMDAuMzcxIDc5Ljg2ODJDMTAxLjAxNiA4MC4xNzYxIDEwMS41NTMgODAuNTI3IDEwMS45ODIgODAuOTIwOUMxMDIuNDEyIDgxLjMxNDggMTAyLjczNCA4MS43NjI0IDEwMi45NDkgODIuMjYzN0MxMDMuMTY0IDgyLjc1NzggMTAzLjI3MSA4My4zMiAxMDMuMjcxIDgzLjk1MDJDMTAzLjI3MSA4NC42MDkgMTAzLjEzOSA4NS4yMDM1IDEwMi44NzQgODUuNzMzNEMxMDIuNjA5IDg2LjI1NjIgMTAyLjIyOSA4Ni43MDM4IDEwMS43MzUgODcuMDc2MkMxMDEuMjQ4IDg3LjQ0MTQgMTAwLjY2MSA4Ny43MjQzIDk5Ljk3MzYgODcuOTI0OEM5OS4yOTMzIDg4LjExODIgOTguNTM0MiA4OC4yMTQ4IDk3LjY5NjMgODguMjE0OEM5Ni45NDQzIDg4LjIxNDggOTYuMjAzMSA4OC4xMTQ2IDk1LjQ3MjcgODcuOTE0MUM5NC43NDkzIDg3LjcxMzUgOTQuMDkwNSA4Ny40MDkyIDkzLjQ5NjEgODcuMDAxQzkyLjkwMTcgODYuNTg1NiA5Mi40MjkgODYuMDcgOTIuMDc4MSA4NS40NTQxQzkxLjcyNzIgODQuODMxMSA5MS41NTE4IDg0LjEwNDIgOTEuNTUxOCA4My4yNzM0SDk0LjI0OEM5NC4yNDggODMuNzgxOSA5NC4zMzQgODQuMjE1MiA5NC41MDU5IDg0LjU3MzJDOTQuNjg0OSA4NC45MzEzIDk0LjkzMiA4NS4yMjQ5IDk1LjI0NzEgODUuNDU0MUM5NS41NjIyIDg1LjY3NjEgOTUuOTI3NCA4NS44NDA4IDk2LjM0MjggODUuOTQ4MkM5Ni43NjUzIDg2LjA1NTcgOTcuMjE2NSA4Ni4xMDk0IDk3LjY5NjMgODYuMTA5NEM5OC4zMjY1IDg2LjEwOTQgOTguODUyOSA4Ni4wMTk5IDk5LjI3NTQgODUuODQwOEM5OS43MDUxIDg1LjY2MTggMTAwLjAyNyA4NS40MTExIDEwMC4yNDIgODUuMDg4OUMxMDAuNDU3IDg0Ljc2NjYgMTAwLjU2NCA4NC4zOTQyIDEwMC41NjQgODMuOTcxN1pNMTEwLjc3MiA4OC4yMTQ4QzEwOS45MTMgODguMjE0OCAxMDkuMTM2IDg4LjA3NTIgMTA4LjQ0MSA4Ny43OTU5QzEwNy43NTQgODcuNTA5NCAxMDcuMTY3IDg3LjExMiAxMDYuNjggODYuNjAzNUMxMDYuMiA4Ni4wOTUxIDEwNS44MzEgODUuNDk3MSAxMDUuNTczIDg0LjgwOTZDMTA1LjMxNSA4NC4xMjIxIDEwNS4xODcgODMuMzgwOSAxMDUuMTg3IDgyLjU4NTlWODIuMTU2MkMxMDUuMTg3IDgxLjI0NjcgMTA1LjMxOSA4MC40MjMyIDEwNS41ODQgNzkuNjg1NUMxMDUuODQ5IDc4Ljk0NzkgMTA2LjIxOCA3OC4zMTc3IDEwNi42OSA3Ny43OTQ5QzEwNy4xNjMgNzcuMjY1IDEwNy43MjIgNzYuODYwNCAxMDguMzY2IDc2LjU4MTFDMTA5LjAxMSA3Ni4zMDE4IDEwOS43MDkgNzYuMTYyMSAxMTAuNDYxIDc2LjE2MjFDMTExLjI5MiA3Ni4xNjIxIDExMi4wMTkgNzYuMzAxOCAxMTIuNjQyIDc2LjU4MTFDMTEzLjI2NSA3Ni44NjA0IDExMy43OCA3Ny4yNTQyIDExNC4xODggNzcuNzYyN0MxMTQuNjA0IDc4LjI2NCAxMTQuOTEyIDc4Ljg2MiAxMTUuMTEyIDc5LjU1NjZDMTE1LjMyIDgwLjI1MTMgMTE1LjQyNCA4MS4wMTc2IDExNS40MjQgODEuODU1NVY4Mi45NjE5SDEwNi40NDNWODEuMTAzNUgxMTIuODY3VjgwLjg5OTRDMTEyLjg1MyA4MC40MzM5IDExMi43NiA3OS45OTcxIDExMi41ODggNzkuNTg4OUMxMTIuNDIzIDc5LjE4MDcgMTEyLjE2OSA3OC44NTEyIDExMS44MjUgNzguNjAwNkMxMTEuNDgxIDc4LjM0OTkgMTExLjAyMyA3OC4yMjQ2IDExMC40NSA3OC4yMjQ2QzExMC4wMjEgNzguMjI0NiAxMDkuNjM3IDc4LjMxNzcgMTA5LjMwMSA3OC41MDM5QzEwOC45NzEgNzguNjgyOSAxMDguNjk2IDc4Ljk0NDMgMTA4LjQ3NCA3OS4yODgxQzEwOC4yNTIgNzkuNjMxOCAxMDguMDggODAuMDQ3MiAxMDcuOTU4IDgwLjUzNDJDMTA3Ljg0MyA4MS4wMTQgMTA3Ljc4NiA4MS41NTQ3IDEwNy43ODYgODIuMTU2MlY4Mi41ODU5QzEwNy43ODYgODMuMDk0NCAxMDcuODU0IDgzLjU2NzEgMTA3Ljk5IDg0LjAwMzlDMTA4LjEzMyA4NC40MzM2IDEwOC4zNDEgODQuODA5NiAxMDguNjEzIDg1LjEzMThDMTA4Ljg4NSA4NS40NTQxIDEwOS4yMTUgODUuNzA4MyAxMDkuNjAyIDg1Ljg5NDVDMTA5Ljk4OCA4Ni4wNzM2IDExMC40MjkgODYuMTYzMSAxMTAuOTIzIDg2LjE2MzFDMTExLjU0NiA4Ni4xNjMxIDExMi4xMDEgODYuMDM3OCAxMTIuNTg4IDg1Ljc4NzFDMTEzLjA3NSA4NS41MzY1IDExMy40OTcgODUuMTgyIDExMy44NTUgODQuNzIzNkwxMTUuMjIgODYuMDQ0OUMxMTQuOTY5IDg2LjQxMDIgMTE0LjY0MyA4Ni43NjExIDExNC4yNDIgODcuMDk3N0MxMTMuODQxIDg3LjQyNzEgMTEzLjM1MSA4Ny42OTU2IDExMi43NzEgODcuOTAzM0MxMTIuMTk4IDg4LjExMSAxMTEuNTMyIDg4LjIxNDggMTEwLjc3MiA4OC4yMTQ4Wk0xMjAuMjYxIDc4Ljg1ODRWODhIMTE3LjY3MlY3Ni4zNzdIMTIwLjExTDEyMC4yNjEgNzguODU4NFpNMTE5Ljc5OSA4MS43NTg4TDExOC45NjEgODEuNzQ4QzExOC45NjggODAuOTI0NSAxMTkuMDgzIDgwLjE2ODkgMTE5LjMwNSA3OS40ODE0QzExOS41MzQgNzguNzkzOSAxMTkuODQ5IDc4LjIwMzEgMTIwLjI1IDc3LjcwOUMxMjAuNjU4IDc3LjIxNDggMTIxLjE0NSA3Ni44MzUzIDEyMS43MTEgNzYuNTcwM0MxMjIuMjc3IDc2LjI5ODIgMTIyLjkwNyA3Ni4xNjIxIDEyMy42MDIgNzYuMTYyMUMxMjQuMTYgNzYuMTYyMSAxMjQuNjY1IDc2LjI0MDkgMTI1LjExNiA3Ni4zOTg0QzEyNS41NzUgNzYuNTQ4OCAxMjUuOTY1IDc2Ljc5NTkgMTI2LjI4NyA3Ny4xMzk2QzEyNi42MTcgNzcuNDgzNCAxMjYuODY3IDc3LjkzMSAxMjcuMDM5IDc4LjQ4MjRDMTI3LjIxMSA3OS4wMjY3IDEyNy4yOTcgNzkuNjk2MyAxMjcuMjk3IDgwLjQ5MTJWODhIMTI0LjY5N1Y4MC40ODA1QzEyNC42OTcgNzkuOTIxOSAxMjQuNjE1IDc5LjQ4MTQgMTI0LjQ1IDc5LjE1OTJDMTI0LjI5MyA3OC44Mjk4IDEyNC4wNiA3OC41OTcgMTIzLjc1MiA3OC40NjA5QzEyMy40NTEgNzguMzE3NyAxMjMuMDc1IDc4LjI0NjEgMTIyLjYyNCA3OC4yNDYxQzEyMi4xOCA3OC4yNDYxIDEyMS43ODMgNzguMzM5MiAxMjEuNDMyIDc4LjUyNTRDMTIxLjA4MSA3OC43MTE2IDEyMC43ODQgNzguOTY1OCAxMjAuNTQgNzkuMjg4MUMxMjAuMzA0IDc5LjYxMDQgMTIwLjEyMSA3OS45ODI3IDExOS45OTIgODAuNDA1M0MxMTkuODYzIDgwLjgyNzggMTE5Ljc5OSA4MS4yNzkgMTE5Ljc5OSA4MS43NTg4Wk0xMzcuMjc5IDg1LjU5MzhWNzEuNUgxMzkuODc5Vjg4SDEzNy41MjZMMTM3LjI3OSA4NS41OTM4Wk0xMjkuNzE3IDgyLjMxNzRWODIuMDkxOEMxMjkuNzE3IDgxLjIxMDkgMTI5LjgyMSA4MC40MDg5IDEzMC4wMjggNzkuNjg1NUMxMzAuMjM2IDc4Ljk1NTEgMTMwLjUzNyA3OC4zMjg1IDEzMC45MzEgNzcuODA1N0MxMzEuMzI1IDc3LjI3NTcgMTMxLjgwNCA3Ni44NzExIDEzMi4zNyA3Ni41OTE4QzEzMi45MzYgNzYuMzA1MyAxMzMuNTczIDc2LjE2MjEgMTM0LjI4MiA3Ni4xNjIxQzEzNC45ODQgNzYuMTYyMSAxMzUuNiA3Ni4yOTgyIDEzNi4xMyA3Ni41NzAzQzEzNi42NiA3Ni44NDI0IDEzNy4xMTEgNzcuMjMyNyAxMzcuNDgzIDc3Ljc0MTJDMTM3Ljg1NiA3OC4yNDI1IDEzOC4xNTMgNzguODQ0MSAxMzguMzc1IDc5LjU0NTlDMTM4LjU5NyA4MC4yNDA2IDEzOC43NTUgODEuMDE0IDEzOC44NDggODEuODY2MlY4Mi41ODU5QzEzOC43NTUgODMuNDE2NyAxMzguNTk3IDg0LjE3NTggMTM4LjM3NSA4NC44NjMzQzEzOC4xNTMgODUuNTUwOCAxMzcuODU2IDg2LjE0NTIgMTM3LjQ4MyA4Ni42NDY1QzEzNy4xMTEgODcuMTQ3OCAxMzYuNjU2IDg3LjUzNDUgMTM2LjExOSA4Ny44MDY2QzEzNS41ODkgODguMDc4OCAxMzQuOTcgODguMjE0OCAxMzQuMjYxIDg4LjIxNDhDMTMzLjU1OSA4OC4yMTQ4IDEzMi45MjUgODguMDY4IDEzMi4zNTkgODcuNzc0NEMxMzEuODAxIDg3LjQ4MDggMTMxLjMyNSA4Ny4wNjkgMTMwLjkzMSA4Ni41MzkxQzEzMC41MzcgODYuMDA5MSAxMzAuMjM2IDg1LjM4NjEgMTMwLjAyOCA4NC42Njk5QzEyOS44MjEgODMuOTQ2NiAxMjkuNzE3IDgzLjE2MjQgMTI5LjcxNyA4Mi4zMTc0Wk0xMzIuMzA2IDgyLjA5MThWODIuMzE3NEMxMzIuMzA2IDgyLjg0NzMgMTMyLjM1MiA4My4zNDE1IDEzMi40NDUgODMuNzk5OEMxMzIuNTQ2IDg0LjI1ODEgMTMyLjcgODQuNjYyOCAxMzIuOTA3IDg1LjAxMzdDMTMzLjExNSA4NS4zNTc0IDEzMy4zODMgODUuNjI5NiAxMzMuNzEzIDg1LjgzMDFDMTM0LjA0OSA4Ni4wMjM0IDEzNC40NTEgODYuMTIwMSAxMzQuOTE2IDg2LjEyMDFDMTM1LjUwMyA4Ni4xMjAxIDEzNS45ODcgODUuOTkxMiAxMzYuMzY2IDg1LjczMzRDMTM2Ljc0NiA4NS40NzU2IDEzNy4wNDMgODUuMTI4MyAxMzcuMjU4IDg0LjY5MTRDMTM3LjQ4IDg0LjI0NzQgMTM3LjYzIDgzLjc1MzMgMTM3LjcwOSA4My4yMDlWODEuMjY0NkMxMzcuNjY2IDgwLjg0MjEgMTM3LjU3NiA4MC40NDgyIDEzNy40NCA4MC4wODNDMTM3LjMxMiA3OS43MTc4IDEzNy4xMzYgNzkuMzk5MSAxMzYuOTE0IDc5LjEyN0MxMzYuNjkyIDc4Ljg0NzcgMTM2LjQxNiA3OC42MzI4IDEzNi4wODcgNzguNDgyNEMxMzUuNzY1IDc4LjMyNDkgMTM1LjM4MiA3OC4yNDYxIDEzNC45MzggNzguMjQ2MUMxMzQuNDY1IDc4LjI0NjEgMTM0LjA2NCA3OC4zNDY0IDEzMy43MzQgNzguNTQ2OUMxMzMuNDA1IDc4Ljc0NzQgMTMzLjEzMyA3OS4wMjMxIDEzMi45MTggNzkuMzc0QzEzMi43MSA3OS43MjQ5IDEzMi41NTYgODAuMTMzMSAxMzIuNDU2IDgwLjU5ODZDMTMyLjM1NiA4MS4wNjQxIDEzMi4zMDYgODEuNTYxOCAxMzIuMzA2IDgyLjA5MThaIiBmaWxsPSIjM0Y1MkREIi8+Cjwvc3ZnPgo=", - "description": "Allows single-click commands to devices or updates to attributes/time-series. Settings enable definition of the on-click action and condition when the button is disabled. Supports multiple layouts and custom styles for different states.", + "description": "Allows single-click commands to devices or updates to attributes/time series. Settings enable definition of the on-click action and condition when the button is disabled. Supports multiple layouts and custom styles for different states.", "descriptor": { "type": "rpc", "sizeX": 3, diff --git a/application/src/main/data/json/system/widget_types/compass.json b/application/src/main/data/json/system/widget_types/compass.json index c74108d034..79cbe781fc 100644 --- a/application/src/main/data/json/system/widget_types/compass.json +++ b/application/src/main/data/json/system/widget_types/compass.json @@ -3,7 +3,7 @@ "name": "Compass", "deprecated": false, "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAABIFBMVEUiIiL////g4OAlJSV3d3fx8fEoKCgxMTErKytMTExHR0cuLi56enpJSUk+Pj50dHROTk5jY2NxcXE0NDRubm5paWnm5uY6Ojr5+fn+/v7i4uJmZmZSUlKCgoJsbGw4ODh9fX1UVFRdXV29vb2AgIBgYGBZWVk2Njbu7u7s7Oxzc3Pq6urd3d15eXlEREQ8PDz7+/v09PSHh4dbW1vk5OSTk5Ozs7NAQEDo6OicnJzQ0NDHx8dBQUHAwMDMzMy2trarq6ulpaX29vbwgICBgYHY2NiPj49WVlbExMSioqKKioqurq6oqKiXl5fz8/Py8vLU1NS4uLiSkpL4+PjdeHifW1uQVFRONja6urrQcnKET09gPj5BMDDFbGxXOjptRES1ySx3AAAN8UlEQVR42syaaVfbRhRAbz2yFsuWvGLAu/ECBLPYAUIglEBSoClJSNPmdP///6LMyLbitF5Uibj38I2Dnq7fe/NmxvDN47C2evlu88mzlafr67EH1tefrjx7svnucnXtm8fhEUTWLuNPnsam8vRJ/DJym8hF1g62Jhym2mwdRCYTvchqfCUWgJX4aviYEYsoi4lU7Jy+OawXOtaeYZtg2sae1SnU796c7kwkJpxL9CJrB88+c9j4kE+ZTMG08h82PrNZeRuixiIW2d9cj43Y6F+bzMW87m/ERqxv7ocIHp3I6pNxKi6qORamW70YJ+ZJ2AoLL+JrHFUN/oEAA8vCAMEk+s+fjPxRbMizECrhRXyN94e7TFIzyYCGcNE0XIEGGcwaI367+gPY6z8Pm5XwImubMY8bVwefImaJtMW2jgaaFNGUUxYrTcmkCPDT1a9IROFFzGMrRNuHEXm3Pqypa/ApC3qgkSvRLk6KFNuUcmjQQ5TFx6vfGdI5Grb927lBoxfZHy643w7wyZVpJMib3otb6UmRtOU5mnlSjd+ufsGncT9cjAPWV3iReExx2sQnD0n0bRJlsjoatQyuGImIbTJdNPSsdHVNWVl5fNqnMUV8RtDoRfa9vcjrVzojGkUKOTSdJMKlnPD6vGSMRIySZ5UqowmSsrJyBYoNRgj3vZeUAFMlrMhbrzt+6DIig+5i50lckzdIYhdkUpo4NkUpUsR2aMp0VNWvq39efSRvSPGMX5i3XqccTAkbtcjalor3vMAQS0cz6em0IInTxNWxEAyRIiMEliy+pvPT1c/U0TVMF91iyODcW4nnLV/hRfyyemngYVAskKviZOgk0ARN32FSxLdp8vHqU6JDxqGao7DH+GEvA5RXOJHLddUdLiMqgqROXeZDaOzZALNFgE8PlZUU1BFJ9CSiwojCa1VeP05GjV7kICZ5cYxHIiEz0S0M81EDFhL5+aGyagxzUqN0LJ/kcXajQkw0SvQica+sbBRFLx+m/GkJYbMwv1x9Amwh+6SH2VM5KaIwb+etw+FFvD1JnSGZFE4BIytbxBAsjKoshTDI19g2KDgk2gxpqTCbftyoRdRytVMaacArHTdHvotDAGRl/cQIR30MWZWT8aNVo2zJmBGL+B7P0ygEnTQ5F72ObjED53uB63xZWX/iY+m0dLQc1njBK78PasI3AT12URgatAwyFg2HmaTPs5xMqv5+9VFMyjZkYRl10AwUZ+fKJGIRvz/OzzwNQbqNWVHFNYf0y6PaFyK/ysqaRJfPMmmWEZ7KsTLZjF4krjz2UORakN1lt4Qt5oqclE++EPnrj9/4AmFTSHCch3oXxZ4yiUctcqDqysuHo7NbhZZNW8B8Ee5uLOYi2tivBNkEuuOFea7mSbQiP070R1/QaWJWBCwkknthsQCiYtIZICoGioTq+B+jFNlfl+tuGUndIlcXVFMYRIxBogDJGlYdSXpH7lb2oxNZU/vEEorURhonCa4gcoQL2pkKocioHeRaZCJb/jx3DBUmkeWR2E6pAIaD5JXa1Ucl8lbtr1Do/aIKZJk8CqalHt/tmyhuF214FmyQGxPA6CKSxyrUo6FKty7oGkpM3RWtRiIiG+T1MRJRT4CbekwTVbg9wVlLR3L2erE2YbFJWEViQGkAWevxTFTZZqGTBQNJVs3F8CKrfoMIrexFMPFNovfAhFITyppA8nKh4mKRwjo38Gi6gpTrhezzCPTTgKpeqm08DDnhn4UVeRd7oA14n9JxxUbw6AjMV7vQzPjT5G04kbV1eX+FR7piYlcMvgJGxcZMlmFcXOtrYUTUKHzfBShm5OMTCEac9QXa3ve60SdRIATisEankzg8PEwxQrDbNyBTBKi9nr+jZ36nJ1EkKl0otBlTPs9zmzpJDc6NXjWUyPkJmju4q9VMxrQLYLyyUNRVv4cQeSLvqXWlAfp2BgS+yMOJ6TblVvsfBieJUCKnd4MHkZeDDj4COnVdBQb9dO5OhbkJaSLZbTmQqtj4lE86J7cp67uXu/1vRTiR7lHLHVxks+BjV1JQbKX8fl/9zyLyK5B7PEQhaaKLCRHublLmxqE4uiWcCNUbd/AdEwgdPbst8LiflxLmJeQawGgdQ67VgEmR7k2KH0ocJsOKiAt38OLi4prPabRycNwyADrzUsKcDjlCIdp1Axy+Kg7YbkGg+NZPSVCR/XFCrCLoWc3kK6NXeyYUrXFK9gOK+Pc/L5CIUqUIXYuvjNWFbjKvI3kxe5Ywe6gX8BCZeo0lkHOrOh7V2eOdmRdAz3WAjJYDs+rw1XGqJuS0DID+fOZZkZnb3j4Ks5B0WBLF7ayN4vuZm2Bmrr0OQMMEkWkZLAGjlRFgNgCcme3OrFY/Gn4myWMQgiUghIxfd5AcyaNiYJGn4wMuiE6lLVgKolEv6Xjk5ek9oIiqrB1D/XnBABI2S8FOAEY1D2DsyOkeUCTuH6hyhVZbZ2mIRm+7huJiRm0xY82qKg2AhFZjSdR6CYCcX1uBRNZiD3QBykktwZJJaN6ptxt7YC2IiJqGG3jojaS2x9LY05INHcbblINAIlujaVhWDxE2S8MW6sMsj2biVhARtfg2AMRuXsvvslRqJU1Li9EW+GkAEdUiOyZD7GaTJdJs2gyxd2STBBC5HLVIMZlPCJaOSOWTRQD5b8+XAUTkFPmAwmxoye2luojtpNYwURxOnSRMPeRWlQYSnaWiM36V/NQDL1N7PQVQ6vWyZYMlY5SzvV4JwJra7cztdT1VtVgqViGl42FOHYlM2zGeAtTSOf4n5NI1gNNp+0amLVpvAEyrpPW0rGCpiOzDS5QsE+DNtGWLaV+KHCL5P/T6xAvcPbzau4VFNkdfq2e226nu/2GOdFPt7QxAa9qlENNW3wISvZjOuE2WTNPNpIs6ksK09Zdpl9cdomH3fudhCbzfJRoG065SmHaqSgFCe8AthdnDfxdb2YrHt1Zi34XZyZdc7QEBWNPOVkybh+O3F4YZxmMzrthcD2FiGoIhjpyIC4vIy1IDEIIJHJfrNgF29bvKwzOJnRGA3OHhYZMJhABy8uI0kIgNGNmkq/W2x8mx77k74SjHotyvxMesfEsAnKNazR6XlqtpbjJrAHYgkdgDOv/Cm9wPt0aAF9rZ8kW2dgKJ3A8GJhP4e5RAIqqzNB8kfa1f1wIUeyz+GbFAIhvZrA2g+ViAiEak/Xd5Z9eUNhCF4TMTG4FEIJBIKx9KKzBlKihMQCSYOKMgH844Xvn/f0nLBnoIJYXNhmQJ7zU3mexml91znmdQLQzygTzIFADcHoR9aF0+SmK/G8jQ6ltWyW1oMU52DNtkp0+zfuOY7IyfX4y3z28X2IKf33AXxEmgCyJuUeI+b1Em/mxR0rhFCXjTaJjzTaPZ9WsjPN80RmUb//P4/lhF5q8ux4cPnfnhw/EdB/F2QFfycECHK2KcxyPTNFkP6Q6xVR4PsTPzQ5RjvFbg7qInsbjoGVBe9JDZLkXg6o3M9hGfl6Hnx3k97SgYGB1wwQCWcJQPu4SDo6KaE2dRzb2HMieLtzKn6XwVOejCM81T4Rl+gFW+SgEzFKWAmC9RKc4k5bIGT+WyXepyWfxuTSJQwLxaUp7KAsTUQy0p/3axUvkraqEX+aukyP+Y2y7sRpiqeyOMIPdk8flUHEI3A5Shb4Rp41T31JpkurcmKbrUygxH6cdmSQamVHqTrKHreoGhNYmhWSzRqYigyfJHflhg206Z2e+99Jj0U7o1i5EX8pWlfe/TtX0Pki3TMHrj5sdUApbEzNQppK12W9hD+x6+kopbQ6VWhfqVYPZgZrEueylTSXcyGZGhoZKhxTX3NO40oJeCVguYEr+BhJXuAcbPFld8JVfLpuMsNh37erotfow7+fSrZVXApen4maHpGNeSl7U2cP+Dc29zG3iOtIFHoDF/xtaYj3AXJVxUQh5RCYzwiocIwCuigxOxAS9q0ICXkb+AF0TuGGEhdxoEueMjBElaQpCugocgXdz6g6W6c2CpLoPCUuEEufMVFHbtAIWpsKdkHKCwItIa/UO3aR7RbQ1dj3OCblvA9MreYHpmPPcpeoLpFRCm5yve8D2x3D/Q4A1BHyYBOMEbsgAnQajMJrAzcFL1AJxkR4BqOyBAY2+C8CrBWkJCgCKUtYFQVgJSLW4f/fXZdLj9fdhQ1vZfKKuBUNb9YHLPnJjcM8TkukZwfVh3TG7iYT+YXHyS98IquLiO4GJvQXBxqhYMuBhR0hrYyQMUCUo6U4MkC1gnqy5R0grY0X7hc+wR7v28AvfO2XBvFeHeNEG4d6kLCQUWKe4X7o249RkLbh2DuHU5WNw6AvBfDQTgdxWolSBZgfZOAHyMDcA3CAC/gQD8xn4B+LjGb1YSyAKVkgCkZrhKApREWNmNkoibHSURwslSSFALSRKB2g7Vqe2QqbQdIMMGbYfyQGMdYhep3F3YLwWnhAKxEyJSKdsilVMUqTSdIhXNFqnAKEFEKjFQUNH1tFWkwpHaJkHUNuRHTrWNKL9QDCvfZUPKZtlQ/V/ZUGpVNlSPgQygAkZ5ZJINseuffij0+icyBAtZwJQ/w9A/oXKIxKx6EXIBRmibdEIuvhRpf8KJIo1BWodJ8iCtW9MIqnQaQdKMxotGcF3sqNYo7nLVKU9ix3XV5mBSlWBrpOpkwJ1qc4P8VFfj/5Of6gNO5ackt2tS3f707Vprx5NLHW0y3tau3zr9NbUu68vgQRB8z6cgmE7ZfM61spkkIhLtRULQmv8GOXWzbG7ba1AAAAAASUVORK5CYII=", - "description": "Displays latest value of the attribute or time-series data on the compass. Expects value to be in range of 0 to 360.", + "description": "Displays latest value of the attribute or time series data on the compass. Expects value to be in range of 0 to 360.", "descriptor": { "type": "latest", "sizeX": 6, diff --git a/application/src/main/data/json/system/widget_types/doughnut.json b/application/src/main/data/json/system/widget_types/doughnut.json index 3e505938af..e8985a5243 100644 --- a/application/src/main/data/json/system/widget_types/doughnut.json +++ b/application/src/main/data/json/system/widget_types/doughnut.json @@ -3,7 +3,7 @@ "name": "Doughnut", "deprecated": false, "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMkAAACgCAMAAACR47ilAAAA9lBMVEX////39/f5+fkAAAD////////////9/f36+vr///8Ihyv/X2r8/Pzy8vL39/f5+fnu7u4hISHs7Oz09PTq6urm5ubg8OXo6OgXjjjw9/Ki0q8nlkWrq6uDw5VktHr/6+3/c32Ty6LB4cqenp7/9fZ0dHQnlkbj4+N0vIf/19r/r7RFpWA2nlP/h4/R6dc9PT3b29v/aXNGpWBYWFgvLy//w8eQkJCy2r1VrG3IyMj/pavV1dXOzs7/m6LCwsL/ub7/fYb/4eP/zdG6urqCgoK2trb/kZhmZmY8PDykpKRKSkr/9fW728TO4tSDwpWi0rCFw5ZWrG24QEw9AAAACXRSTlOAgIAAcN+QOHDzcMjxAAALEklEQVR42tyae3PSQBTFq/V122ySbrIJYIBSUB4KgooKpWBRO75n/P5fxt2FugkLIZvNMuqZ0Zb/+M05596b6NHx8b378K/r/r3j46PjB/A/6MHx0UP4P/Tw6Aj+Dz06HIl1KzCiuwch4RTOWkZwDkji2J6HmDybyike5s5BSBgHcjH2Mcau6yLPK5rGNIkAQa7vehQH45CEPsXh5jiFsZgmESAYO7CWY7t4RVMgjFGS4G2lU2sEtCO2hzFAa3E1XXSvgYuykRVMMSymSIJK52XpZKWqxUywYDw6Xak/HXMcy/NJiDmLo81iYnaVK7XHJ3E1qCU2dE/jOr8Z1zlMSJ0pwpfCPSk3Xp5sqmQj34L3p5t6v2AwjstShmxbY5Jpk8huMAxZPxCG1uk29ZkzFgpDjJCGLwWnK6iWTrbru+tC/XSHrpgxHmPxbPW6GPDkImaHTOILT2T1uwCAQp93PyeKPonMIesx7QlA/3S3zscAFiaURQHFBElAOVJUsRFGfHal+HJNWXytiOn3pNw5SdVPy/FovGCcisL74rGI5UTRJ2mU0jBKtQu24xFDqV+dp6GMeixiIVYaYurpUg9W6VmnMijfXvQI+/zLXS97/dHOulBbEMEuTdhhSYQhMkVjsOUWtmGl6/HNDhpmi3rC9NNV/rUjUGVIirtCbSF8PAFTd3vU3tdZwtRQ9D0ZPN3C8awiMJIolMV1se+Ttr+iWd5sS9iSJ0wBRZ+kscWOzgB2yXIoDHv+5TQhtmHHDOipoeiTdGSOahl2y6JiMBzHZUlzLYBvY5ll2gInVEDRIynX1DgEDedZw/AZ0DvfVhYFFC2S8vNNkHcBZJQFaxjX9Ylvs4xtGccWe2xxjJMEm11/fAEq+uMMY7Eoy7mEouyKINEB6YhgKcFwFt6X3hYUqfbaJDKIliFCtywhYrZIKLz25kjkjryTDFFkcXFImC0Sik3SUPR3/AZIA3TEVyaLGAJYjJIoLUDE9WQUBU8U9kgpV7LkiGHals2E9QHYi0vHEEk1WZEA9CRu5ZBIKFMA3095jNQieVs4iLiVQ4qSfJe0ACutKjokwVMTIMBfuNKy2NBKolyDTVxkGyAxAiISRjyGkmy9G7pp+cpJUjUDwrUd5Yo9ee3Ll/oUHhgEEa4kaz9m+fIcBRLlbA2gSAkUB+rxvTKi+fL35ksxXVX1hageMJ9YyVdjN6tbch+JiidBHKQKxUugLOIoXbDD/flS8eR5vCRgRBZ7yRf6ADfJ+eXvN0XBk0r8RlFou/peCV1oxVvfy1J6BU+emi2JCBjCxIZusvT+3knMSZQtqYEBCVcQu1umCVMsZoo2iWyJmWyJ1ot8xSYxsnVJZEuqYFSs9cSGpVpTMpM8NTq35HwRC/pSU/RIZEsqYFIiX90NU9IXPZ/Cf5clK1OwbApOjVdGTy7MWiKz2LThCVOWgNKfHjOS1MxbIi8ViJvSB4ukmpItXWXzlsj5CpOm1HnndT2pmLdEnsQbTemBl/rwmI3kmXFLZFP4+FrG7ki+5zVJAmm9mxe/WaA1Eijd9HjxniiE6x0cSqtFP03GS5fkXbHhIplNSWzHvhQvdZKSIClDRk0+cbVBEmpmH8QWxOLFl2OKKft7cpEnXJiQaEKIc+tDU5nE2ozXmB3EWiSNnOGKhpxhNkSAZ82hA87lrC1IlOM1BTvMSSLXJFAmmUWzeYTIvDlDKJrPoktKorBSxPQSczg/SUl5LQoS1EQAnyY8XZh+HkaMJPP0Si7HFoTYc3I0Xt4mNWWSdgT86/OekMn8kwLJ6mJZxDcKxsjO78lb9ZoIkqEgoVRtdKlG4uN4URaA+IuJvCRVQTJQJsFfHID5nJNM5gAKJLwoIbQEyRU7vfJ4IhcelElgHg1nXxCg5iUafrmcfeEkSkU5F/91glXedpR7Ip+Pz0FJl5j9PZxPEP+B6J8ZmjnOTOF5izix15EjMbxyeVLSP7o07mEPponhpUMiwtUB05KHlwvj+NNWiPOTBHrvUDVJMIblxhhWJ5GvrrdgWjKJD9fJy6sQkgswL/leqcdI+ELJS1Ix8O9xSgsFBEmvKJIAlNT+80sUTWAt9CFiUznzYd/eIAkPTtJ+8/rJ+rdXZ1QvIs7x8TX78ARl9EQi8Q7sCWqyb78meXF29pV9ZLbQn69ffT47e5V9yUPssXF6cBLuwpok+t3e2fYkDgRx/DT3ZhpBil7dglSBQJtrrQnWqjHmHjBivMfv/2muHU4HqZDtTLca4j8xAaOmP3dmOjudHWw7BfCT/Oqn+BpuEUvHuookBzWTnA0bp0iCl53b0si2AYanp35ub7Y9LEFSKK/U7PFLJDM7oYSsDMmre/wTyRVetp+/fVRq277+/UQehYsbrQGDBBdlloWse3++HmmaeVHKuzN+WUOCWb1u102HRdJ4sDMl14A6xFjAzFa+Y52ozgySSNCukts0i7zThTUZcba/WBoWZJAuI6snEgxaPgbmpAEoXJdDXZJnJYlveS7M3zMqKq1wSB7sGWSa5ovyqMQeaRdXnu+0PrX4awIRr9yFJHTVVzlJejb7/70z3d1vE04Wdr+Q736BS+KReXFI0KzQyK7x5j6/M97qdhgsnnw+gd0DSUWiTyQxg2SYZY/DwwzkHnOW0/QwTezE1wzCeMp2sTDcqrtyRySQ2qgHP/eWe3ydTEGL5KXKHYNEWE0dpo+X69/OZqPp41ZldjYaNko8//3MrqbKK9xykZvsLlW414QuxlMHnuR736/ipw79V6kT7RTu8Dfo8BU9nZsAT9yGoibc6GZdRKLlKMqFWkQp/TGRHGGLKqyQ1rNfz0CLBPsptmhNOgLzErgJGpd2Z4EWiWuR6jIvbIGeGxclwvtSEghl3Zy8Zu589sQ5owNHNw4rMC1yk124W+qKEvd3uYpdsJeQUEaf6Sg3Lnn3YMD2eQFJuwl3zyNXU04ysLjPHiS3k72F9PEcC6ny3lQI+YvCj10t6vU4BnwwVwFJR7AoEpc/f8pU9sQ93MUer7ELJkVnanDazMl8BNNOPu+vEpKe6IQAt5G7nU+aOPqRvcM9ViUkrhKfCOKcoG3P16GhMUUKSfQWpV6nd39HSnm/2vvtVv6116zsdJPbFZ9u4sxF+Nto5UMwm80Kz85dsE+ccUFQP5uZMo4qT2aGFikCs6ItUbizm2FUe4I5JhDTPSw9RspaggQCqyZXGVgMEtr9lnR6FYMpOWNGnMQ1Yf2vrLEhr0cQRuWgFAkERlFonAujgFuSBCJjKATCGydQlsRR5lAIhFG+JRKGqxhAQRBugNxmTI4xhILOzr1pyaf5VFmhiBGEkUjwSDBrMbJb+aOsRXVdKCX51CtPamE0fpURtiQk4HSt6p3FiRggfBJCqdrC+ooBIicpooxljh9PLAaIkIRsQe4t5CFyEP5ky0trWT0ei1scRB4x/pJglm2vgDJmsBAHyXOBIeEEWIGNEQc7fMhJyO+LmnRATzRQnTsCUG5dhOK9PClZJ5ANAmW9oJAbOAxNr7Ym/RhWy+lfFn5PWh0Qz0Z3QmuF1CS4iN1lx3A6AVEsK4qBJSIRqdO11khFE8/r5fK8y2j9j/IzBbl1kbfIdcnwEMmamGIJB8AXrYlccfjKHHISYvFq5zBDgjbWZWCoQMBhhATVCcsuR98FmUyQoJwSMCFmnGIZIkEYDTNTHq2GXIZIkOYiCNUqiCjoOFC5kMSQ4kG/54VRV80But3QC/oXDhjS9sZ8+ufWO8mb0zvJ29PmkGx/+Aiboa0N+jTpzfmE74351PV/l9Yow3BWYVkAAAAASUVORK5CYII=", - "description": "Displays the latest values of the attributes or time-series data in a doughnut chart. Supports numeric values only.", + "description": "Displays the latest values of the attributes or time series data in a doughnut chart. Supports numeric values only.", "descriptor": { "type": "latest", "sizeX": 4, diff --git a/application/src/main/data/json/system/widget_types/doughnut_deprecated.json b/application/src/main/data/json/system/widget_types/doughnut_deprecated.json index f0fc4e30c0..39f38fd208 100644 --- a/application/src/main/data/json/system/widget_types/doughnut_deprecated.json +++ b/application/src/main/data/json/system/widget_types/doughnut_deprecated.json @@ -3,7 +3,7 @@ "name": "Doughnut", "deprecated": true, "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAIAAADGnbT+AAAABmJLR0QA/wD/AP+gvaeTAAAR7UlEQVR42u2deXhU1RXA+a/a3a+t1mr/qGtxLVYtiBUtqK2y77IpCLIFimUTlU9FQOGDAhVZJGEJiyBgVkhCzE5WkpB9IyEhZCf77G/mvdvzmDgzSSZhmLzz3n1v7vnOH36I5s28X+49+xlEmDBBkEHsK2DCwJJDytrKd2Xv3pj2xdrEDxf/sGzW+Xemh89y1YmhU+dEzFvyw/I1ies2pG76b+auI4VHL1RF598ouGFsFojAvkPfBYvjSX4Tf6KQ+yjePPa0wT+Hc/yrghuFo4PGe60TQ6csi12xK/uriMrIivYKK29jYGlcbAK53GDbdcky6XvjI/t0f9rj1A/izI6/1mBoHAhYvTibujrhg2PFJ0paSnmBZ2BpRzrMwulizi/KNCRA7wqTq84IMTr+vslqkhAsV33r3Oytl7bHVsfpOT0DS61isZGYKuvKGNNj3+j64smhwwOdb1oQBCSwHDohZMrm9C2pdWlW3srAUo2A8bQ2zvyUv/6WPDn0wb06s4stNDdyPjZbP55hs77O2VvRfpWBRa/wgnhEzQo1es6Tq5a3OQ2grRnb5QHLoR8mrU+rT4fDkoFFkRitJCCXe+mYwTuk7Bpb5byVgstDZAbLrgsuLA6rOGexWRhYCouVJxAyGHpEPxCk7HoozxlxyGrIVgQsu848P+d02VmO5xhYylx858qtLx83DBwpu36W5Iw4XO+sURAsu86LWhBRGaXeCIUqwUqvtb12UjKk7Do33Blx0HF6xcGyq1/MisLmIgYWurSbhQ0XzeDESUsV6KgTBpfjkKcELNAxQRMga9Ru7mBgoQj4S6eKuGcO6iVHyq6P7tfxLj7Z1LAZ9LAFCjlKuBlVlIhUB1gNemGmt3EEz7Wm0/naPk3dQBVYdl2f/GmLqZWBJY1EXbXiHVSumlLjDJIeL/6WQrBAZ5ybk16fwcAakEA0HCyqB/CRsuvJIqeHn1ybQidYdgWry2wzM7C8kWsdvOSuX/+6JdUZmbzaXkkzWHaHscHQwMC6PblUZ3v2kF5OqkCXRpkcD9BmbqccLPu1mN9cwMDyVOBK6lEvJY+O/s7gEtO30g8W6LjgSeEV5xlYt46ng1ElP1J2fdK/W5nU2JCJqmAL9EBeAG0JbIrAggrPVTEmpaiya6vJ+XpWxq9RC1igWy9to6oMmhawoAh9UaTCVIFC7bLjkfzzDqoILNBNaV/SUzlIBVhQ9zInzKg4VaAhZc4XE3MtTl1ggX6S8hklVTeDaKBqahAVVIFCq4XjwYpbSlQHFui6pI9pYEthsKCgat45EyVUgUKNvOPZoElQjWCBfpa60SbYfBcssJNXx5rpoQp08vdGl7i/WaVggW7P3KFsxlpJsDYl00UV6POHXdp1iKBesEAPFwb6IlgBORxtVIFCXtLAOX/RF0UvVTVboRXhvgVW8nXbQ3t1FIIFWtzsrAaGIQ6qBmts8MTLjTm+AlatTvir7HlAzzXyqjPiANkSVYN1szXjbfBCtA8WVMKMOW2glqrHD+iDSp1gGThDja623dzuCDyCJ99p6YQ/hGAEtDKfLQv6X/bXMJdmcth0atmCFIL8PT9yg0WbGwgKPdNLokzHC7miZt7mbVMMlMlXdlTChJkv0rdOC59BG1u7L+/RMljh5VZ6eIKq1PWJ5rRam03qDiuIIeXdyN+Tsx+mgNDD1sXaZG2C1WQQ5KkwvqXf93aYMbrSasXv2IMLFK5LKFSHThsKjK05cKdrEKz55xWOsEON17p4c1mrAi2g1zqvgSk2PmSy4llqrYH1XbGSUauH9unAtrveqXBXcaOhcUfWLggBKMhWbHW8dsBqNAhP+it2CcJQtSutFDWqw+kFs2WUAmtpzHJ5SgLlAGulQuV7kJ8JLaNxshkki+DkgLG5ciIFFzEkeWBeoUZOrOwG2wNKULUowuRaDkqhdFg6wOiRq05rA8TetOMVQg37uDNyh0NhNuSpItXMAIqsvDA5FDG4Ov/CQhjpprXIO/TbyEzVsEB9XpPKRv9AZHVe1HuSIwUDm08Un7TYev2OCYItKc4aHaFWsCB7I8lINM91SpCxxajKaYsQYYJ1BBJSBQmAJmOTmzukstw4a4J+5POGmeOIlVMlWEfyZT2uIOxpUvMMYoimwijlgSO1MHpJZkO2mx9gMpq3bgCkHMqFnlEfWNB18+JR+Y4rqG82q38FBOSCoPLTa6QgRwnTU900gfE8F3pW/+pQV6rEQ2vKG8RsUhlYxwrkO66WXTBZtbLzAYJM2zN3ejGcDUKvraY2N3dfSaFx6hs9kHIeWkGn1ASWVcbjCsIKNm2tRYJz6/PUzZ5TtSJuJdTwuGG0s8P00X/6Qqrr0Jo5Hs4z1YAFYUnZpi24VhJrRsCV88SWh6Eg4pi/3pF0m407Gdg/Ug61XYxXDVjTgo3yBNbrdJrd4QZ+4tzIBf3UHENZTqdF5+bAy88xTHzNQ6pATe8vVAdYkJiTIdT+5/3d2uE1KbAKZVLotN5UvR+/qrS1zM3d19JsXPGe50g5lC8rUQFYMDBd5ul7GhaIy7siBQs446oT3DQMWjnLob1eIGVXy1fbaAcLIkl/CUA322eHGn1njennaZvtKeSDBYeNVqObuy8zXT/6Za+pEk348aMIZ6EarGB8s/3pAH29zofW48KEdxhRVKOrcXP31dUaF84aCFIOtSbGUg3Wwgj0CpmzJVbCxGy27NoiCVJdJvzHK+kFCzx/T7ZODkRh2pGvIwUp5IQY/esvSEiVqK8PF/Q6SsE6j9yEA85mfhPv01DV1RjnTZMYKcdtGHeBUrCWX8C9B/2iTL5MFRd2Fgkpu5o3racRLMg6P3FAj9oQUdHm28dVfa30N6CrbzhuJLFaqQMrs96GelytiTUTnxfzto2oh5YtJ4s6sHZnWlDBKm3hGVh81VX9qL/hgcUF+lMH1mzM7Vyug/Z8XExr/PDAMq32owssqJN5HNPA+r6Uxa5+jLMnxSHehm+OkMrMkgasLEwDa0iA3sS4cv4Sc4YJr+KxxRcVUATWwVzEetFPEpnZ3k0sO7cgmlkS1ZRKA9baOMSKhoRqG4Op222YlY4Yzdq+mSKwJpzFstwHf6Nj92Cv29BqGD8SCSyj31xawIJKAzzLfW448wfdBbQ2rMMKk44eQQSeCrCqO3i8e/BwHscw6i1c8GlE+732OhVgxVYh5p7LWFzUbaS04gpi/D0rnQqw8FoIH92vszGu3NsfPNxZWGUOEWFUgLUjAyuZ88YpA0OoLwErGyvicOQAFWDBYE8ksFZEmxhAfdrv2zdjRRy2baQCLLy9cHuzLQygPu33b49gZQzXraACLLxNE5EVLITVdzArPhorlLV8PhVgjTiGBVZqLYu59x1/L8jFAuvd6VSAhTddrfAG8wn7jjhcv4YVI50+hgqw8PZNKD6ZneqAQ3sbFlhjXqECLLx8TodZYACpVCQA6+F9WGF3Fh31abDwZstocvYVA8tTgYlCSGA1GxlYPgzWkABmvCsSIW0iCT9B0eR7qADrhUA9K21QQAzFWGClP0wFWKNOYAVIsxtYgLRvaU/CAivzOSrAwkvphJSxlE7fcuMsFlg5I6kAC1ZCIIG1M4MlofuW6i1YYBVMogIsvBadf7OymX6kdAEWWCV0JKF3XsIq9INLlvHTp1wegQVW1edUgHUKbXcczEXiWSTLrQgcSboLC6yGY1SAlViN2F/v4yP8+hRdFhZVoO3JVIB1tQ2x/Wv/ZWa/u5PafYhgWeqoAAt2JOHNtH2HNay6lcKpWFQl/ZoQgQqwQMafwYo4ALIcuwx7GlgWcvG3WGBlD5fkGaUBC69RBzSlhsXfu0tbHOI9WOZHEViBmFt62fTRngLvHg+s+kMUgXWpDtExhKADK8zqdg+m3IsIlj6PIrAsNjIYcycFW3PiFLwUod1yF2gaFQkyIwRxuC38zxlRXZL3L0SwckZJ9ZjqGMf94F5dWStzDgnRF5CEOxDBuvYldWBlIS8QWM4S0iCQHsajCrQzkzqwrMgrT9ihRUxVJPEXiFSl/AHaYKkDC2RxpIkdWohS/DbucVU8R8KHlRKssCu4a+XEQ8tnq+D1+SThTlywGk9QChZEmwYjL8KcGuSbHWECyX0dl6qkXxFrO6VggSyKRF/de7zQ98bdNhzFpQq0aJa0jywxWKH4y8af8tc36H3p2OKaScp96GA1h1INFtyG8OKx2YKV5j50CUJrAzZVyfeKmSKawQKB1TfYYIHCqGaf4Aq1oM+hV5ZJ/uDSgwWOmwxgPbJPB5lvjVMF9ceQvEMH6w5iKFQBWCDTgo0ysPXcIX29TrvGlqWBpD0gx3EFyUcEQQEr9IpVBrBAYTmURZPHFm8il1+SgyrRbA9XDVhQTDw8UC8PW/PPm7RWuwytXfkTZKIqY7CEaRx0sAjmHpTeukBLbAk2UjRbJqpAa/cgfQ4ssOBNv3hULxtbsH1OC3cinFXYCUFXTXuI8GaVgUWQC+F76xG1L6CDd4zX1OW+vP0g3qdBBAuOkGFyWVpjTxvU3YwP4/lyXpGVqozHxANSjWARzLEOrvrMQZ1R1TXxuhxxiJ6cVEldyyA3WHCK4PWydtXS7NHV9YhmXd9B6g6ohqo6f1mioN01a5joJagXLJC8Jh7qqPDAyugRf28O6apbKpgspm8pv/4KJsqNlKh3ks4M7A83SIYvcFUMVi3NkfzuVoI+l1z8jfMbTP0jaTguySQCyd0/8SZKuV8JqqDXeYkMn1AOsJoMwpMIJQ+re3RIWxpF/7n395j7KkYuzHuBThsY8qkIUvbCdq5FI2CBfFfM4bqB/edAEn9KSuYR01WFkYIHKHlXfBilqBJDDIfl+ayDZPtWJSwuHdLbDSyZe+vvNPHnpHSheGDIL4YiUrpIfAAFkQKFTJFchoF8YLUYBahHQHEDq7feXpUI5PPBxhfwR7rBj7gRTHL/idtl6ukleL/oLsglg+T8vY2osCK6gV6UTYIZCyOBJI8TwviDtnhStlj8EYrz5Ph1ajkn57seJPOdADOJBkLV4bx+3UDvFIaYQS6ldr8YqPR6JAb8hzCnBQo+C6aQ5Lup4QmxRpQusMw2Mu6Ml5ssVsaYPHIDB9QFdZc45rr0PfF6hbkuHSnEUCLeINZWYtPf9BKMxFIv/mFHGmk6JU7xh2Pv8t8VCHLeVjiUN2ocLDHUrBOevX1jq5cbaJavFE7VCsE883X537ICYIFk1ttuay8ruIGGHoYQ+HcMGk8c4fZERV6xMmCBHMzlBuAGbmHQeKTK5UwVAwvkixSLV25gKPoUA21oxVoFX66SYAkeLHg6kMP1jDRe/B2DxoNFS/OUTZIqCRa5uXygn+FHbtxA+euW1KiF07GrYmgHyx6AeMtdH+LoHm4gRLFhQiaD5pYKgX5e+QHmyoMFAom/Hts0b2YDhdvOBjLNe5PwVOziowIscrOrZ0mUyeEG1gwoG+irCuNDeFrGpdAClt3esid8mBvojcLCVYGiDjiKwCI3a+R7UiVJNlDzWr6KtkJZusDqdUE2k/Q/M276ja3/jNTspvDVUQwWcwM9qTOG+hwqhWKwzLUkayijp++ahaHEXE3t26P7KoR4TPlKKsov6dI7xPoqnur9QnSDZZfWaLH2g/HUdf3dR5rD6H9pagBLvBZrSN5oRpXYDSFj3boPgGUXBZs8abDTxc4t1Uw+URVYYgCi5eYGLJ+yuu4Uq59l6TL1YbDs0pYg1nH7hOs3TIY5CwysbmEusZcBRmhqFSn4aA3HkAaEMrBuSRdHavdqzWeE1qO6ANSpaAwsD8NdRrG4O+MJ1SOV/og4bZaCaioGVje+xDqInH+oMuApdv2HUlWewMDqJbpscuV90T+nH6nke8iV5cRQrL2XoEWwuswvG2mNIUUzcPcoe711Emb5wVA4Oqo9GVheibWNNJ0Wu1Zk2Pp3yzEkAHrjya5ufU2LD4DlaoR1pJOqDeIaXBgEItPh9Evxx0GHbWememMHDKzbuShhOAz4klDRmz1cyvkwcM1lv0jK/MSQAeyFk2EKFwOLaoE1bhDQh+HYlZ+SsqXiYCNwMC89LV6gyb//Ue8WBz3CP6Q/SjKfE/9C/jgRzaqNYjCzPYmYrnk/CImBxYQJA4uJMvJ/KZzDcnj6uUcAAAAASUVORK5CYII=", - "description": "Displays the latest values of the attributes or time-series data for multiple entities in a doughnut chart. Supports numeric values only.", + "description": "Displays the latest values of the attributes or time series data for multiple entities in a doughnut chart. Supports numeric values only.", "descriptor": { "type": "latest", "sizeX": 7, diff --git a/application/src/main/data/json/system/widget_types/horizontal_doughnut.json b/application/src/main/data/json/system/widget_types/horizontal_doughnut.json index 26b00decb9..5267771809 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_doughnut.json +++ b/application/src/main/data/json/system/widget_types/horizontal_doughnut.json @@ -3,7 +3,7 @@ "name": "Horizontal doughnut", "deprecated": false, "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMkAAACgCAMAAACR47ilAAABDlBMVEX///8AAAD///////////////////////////8Ihyv/X2r9/f36+vrz8/MhISHt7e339/f19fXn5+fr6+vv7++Dw5Xp6unx8fE9PT3y8vLB4cro6OhFpWDg8OW2traenp7j4+ORkZEnlkXw9/I2nlMYjjj/cnzNzc2qqqrCwsLb29t0vIf/h4//6+3/r7RktHrIyMj/w8eTy6JWVlb/aXMnlkai0q//9fbV1dVJSUnR6de7u7v/19ovLy//4eOwsLD/m6L/TVqi0rD/ub6kpKSEhISx2b1kZGTPz890dHT/pav/fYZUrG3/zdH/zdD/m6GsrKxtbW3/6er/kZn/eYNVrW08PDz/pazf7+MnlUVxCrBzAAAACHRSTlOAAHDfkI9AMGXS8IIAAAzaSURBVHja3NdBb9MwGAbgFgS8rW3ZsR1baVoJrayXLp1abas0VVO1STswBgcO8P9/CXFaGImzAHFTBO8l50fv99lOr99/8RL/el6+6Pd7/Vf4H/Kq33uDA4YQkDz4C3nd6+FQIdXgmHlxMImDUEqFEFTkX0KPjOkdTOIcImIsSRhjUZSD8nTbTSeSvUNzrpTinGubOA7tsJnuJIJppZK8FK5SmRrFtc41rppuMN1ICghXjGAXKqySMs019iiYcMn0+io+n987Sd4IBbLlcnODXSjjUhpeNNPdlIVLpqPVh5PBLidfXQkR1u+HRR4+Lz5mcBE8lcph8mKCKV1IRvF48HPeuUqA7Q6y52ydxmGkUdpGImzGupCM5qeDau4Zj/A4rObszmFolKa5hQVYOpAUDD/nTNGiEh+zdBihUsVtuxnrQjK9Gg/qM7aG4GxYn+0GANF5L0kkwinhkmns6qjPB6sIPg2fy2wJgCipNIuCagmXlB1+rtye3HmCioXl+2JZSC3hEt9RPbs4x3o2/JXFSsNdLQGUMMn95aAR8pVGVlFkJYpvuQEol0q7zW9BCZdMxk2M03G8f3URZF+aLdsMYMZoFrWYsHDJ6tnBOjlf3U+AH+8uAmC9eVycXTSNGFUpT1pMWICksZCT+WiK7yE5JUq40QS73Cy3s+drsVLpFudxmKS+kPFqglLIfsBMqngiCFw272fP1SKM0qwFpb1kOq9bjPiJUaYUv1rGuPc8o0U1tc0sAJqaFpT2ksm7mjpGqA3ZW1iiNc+7kUYXmGWN5SEDUcVxfCTJ9eUvHVUKpUJEOcc6jUwT6ixn/oRlgDa8ppVOJLenv+HwMITuOCxxmLdcAHj0ern4CFjpjrAjSGLvtPIcXshPnB3GSMlqZ+wOsGlBCbb4kmZIPMVv5wljuSos64W/90UrgnYq8SHvrvFHId+PgCjJLYoA2ayGknIWSPElzZA52sRZcozl5q0GsPApiQmmNEtib0PahewszPJUCmAz8yjcaBa0Ks2S28pkTdAilStTSQtkD2XKEsQYHdGuJNdlyDnCsh8xlUoKfClTbkBTZcMp9ZJJ+UKMEZzigcm4SUV1WS6ynBJwrTRLDgbxH5hSo/KPPFsjSd0TrAPJvAS5Rev422ILyrJE2QI8dFXqJasuIE+/MDKpUu5AUmVFIMWXTE7DR6uZwnNKZVWE5EyQIIovuTwwxN97qStr/wDo0PnyJbF3sQfG33subeUwXrj5+sas+TWlDURR/PXMNGtwwRAENkSdkmS0YvEPFKbpAxRqndpOp9//q9TdrNms60PMsrTnweHR39x77jkkWN0vk2SoBSJ2KIXSE1M511JF7NdOSR6qFWWIHUuhHOpP+c6Btq3pdZLb6kiO4UCF7dvUw2f9fnkjS9PrJDdO3G6idIL+Oy0hr+/Q5aa3JzHtfgJXEq7vC6vopuf93prE7FtDuJI8YIe4uDaGsiOSI+e7pVC6bSr3Sw3FJh4liTmSE7hUYRUfODeHYkdiFq5bOJFC4fvVwtJwyk5Ibhxmoul63otfDsUPLIJekdw6jhJzv/o9XGhDEUFvT3KmQAZwLh713PRfFMl7HvSkZU3ySRuJe4mhdLShLNELGlcWRXLpeCRm1D8NZaS9w38Uh9ia5Mb94TKdQgmWmufb7V7LsyM53keWmPHY1s7XFQiPFAsSfbku4VYKhWdKNejPm6+XIrkxGpdz8aEEPu7M9bIhOd6r39VQ2lTz/BIk6NqRHDXzOy3kwRBdAKjn+StFcg+PNupeimTQbLkYY1HO2DOJn+FZWYIaJK1eEOBOC0fqExuS0+aVKyIolTGd5O3rVRjFguS40ZMhReKzKB9hsooSYLyKUq8Oich5fr0+KJIfze6wIjlq0lQUCVllGEW+mMks98DGtUk6QQfLqlFa/ddJKCoihP/ZsqzYhJSUJH8UyWkDkhkDkE7kdhEySeuRgNfIALg2EgUvNYpoicF+hWOATufTMAaicBpO6TPJWRObKJI0BTBOBQlheZrXJeHhqHWvayB41fKLnEFqPRck8ZwiDikNI9AwliSnWsDbzWSRcqj6JB3awr1arwv4ZbMfDsr3giTyV165Z4yTEAqk4Xi8psB8LUg0w39sQuJHPkhCkeWCxEvqksjvW1dVy3eCriQ5UzmdLbj5pAoSIGXTDQAOxCTJ10aGVyTIkmQ1A0iSED9ZrVhek4RX+8DHZ71EdqXlxT8EoSQDzV+SrOecgDtmDUlyZF+6iCccLG4KhOqStPvVbPyAw/IMD0rf0ijLZhHVSLjiwvobebu0IgzXMvtwv3q8HuGJMyx/kzUYgivN0zTNJxoJY0AWMpApv1ySZFD7dLkgGQHf1Rnmzcs4wysKgJaeZ8XtSmkcUmzmbLvdGiQDOJeZ8h5+V99vmYEiTZfMqiRkHYbzBNtQSJKclCQP2K9EHfbwWO2QI79mXSEUpQySSziXeYZbksSMxvqSJI4ebNclOax2SEHSejPIf0ESEI3kH82kGy1QaLIJ55uf8vNTZk3jEWrO5AUJfyO0Z5LuYnpwEEPo24HQN5FXxedw1pBk7zOJ/1Jztk1pA0Ecf7sd4JCHmthaEsSEIB3nxlgbjEGZVqmdseMDZabf/4t0kwM2ydkxzYVz3BeIvpEfm//u3f8eIsbWJPeM+SGfM4ZZ4YxNQu5FzCz4dMk6qSvopMSM0WTm9ZrEZ5ENYERsnqTHFpkJitaun8q1S6kKWxxsIpkAxoT5APw+0U7IGC/WT3akfqKJhIJIbllkAADmhEAxNYWmWtgZP2d6fLc8yWHJ0QqRcBR7YF+nPjyPUP6FSGwQJDTuapXWye+S7jaRQLhkGJEHsKle10Yhkk47PRZ+wrGwnJMG2oNNyMXRD4zGyq2QRvVOCRL67NnffLvI/KSBfv3XjLki20ToPPV6npezOh9MjCE0vAcTQHGmRST40S3DsPAVRBh8yUwDipjcnfSc8duzzvCwhy+9XCn0LQHkz4nkjEhuypGYQhRzIfy1dO4LtZM9uEt73DSPf1zM3EciCXmuC3grg8IikvMyjgSR0M9+LPmAB/S3l6cnHzMm5DRlp7qz2WwhSC7b9tBrZLWDPtckTosgkV2iUiQG6ns1ZjHAFL0lYMwq5Xd11o1xFocg8Uaj3mWOpD86wnl8lgQOShYvO9Xjw6TyLpPE9BFugvl5UfCSB5k26xcI4tLTNQrluZbZJ5KSbqrcT1hk4gtHhuXqfR+KrWpNM6WL2smjO1uQToQ/SDGM/SmJ5KSk4UVasP2k8PLk/W2E700LCskkc/D5F9ld+dqFfmD2b6bVtswRkSiuOlAYPKCqGwQ2FHRWavCUMlNJ8Nnv37K+j3L9xJIVj5LXvYZN69goEwJx/28k3KYaoLI6px5IgtOsdDe5gnq72apqxfQENAQ9XHW4ym8teHur2PVk0AWnWZkormKD80o7C2gRO9VNlEjG2p2iWlyD8/tWWjikr24HjgN6IpZJU2zwogUt9b1E4GjcqEaCN+BCfrgUScZ6NU8k1VQuIsHHS3tSUCdG6ulyQTR4VRLY15cU2oCTVvwV1NrdN7fLdtNP6jA93Rz+78ZnNNV3Pg+cV0lKDabuCqS2261mD/d7zUmhO1rujo8vAEFUjm0IEjkpXwaw/RCnAhEliZ2Oyvlyzac2ZKXEKEjQamFyuripXpWEkkJxDtuPD/t/nP2TvS7CNBUvKZNPN+lcmR+v/tNuoyGuwFQAyZEMDrZ2LJNC/t7G9R3lyy9zJHDzTkf9kgYVA/VbfImEGj3Vry1NVOSzVGc1QIpqT5V/cnRJ5bDis1REQoe1dOywR7VXXCeJhJ4vHV0F1V7t2EjjSf9/gzhV6FHj7QvSdS7V2lK6bsSQQCrvW8+SDP62dy8pEsJAAIbtoZmpOkA2IdmUceUiIJiFB3Dldu5/lWFgoKBjTyeW1Yj4ncCfxMfGSkDVDWYQNf6U5hLmSS0lD/EgxiWZqdNYf/5HT+FDVXnCUs4H1LkLS6dejRZ2YUdU2rjlk8gM7KBHtZCK6XDy78kpKIbUTewDidagZkjdFMUxwWZ2VAgpLOEnGAsWNrEOH3SCp1Z9CUy0Nm20FncwmqCOsAQ8YcYlKMcdgheUpIT1mBujh0LedJjrW9jb9knJqSDGDw5XdAPUk5fwDnsUeHr1itb2hKuchyryEmbwmRAHm12Yn4fIFfIFkZcw/43/CW6J5lcfo6MOmXBB5CW5RLgDmkFAVsKMuKUzLcjJS8AbOnAHl5TwAx22g0sKpYAbuKTawSVVpkiVy9FbUCAoYTbS0TJAdArVS85YeBfZyWBmCfgELYNtQZu8hLV2NtE5or8ACks0aXpvBJecw1VyPFfJ8Vwlx9M0H3AOTfMJ5/DV3O5wBvfbeU5d/wHsiOrJpqVt0wAAAABJRU5ErkJggg==", - "description": "Displays the latest values of the attributes or time-series data in a doughnut chart using horizontal layout. Supports numeric values only.", + "description": "Displays the latest values of the attributes or time series data in a doughnut chart using horizontal layout. Supports numeric values only.", "descriptor": { "type": "latest", "sizeX": 4, diff --git a/application/src/main/data/json/system/widget_types/line_chart.json b/application/src/main/data/json/system/widget_types/line_chart.json index f5b123231c..712f507282 100644 --- a/application/src/main/data/json/system/widget_types/line_chart.json +++ b/application/src/main/data/json/system/widget_types/line_chart.json @@ -3,7 +3,7 @@ "name": "Line chart", "deprecated": false, "image": "tb-image:Y2hhcnRfKDEpLnN2Zw==:IkxpbmUgY2hhcnQiIHN5c3RlbSB3aWRnZXQgaW1hZ2U=;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF80MTg0Xzk0NTI3KSI+CjxwYXRoIGQ9Ik0yLjg0NzY2IDEuMjgxMjVWN0gyLjEyNVYyLjE4MzU5TDAuNjY3OTY5IDIuNzE0ODRWMi4wNjI1TDIuNzM0MzggMS4yODEyNUgyLjg0NzY2Wk04LjY3NTE3IDMuNzAzMTJWNC41NzAzMUM4LjY3NTE3IDUuMDM2NDYgOC42MzM1MSA1LjQyOTY5IDguNTUwMTcgNS43NUM4LjQ2Njg0IDYuMDcwMzEgOC4zNDcwNSA2LjMyODEyIDguMTkwOCA2LjUyMzQ0QzguMDM0NTUgNi43MTg3NSA3Ljg0NTc1IDYuODYwNjggNy42MjQzOSA2Ljk0OTIyQzcuNDA1NjQgNy4wMzUxNiA3LjE1ODI1IDcuMDc4MTIgNi44ODIyIDcuMDc4MTJDNi42NjM0NSA3LjA3ODEyIDYuNDYxNjMgNy4wNTA3OCA2LjI3NjczIDYuOTk2MDlDNi4wOTE4NCA2Ljk0MTQxIDUuOTI1MTcgNi44NTQxNyA1Ljc3NjczIDYuNzM0MzhDNS42MzA5IDYuNjExOTggNS41MDU5IDYuNDUzMTIgNS40MDE3MyA2LjI1NzgxQzUuMjk3NTcgNi4wNjI1IDUuMjE4MTQgNS44MjU1MiA1LjE2MzQ1IDUuNTQ2ODhDNS4xMDg3NyA1LjI2ODIzIDUuMDgxNDIgNC45NDI3MSA1LjA4MTQyIDQuNTcwMzFWMy43MDMxMkM1LjA4MTQyIDMuMjM2OTggNS4xMjMwOSAyLjg0NjM1IDUuMjA2NDIgMi41MzEyNUM1LjI5MjM2IDIuMjE2MTUgNS40MTM0NSAxLjk2MzU0IDUuNTY5NyAxLjc3MzQ0QzUuNzI1OTUgMS41ODA3MyA1LjkxMzQ1IDEuNDQyNzEgNi4xMzIyIDEuMzU5MzhDNi4zNTM1NiAxLjI3NjA0IDYuNjAwOTUgMS4yMzQzOCA2Ljg3NDM5IDEuMjM0MzhDNy4wOTU3NSAxLjIzNDM4IDcuMjk4ODcgMS4yNjE3MiA3LjQ4Mzc3IDEuMzE2NDFDNy42NzEyNyAxLjM2ODQ5IDcuODM3OTMgMS40NTMxMiA3Ljk4Mzc3IDEuNTcwMzFDOC4xMjk2IDEuNjg0OSA4LjI1MzMgMS44Mzg1NCA4LjM1NDg2IDIuMDMxMjVDOC40NTkwMyAyLjIyMTM1IDguNTM4NDUgMi40NTQ0MyA4LjU5MzE0IDIuNzMwNDdDOC42NDc4MyAzLjAwNjUxIDguNjc1MTcgMy4zMzA3MyA4LjY3NTE3IDMuNzAzMTJaTTcuOTQ4NjEgNC42ODc1VjMuNTgyMDNDNy45NDg2MSAzLjMyNjgyIDcuOTMyOTggMy4xMDI4NiA3LjkwMTczIDIuOTEwMTZDNy44NzMwOSAyLjcxNDg0IDcuODMwMTIgMi41NDgxOCA3Ljc3MjgzIDIuNDEwMTZDNy43MTU1NCAyLjI3MjE0IDcuNjQyNjIgMi4xNjAxNiA3LjU1NDA4IDIuMDc0MjJDNy40NjgxNCAxLjk4ODI4IDcuMzY3ODggMS45MjU3OCA3LjI1MzMgMS44ODY3MkM3LjE0MTMyIDEuODQ1MDUgNy4wMTUwMiAxLjgyNDIyIDYuODc0MzkgMS44MjQyMkM2LjcwMjUyIDEuODI0MjIgNi41NTAxNyAxLjg1Njc3IDYuNDE3MzYgMS45MjE4OEM2LjI4NDU1IDEuOTg0MzggNi4xNzI1NyAyLjA4NDY0IDYuMDgxNDIgMi4yMjI2NkM1Ljk5Mjg4IDIuMzYwNjggNS45MjUxNyAyLjU0MTY3IDUuODc4MyAyLjc2NTYyQzUuODMxNDIgMi45ODk1OCA1LjgwNzk4IDMuMjYxNzIgNS44MDc5OCAzLjU4MjAzVjQuNjg3NUM1LjgwNzk4IDQuOTQyNzEgNS44MjIzMSA1LjE2Nzk3IDUuODUwOTUgNS4zNjMyOEM1Ljg4MjIgNS41NTg1OSA1LjkyNzc4IDUuNzI3ODYgNS45ODc2NyA1Ljg3MTA5QzYuMDQ3NTcgNi4wMTE3MiA2LjEyMDQ4IDYuMTI3NiA2LjIwNjQyIDYuMjE4NzVDNi4yOTIzNiA2LjMwOTkgNi4zOTEzMiA2LjM3NzYgNi41MDMzIDYuNDIxODhDNi42MTc4OCA2LjQ2MzU0IDYuNzQ0MTggNi40ODQzOCA2Ljg4MjIgNi40ODQzOEM3LjA1OTI5IDYuNDg0MzggNy4yMTQyMyA2LjQ1MDUyIDcuMzQ3MDUgNi4zODI4MUM3LjQ3OTg2IDYuMzE1MSA3LjU5MDU0IDYuMjA5NjQgNy42NzkwOCA2LjA2NjQxQzcuNzcwMjIgNS45MjA1NyA3LjgzNzkzIDUuNzM0MzggNy44ODIyIDUuNTA3ODFDNy45MjY0NyA1LjI3ODY1IDcuOTQ4NjEgNS4wMDUyMSA3Ljk0ODYxIDQuNjg3NVpNMTMuMzA3NCAzLjcwMzEyVjQuNTcwMzFDMTMuMzA3NCA1LjAzNjQ2IDEzLjI2NTcgNS40Mjk2OSAxMy4xODI0IDUuNzVDMTMuMDk5IDYuMDcwMzEgMTIuOTc5MyA2LjMyODEyIDEyLjgyMyA2LjUyMzQ0QzEyLjY2NjggNi43MTg3NSAxMi40Nzc5IDYuODYwNjggMTIuMjU2NiA2Ljk0OTIyQzEyLjAzNzggNy4wMzUxNiAxMS43OTA0IDcuMDc4MTIgMTEuNTE0NCA3LjA3ODEyQzExLjI5NTcgNy4wNzgxMiAxMS4wOTM4IDcuMDUwNzggMTAuOTA4OSA2Ljk5NjA5QzEwLjcyNCA2Ljk0MTQxIDEwLjU1NzQgNi44NTQxNyAxMC40MDg5IDYuNzM0MzhDMTAuMjYzMSA2LjYxMTk4IDEwLjEzODEgNi40NTMxMiAxMC4wMzM5IDYuMjU3ODFDOS45Mjk3NyA2LjA2MjUgOS44NTAzNCA1LjgyNTUyIDkuNzk1NjYgNS41NDY4OEM5Ljc0MDk3IDUuMjY4MjMgOS43MTM2MyA0Ljk0MjcxIDkuNzEzNjMgNC41NzAzMVYzLjcwMzEyQzkuNzEzNjMgMy4yMzY5OCA5Ljc1NTI5IDIuODQ2MzUgOS44Mzg2MyAyLjUzMTI1QzkuOTI0NTYgMi4yMTYxNSAxMC4wNDU3IDEuOTYzNTQgMTAuMjAxOSAxLjc3MzQ0QzEwLjM1ODIgMS41ODA3MyAxMC41NDU3IDEuNDQyNzEgMTAuNzY0NCAxLjM1OTM4QzEwLjk4NTggMS4yNzYwNCAxMS4yMzMyIDEuMjM0MzggMTEuNTA2NiAxLjIzNDM4QzExLjcyNzkgMS4yMzQzOCAxMS45MzExIDEuMjYxNzIgMTIuMTE2IDEuMzE2NDFDMTIuMzAzNSAxLjM2ODQ5IDEyLjQ3MDEgMS40NTMxMiAxMi42MTYgMS41NzAzMUMxMi43NjE4IDEuNjg0OSAxMi44ODU1IDEuODM4NTQgMTIuOTg3MSAyLjAzMTI1QzEzLjA5MTIgMi4yMjEzNSAxMy4xNzA3IDIuNDU0NDMgMTMuMjI1MyAyLjczMDQ3QzEzLjI4IDMuMDA2NTEgMTMuMzA3NCAzLjMzMDczIDEzLjMwNzQgMy43MDMxMlpNMTIuNTgwOCA0LjY4NzVWMy41ODIwM0MxMi41ODA4IDMuMzI2ODIgMTIuNTY1MiAzLjEwMjg2IDEyLjUzMzkgMi45MTAxNkMxMi41MDUzIDIuNzE0ODQgMTIuNDYyMyAyLjU0ODE4IDEyLjQwNSAyLjQxMDE2QzEyLjM0NzcgMi4yNzIxNCAxMi4yNzQ4IDIuMTYwMTYgMTIuMTg2MyAyLjA3NDIyQzEyLjEwMDMgMS45ODgyOCAxMi4wMDAxIDEuOTI1NzggMTEuODg1NSAxLjg4NjcyQzExLjc3MzUgMS44NDUwNSAxMS42NDcyIDEuODI0MjIgMTEuNTA2NiAxLjgyNDIyQzExLjMzNDcgMS44MjQyMiAxMS4xODI0IDEuODU2NzcgMTEuMDQ5NiAxLjkyMTg4QzEwLjkxNjggMS45ODQzOCAxMC44MDQ4IDIuMDg0NjQgMTAuNzEzNiAyLjIyMjY2QzEwLjYyNTEgMi4zNjA2OCAxMC41NTc0IDIuNTQxNjcgMTAuNTEwNSAyLjc2NTYyQzEwLjQ2MzYgMi45ODk1OCAxMC40NDAyIDMuMjYxNzIgMTAuNDQwMiAzLjU4MjAzVjQuNjg3NUMxMC40NDAyIDQuOTQyNzEgMTAuNDU0NSA1LjE2Nzk3IDEwLjQ4MzIgNS4zNjMyOEMxMC41MTQ0IDUuNTU4NTkgMTAuNTYgNS43Mjc4NiAxMC42MTk5IDUuODcxMDlDMTAuNjc5OCA2LjAxMTcyIDEwLjc1MjcgNi4xMjc2IDEwLjgzODYgNi4yMTg3NUMxMC45MjQ2IDYuMzA5OSAxMS4wMjM1IDYuMzc3NiAxMS4xMzU1IDYuNDIxODhDMTEuMjUwMSA2LjQ2MzU0IDExLjM3NjQgNi40ODQzOCAxMS41MTQ0IDYuNDg0MzhDMTEuNjkxNSA2LjQ4NDM4IDExLjg0NjQgNi40NTA1MiAxMS45NzkzIDYuMzgyODFDMTIuMTEyMSA2LjMxNTEgMTIuMjIyNyA2LjIwOTY0IDEyLjMxMTMgNi4wNjY0MUMxMi40MDI0IDUuOTIwNTcgMTIuNDcwMSA1LjczNDM4IDEyLjUxNDQgNS41MDc4MUMxMi41NTg3IDUuMjc4NjUgMTIuNTgwOCA1LjAwNTIxIDEyLjU4MDggNC42ODc1Wk0xNC4zMDY4IDIuNzA3MDNWMi40MDYyNUMxNC4zMDY4IDIuMTkwMSAxNC4zNTM2IDEuOTkzNDkgMTQuNDQ3NCAxLjgxNjQxQzE0LjU0MTEgMS42MzkzMiAxNC42NzUzIDEuNDk3NCAxNC44NDk3IDEuMzkwNjJDMTUuMDI0MiAxLjI4Mzg1IDE1LjIzMTIgMS4yMzA0NyAxNS40NzA4IDEuMjMwNDdDMTUuNzE1NiAxLjIzMDQ3IDE1LjkyNCAxLjI4Mzg1IDE2LjA5NTggMS4zOTA2MkMxNi4yNzAzIDEuNDk3NCAxNi40MDQ0IDEuNjM5MzIgMTYuNDk4MiAxLjgxNjQxQzE2LjU5MTkgMS45OTM0OSAxNi42Mzg4IDIuMTkwMSAxNi42Mzg4IDIuNDA2MjVWMi43MDcwM0MxNi42Mzg4IDIuOTE3OTcgMTYuNTkxOSAzLjExMTk4IDE2LjQ5ODIgMy4yODkwNkMxNi40MDcgMy40NjYxNSAxNi4yNzQyIDMuNjA4MDcgMTYuMDk5NyAzLjcxNDg0QzE1LjkyNzkgMy44MjE2MSAxNS43MjA4IDMuODc1IDE1LjQ3ODYgMy44NzVDMTUuMjM2NSAzLjg3NSAxNS4wMjY4IDMuODIxNjEgMTQuODQ5NyAzLjcxNDg0QzE0LjY3NTMgMy42MDgwNyAxNC41NDExIDMuNDY2MTUgMTQuNDQ3NCAzLjI4OTA2QzE0LjM1MzYgMy4xMTE5OCAxNC4zMDY4IDIuOTE3OTcgMTQuMzA2OCAyLjcwNzAzWk0xNC44NDk3IDIuNDA2MjVWMi43MDcwM0MxNC44NDk3IDIuODI2ODIgMTQuODcxOSAyLjk0MDEgMTQuOTE2MSAzLjA0Njg4QzE0Ljk2MyAzLjE1MzY1IDE1LjAzMzMgMy4yNDA4OSAxNS4xMjcxIDMuMzA4NTlDMTUuMjIwOCAzLjM3MzcgMTUuMzM4IDMuNDA2MjUgMTUuNDc4NiAzLjQwNjI1QzE1LjYxOTMgMy40MDYyNSAxNS43MzUyIDMuMzczNyAxNS44MjYzIDMuMzA4NTlDMTUuOTE3NCAzLjI0MDg5IDE1Ljk4NTIgMy4xNTM2NSAxNi4wMjk0IDMuMDQ2ODhDMTYuMDczNyAyLjk0MDEgMTYuMDk1OCAyLjgyNjgyIDE2LjA5NTggMi43MDcwM1YyLjQwNjI1QzE2LjA5NTggMi4yODM4NSAxNi4wNzI0IDIuMTY5MjcgMTYuMDI1NSAyLjA2MjVDMTUuOTgxMiAxLjk1MzEyIDE1LjkxMjIgMS44NjU4OSAxNS44MTg1IDEuODAwNzhDMTUuNzI3MyAxLjczMzA3IDE1LjYxMTUgMS42OTkyMiAxNS40NzA4IDEuNjk5MjJDMTUuMzMyOCAxLjY5OTIyIDE1LjIxNjkgMS43MzMwNyAxNS4xMjMyIDEuODAwNzhDMTUuMDMyIDEuODY1ODkgMTQuOTYzIDEuOTUzMTIgMTQuOTE2MSAyLjA2MjVDMTQuODcxOSAyLjE2OTI3IDE0Ljg0OTcgMi4yODM4NSAxNC44NDk3IDIuNDA2MjVaTTE3LjA3NjMgNS45MTAxNlY1LjYwNTQ3QzE3LjA3NjMgNS4zOTE5MyAxNy4xMjMyIDUuMTk2NjEgMTcuMjE2OSA1LjAxOTUzQzE3LjMxMDcgNC44NDI0NSAxNy40NDQ4IDQuNzAwNTIgMTcuNjE5MyA0LjU5Mzc1QzE3Ljc5MzcgNC40ODY5OCAxOC4wMDA4IDQuNDMzNTkgMTguMjQwNCA0LjQzMzU5QzE4LjQ4NTIgNC40MzM1OSAxOC42OTM1IDQuNDg2OTggMTguODY1NCA0LjU5Mzc1QzE5LjAzOTggNC43MDA1MiAxOS4xNzQgNC44NDI0NSAxOS4yNjc3IDUuMDE5NTNDMTkuMzYxNSA1LjE5NjYxIDE5LjQwODMgNS4zOTE5MyAxOS40MDgzIDUuNjA1NDdWNS45MTAxNkMxOS40MDgzIDYuMTIzNyAxOS4zNjE1IDYuMzE5MDEgMTkuMjY3NyA2LjQ5NjA5QzE5LjE3NjYgNi42NzMxOCAxOS4wNDM3IDYuODE1MSAxOC44NjkzIDYuOTIxODhDMTguNjk3NCA3LjAyODY1IDE4LjQ5MDQgNy4wODIwMyAxOC4yNDgyIDcuMDgyMDNDMTguMDA2IDcuMDgyMDMgMTcuNzk3NyA3LjAyODY1IDE3LjYyMzIgNi45MjE4OEMxNy40NDg3IDYuODE1MSAxNy4zMTMzIDYuNjczMTggMTcuMjE2OSA2LjQ5NjA5QzE3LjEyMzIgNi4zMTkwMSAxNy4wNzYzIDYuMTIzNyAxNy4wNzYzIDUuOTEwMTZaTTE3LjYxOTMgNS42MDU0N1Y1LjkxMDE2QzE3LjYxOTMgNi4wMjk5NSAxNy42NDE0IDYuMTQ0NTMgMTcuNjg1NyA2LjI1MzkxQzE3LjczMjUgNi4zNjA2OCAxNy44MDI5IDYuNDQ3OTIgMTcuODk2NiA2LjUxNTYyQzE3Ljk5MDQgNi41ODA3MyAxOC4xMDc1IDYuNjEzMjggMTguMjQ4MiA2LjYxMzI4QzE4LjM4ODggNi42MTMyOCAxOC41MDQ3IDYuNTgwNzMgMTguNTk1OCA2LjUxNTYyQzE4LjY4OTYgNi40NDc5MiAxOC43NTg2IDYuMzYwNjggMTguODAyOSA2LjI1MzkxQzE4Ljg0NzEgNi4xNDcxNCAxOC44NjkzIDYuMDMyNTUgMTguODY5MyA1LjkxMDE2VjUuNjA1NDdDMTguODY5MyA1LjQ4MzA3IDE4Ljg0NTggNS4zNjg0OSAxOC43OTkgNS4yNjE3MkMxOC43NTQ3IDUuMTU0OTUgMTguNjg1NyA1LjA2OTAxIDE4LjU5MTkgNS4wMDM5MUMxOC41MDA4IDQuOTM2MiAxOC4zODM2IDQuOTAyMzQgMTguMjQwNCA0LjkwMjM0QzE4LjEwMjMgNC45MDIzNCAxNy45ODY1IDQuOTM2MiAxNy44OTI3IDUuMDAzOTFDMTcuODAxNiA1LjA2OTAxIDE3LjczMjUgNS4xNTQ5NSAxNy42ODU3IDUuMjYxNzJDMTcuNjQxNCA1LjM2ODQ5IDE3LjYxOTMgNS40ODMwNyAxNy42MTkzIDUuNjA1NDdaTTE4LjQyIDIuMTIxMDlMMTUuNjQyNyA2LjU2NjQxTDE1LjIzNjUgNi4zMDg1OUwxOC4wMTM4IDEuODYzMjhMMTguNDIgMi4xMjEwOVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPHBhdGggZD0iTTguMDU4NTkgMzMuNjY3N0M4LjA1ODU5IDM0LjAxNDEgNy45Nzc4NiAzNC4zMDgzIDcuODE2NDEgMzQuNTUwNUM3LjY1NzU1IDM0Ljc5MDEgNy40NDE0MSAzNC45NzI0IDcuMTY3OTcgMzUuMDk3NEM2Ljg5NzE0IDM1LjIyMjQgNi41OTExNSAzNS4yODQ5IDYuMjUgMzUuMjg0OUM1LjkwODg1IDM1LjI4NDkgNS42MDE1NiAzNS4yMjI0IDUuMzI4MTIgMzUuMDk3NEM1LjA1NDY5IDM0Ljk3MjQgNC44Mzg1NCAzNC43OTAxIDQuNjc5NjkgMzQuNTUwNUM0LjUyMDgzIDM0LjMwODMgNC40NDE0MSAzNC4wMTQxIDQuNDQxNDEgMzMuNjY3N0M0LjQ0MTQxIDMzLjQ0MTIgNC40ODQzOCAzMy4yMzQxIDQuNTcwMzEgMzMuMDQ2NkM0LjY1ODg1IDMyLjg1NjUgNC43ODI1NSAzMi42OTEyIDQuOTQxNDEgMzIuNTUwNUM1LjEwMjg2IDMyLjQwOTkgNS4yOTI5NyAzMi4zMDE4IDUuNTExNzIgMzIuMjI2M0M1LjczMzA3IDMyLjE0ODIgNS45NzY1NiAzMi4xMDkxIDYuMjQyMTkgMzIuMTA5MUM2LjU5MTE1IDMyLjEwOTEgNi45MDIzNCAzMi4xNzY4IDcuMTc1NzggMzIuMzEyM0M3LjQ0OTIyIDMyLjQ0NTEgNy42NjQwNiAzMi42Mjg3IDcuODIwMzEgMzIuODYzQzcuOTc5MTcgMzMuMDk3NCA4LjA1ODU5IDMzLjM2NTYgOC4wNTg1OSAzMy42Njc3Wk03LjMzMjAzIDMzLjY1MjFDNy4zMzIwMyAzMy40NDEyIDcuMjg2NDYgMzMuMjU1IDcuMTk1MzEgMzMuMDkzNUM3LjEwNDE3IDMyLjkyOTQgNi45NzY1NiAzMi44MDE4IDYuODEyNSAzMi43MTA3QzYuNjQ4NDQgMzIuNjE5NSA2LjQ1ODMzIDMyLjU3NCA2LjI0MjE5IDMyLjU3NEM2LjAyMDgzIDMyLjU3NCA1LjgyOTQzIDMyLjYxOTUgNS42Njc5NyAzMi43MTA3QzUuNTA5MTEgMzIuODAxOCA1LjM4NTQyIDMyLjkyOTQgNS4yOTY4OCAzMy4wOTM1QzUuMjA4MzMgMzMuMjU1IDUuMTY0MDYgMzMuNDQxMiA1LjE2NDA2IDMzLjY1MjFDNS4xNjQwNiAzMy44NzA4IDUuMjA3MDMgMzQuMDU4MyA1LjI5Mjk3IDM0LjIxNDZDNS4zODE1MSAzNC4zNjgyIDUuNTA2NTEgMzQuNDg2NyA1LjY2Nzk3IDM0LjU3MDFDNS44MzIwMyAzNC42NTA4IDYuMDI2MDQgMzQuNjkxMiA2LjI1IDM0LjY5MTJDNi40NzM5NiAzNC42OTEyIDYuNjY2NjcgMzQuNjUwOCA2LjgyODEyIDM0LjU3MDFDNi45ODk1OCAzNC40ODY3IDcuMTEzMjggMzQuMzY4MiA3LjE5OTIyIDM0LjIxNDZDNy4yODc3NiAzNC4wNTgzIDcuMzMyMDMgMzMuODcwOCA3LjMzMjAzIDMzLjY1MjFaTTcuOTI1NzggMzAuOTk5OEM3LjkyNTc4IDMxLjI3NTggNy44NTI4NiAzMS41MjQ1IDcuNzA3MDMgMzEuNzQ1OEM3LjU2MTIgMzEuOTY3MiA3LjM2MTk4IDMyLjE0MTcgNy4xMDkzOCAzMi4yNjkzQzYuODU2NzcgMzIuMzk2OSA2LjU3MDMxIDMyLjQ2MDcgNi4yNSAzMi40NjA3QzUuOTI0NDggMzIuNDYwNyA1LjYzNDExIDMyLjM5NjkgNS4zNzg5MSAzMi4yNjkzQzUuMTI2MyAzMi4xNDE3IDQuOTI4MzkgMzEuOTY3MiA0Ljc4NTE2IDMxLjc0NThDNC42NDE5MyAzMS41MjQ1IDQuNTcwMzEgMzEuMjc1OCA0LjU3MDMxIDMwLjk5OThDNC41NzAzMSAzMC42NjkgNC42NDE5MyAzMC4zODc4IDQuNzg1MTYgMzAuMTU2QzQuOTMwOTkgMjkuOTI0MiA1LjEzMDIxIDI5Ljc0NzIgNS4zODI4MSAyOS42MjQ4QzUuNjM1NDIgMjkuNTAyNCA1LjkyMzE4IDI5LjQ0MTIgNi4yNDYwOSAyOS40NDEyQzYuNTcxNjEgMjkuNDQxMiA2Ljg2MDY4IDI5LjUwMjQgNy4xMTMyOCAyOS42MjQ4QzcuMzY1ODkgMjkuNzQ3MiA3LjU2MzggMjkuOTI0MiA3LjcwNzAzIDMwLjE1NkM3Ljg1Mjg2IDMwLjM4NzggNy45MjU3OCAzMC42NjkgNy45MjU3OCAzMC45OTk4Wk03LjIwMzEyIDMxLjAxMTVDNy4yMDMxMiAzMC44MjE0IDcuMTYyNzYgMzAuNjUzNCA3LjA4MjAzIDMwLjUwNzZDNy4wMDEzIDMwLjM2MTcgNi44ODkzMiAzMC4yNDcyIDYuNzQ2MDkgMzAuMTYzOEM2LjYwMjg2IDMwLjA3NzkgNi40MzYyIDMwLjAzNDkgNi4yNDYwOSAzMC4wMzQ5QzYuMDU1OTkgMzAuMDM0OSA1Ljg4OTMyIDMwLjA3NTMgNS43NDYwOSAzMC4xNTZDNS42MDU0NyAzMC4yMzQxIDUuNDk0NzkgMzAuMzQ2MSA1LjQxNDA2IDMwLjQ5MTlDNS4zMzU5NCAzMC42Mzc4IDUuMjk2ODggMzAuODExIDUuMjk2ODggMzEuMDExNUM1LjI5Njg4IDMxLjIwNjggNS4zMzU5NCAzMS4zNzc0IDUuNDE0MDYgMzEuNTIzMkM1LjQ5NDc5IDMxLjY2OSA1LjYwNjc3IDMxLjc4MjMgNS43NSAzMS44NjNDNS44OTMyMyAzMS45NDM4IDYuMDU5OSAzMS45ODQxIDYuMjUgMzEuOTg0MUM2LjQ0MDEgMzEuOTg0MSA2LjYwNTQ3IDMxLjk0MzggNi43NDYwOSAzMS44NjNDNi44ODkzMiAzMS43ODIzIDcuMDAxMyAzMS42NjkgNy4wODIwMyAzMS41MjMyQzcuMTYyNzYgMzEuMzc3NCA3LjIwMzEyIDMxLjIwNjggNy4yMDMxMiAzMS4wMTE1Wk0xMi42NzUyIDMxLjkwOTlWMzIuNzc3MUMxMi42NzUyIDMzLjI0MzIgMTIuNjMzNSAzMy42MzY1IDEyLjU1MDIgMzMuOTU2OEMxMi40NjY4IDM0LjI3NzEgMTIuMzQ3IDM0LjUzNDkgMTIuMTkwOCAzNC43MzAyQzEyLjAzNDUgMzQuOTI1NSAxMS44NDU3IDM1LjA2NzUgMTEuNjI0NCAzNS4xNTZDMTEuNDA1NiAzNS4yNDE5IDExLjE1ODIgMzUuMjg0OSAxMC44ODIyIDM1LjI4NDlDMTAuNjYzNSAzNS4yODQ5IDEwLjQ2MTYgMzUuMjU3NiAxMC4yNzY3IDM1LjIwMjlDMTAuMDkxOCAzNS4xNDgyIDkuOTI1MTcgMzUuMDYxIDkuNzc2NzMgMzQuOTQxMkM5LjYzMDkgMzQuODE4OCA5LjUwNTkgMzQuNjU5OSA5LjQwMTczIDM0LjQ2NDZDOS4yOTc1NyAzNC4yNjkzIDkuMjE4MTQgMzQuMDMyMyA5LjE2MzQ1IDMzLjc1MzdDOS4xMDg3NyAzMy40NzUgOS4wODE0MiAzMy4xNDk1IDkuMDgxNDIgMzIuNzc3MVYzMS45MDk5QzkuMDgxNDIgMzEuNDQzOCA5LjEyMzA5IDMxLjA1MzEgOS4yMDY0MiAzMC43MzhDOS4yOTIzNiAzMC40MjI5IDkuNDEzNDUgMzAuMTcwMyA5LjU2OTcgMjkuOTgwMkM5LjcyNTk1IDI5Ljc4NzUgOS45MTM0NSAyOS42NDk1IDEwLjEzMjIgMjkuNTY2MkMxMC4zNTM2IDI5LjQ4MjggMTAuNjAxIDI5LjQ0MTIgMTAuODc0NCAyOS40NDEyQzExLjA5NTcgMjkuNDQxMiAxMS4yOTg5IDI5LjQ2ODUgMTEuNDgzOCAyOS41MjMyQzExLjY3MTMgMjkuNTc1MyAxMS44Mzc5IDI5LjY1OTkgMTEuOTgzOCAyOS43NzcxQzEyLjEyOTYgMjkuODkxNyAxMi4yNTMzIDMwLjA0NTMgMTIuMzU0OSAzMC4yMzhDMTIuNDU5IDMwLjQyODEgMTIuNTM4NSAzMC42NjEyIDEyLjU5MzEgMzAuOTM3M0MxMi42NDc4IDMxLjIxMzMgMTIuNjc1MiAzMS41Mzc1IDEyLjY3NTIgMzEuOTA5OVpNMTEuOTQ4NiAzMi44OTQzVjMxLjc4ODhDMTEuOTQ4NiAzMS41MzM2IDExLjkzMyAzMS4zMDk3IDExLjkwMTcgMzEuMTE2OUMxMS44NzMxIDMwLjkyMTYgMTEuODMwMSAzMC43NTUgMTEuNzcyOCAzMC42MTY5QzExLjcxNTUgMzAuNDc4OSAxMS42NDI2IDMwLjM2NjkgMTEuNTU0MSAzMC4yODFDMTEuNDY4MSAzMC4xOTUxIDExLjM2NzkgMzAuMTMyNiAxMS4yNTMzIDMwLjA5MzVDMTEuMTQxMyAzMC4wNTE4IDExLjAxNSAzMC4wMzEgMTAuODc0NCAzMC4wMzFDMTAuNzAyNSAzMC4wMzEgMTAuNTUwMiAzMC4wNjM2IDEwLjQxNzQgMzAuMTI4N0MxMC4yODQ1IDMwLjE5MTIgMTAuMTcyNiAzMC4yOTE0IDEwLjA4MTQgMzAuNDI5NEM5Ljk5Mjg4IDMwLjU2NzUgOS45MjUxNyAzMC43NDg1IDkuODc4MyAzMC45NzI0QzkuODMxNDIgMzEuMTk2NCA5LjgwNzk4IDMxLjQ2ODUgOS44MDc5OCAzMS43ODg4VjMyLjg5NDNDOS44MDc5OCAzMy4xNDk1IDkuODIyMzEgMzMuMzc0OCA5Ljg1MDk1IDMzLjU3MDFDOS44ODIyIDMzLjc2NTQgOS45Mjc3OCAzMy45MzQ3IDkuOTg3NjcgMzQuMDc3OUMxMC4wNDc2IDM0LjIxODUgMTAuMTIwNSAzNC4zMzQ0IDEwLjIwNjQgMzQuNDI1NUMxMC4yOTI0IDM0LjUxNjcgMTAuMzkxMyAzNC41ODQ0IDEwLjUwMzMgMzQuNjI4N0MxMC42MTc5IDM0LjY3MDMgMTAuNzQ0MiAzNC42OTEyIDEwLjg4MjIgMzQuNjkxMkMxMS4wNTkzIDM0LjY5MTIgMTEuMjE0MiAzNC42NTczIDExLjM0NyAzNC41ODk2QzExLjQ3OTkgMzQuNTIxOSAxMS41OTA1IDM0LjQxNjQgMTEuNjc5MSAzNC4yNzMyQzExLjc3MDIgMzQuMTI3NCAxMS44Mzc5IDMzLjk0MTIgMTEuODgyMiAzMy43MTQ2QzExLjkyNjUgMzMuNDg1NCAxMS45NDg2IDMzLjIxMiAxMS45NDg2IDMyLjg5NDNaTTEzLjY3NDYgMzAuOTEzOFYzMC42MTNDMTMuNjc0NiAzMC4zOTY5IDEzLjcyMTQgMzAuMjAwMyAxMy44MTUyIDMwLjAyMzJDMTMuOTA4OSAyOS44NDYxIDE0LjA0MzEgMjkuNzA0MiAxNC4yMTc1IDI5LjU5NzRDMTQuMzkyIDI5LjQ5MDYgMTQuNTk5IDI5LjQzNzMgMTQuODM4NiAyOS40MzczQzE1LjA4MzQgMjkuNDM3MyAxNS4yOTE4IDI5LjQ5MDYgMTUuNDYzNiAyOS41OTc0QzE1LjYzODEgMjkuNzA0MiAxNS43NzIyIDI5Ljg0NjEgMTUuODY2IDMwLjAyMzJDMTUuOTU5NyAzMC4yMDAzIDE2LjAwNjYgMzAuMzk2OSAxNi4wMDY2IDMwLjYxM1YzMC45MTM4QzE2LjAwNjYgMzEuMTI0OCAxNS45NTk3IDMxLjMxODggMTUuODY2IDMxLjQ5NThDMTUuNzc0OCAzMS42NzI5IDE1LjY0MiAzMS44MTQ5IDE1LjQ2NzUgMzEuOTIxNkMxNS4yOTU3IDMyLjAyODQgMTUuMDg4NiAzMi4wODE4IDE0Ljg0NjQgMzIuMDgxOEMxNC42MDQzIDMyLjA4MTggMTQuMzk0NiAzMi4wMjg0IDE0LjIxNzUgMzEuOTIxNkMxNC4wNDMxIDMxLjgxNDkgMTMuOTA4OSAzMS42NzI5IDEzLjgxNTIgMzEuNDk1OEMxMy43MjE0IDMxLjMxODggMTMuNjc0NiAzMS4xMjQ4IDEzLjY3NDYgMzAuOTEzOFpNMTQuMjE3NSAzMC42MTNWMzAuOTEzOEMxNC4yMTc1IDMxLjAzMzYgMTQuMjM5NyAzMS4xNDY5IDE0LjI4MzkgMzEuMjUzN0MxNC4zMzA4IDMxLjM2MDQgMTQuNDAxMSAzMS40NDc3IDE0LjQ5NDkgMzEuNTE1NEMxNC41ODg2IDMxLjU4MDUgMTQuNzA1OCAzMS42MTMgMTQuODQ2NCAzMS42MTNDMTQuOTg3MSAzMS42MTMgMTUuMTAyOSAzMS41ODA1IDE1LjE5NDEgMzEuNTE1NEMxNS4yODUyIDMxLjQ0NzcgMTUuMzUyOSAzMS4zNjA0IDE1LjM5NzIgMzEuMjUzN0MxNS40NDE1IDMxLjE0NjkgMTUuNDYzNiAzMS4wMzM2IDE1LjQ2MzYgMzAuOTEzOFYzMC42MTNDMTUuNDYzNiAzMC40OTA2IDE1LjQ0MDIgMzAuMzc2MSAxNS4zOTMzIDMwLjI2OTNDMTUuMzQ5IDMwLjE1OTkgMTUuMjggMzAuMDcyNyAxNS4xODYzIDMwLjAwNzZDMTUuMDk1MSAyOS45Mzk5IDE0Ljk3OTMgMjkuOTA2IDE0LjgzODYgMjkuOTA2QzE0LjcwMDYgMjkuOTA2IDE0LjU4NDcgMjkuOTM5OSAxNC40OTEgMzAuMDA3NkMxNC4zOTk4IDMwLjA3MjcgMTQuMzMwOCAzMC4xNTk5IDE0LjI4MzkgMzAuMjY5M0MxNC4yMzk3IDMwLjM3NjEgMTQuMjE3NSAzMC40OTA2IDE0LjIxNzUgMzAuNjEzWk0xNi40NDQxIDM0LjExNjlWMzMuODEyM0MxNi40NDQxIDMzLjU5ODcgMTYuNDkxIDMzLjQwMzQgMTYuNTg0NyAzMy4yMjYzQzE2LjY3ODUgMzMuMDQ5MiAxNi44MTI2IDMyLjkwNzMgMTYuOTg3MSAzMi44MDA1QzE3LjE2MTUgMzIuNjkzOCAxNy4zNjg2IDMyLjY0MDQgMTcuNjA4MiAzMi42NDA0QzE3Ljg1MjkgMzIuNjQwNCAxOC4wNjEzIDMyLjY5MzggMTguMjMzMiAzMi44MDA1QzE4LjQwNzYgMzIuOTA3MyAxOC41NDE4IDMzLjA0OTIgMTguNjM1NSAzMy4yMjYzQzE4LjcyOTMgMzMuNDAzNCAxOC43NzYxIDMzLjU5ODcgMTguNzc2MSAzMy44MTIzVjM0LjExNjlDMTguNzc2MSAzNC4zMzA1IDE4LjcyOTMgMzQuNTI1OCAxOC42MzU1IDM0LjcwMjlDMTguNTQ0NCAzNC44OCAxOC40MTE1IDM1LjAyMTkgMTguMjM3MSAzNS4xMjg3QzE4LjA2NTIgMzUuMjM1NCAxNy44NTgyIDM1LjI4ODggMTcuNjE2IDM1LjI4ODhDMTcuMzczOCAzNS4yODg4IDE3LjE2NTQgMzUuMjM1NCAxNi45OTEgMzUuMTI4N0MxNi44MTY1IDM1LjAyMTkgMTYuNjgxMSAzNC44OCAxNi41ODQ3IDM0LjcwMjlDMTYuNDkxIDM0LjUyNTggMTYuNDQ0MSAzNC4zMzA1IDE2LjQ0NDEgMzQuMTE2OVpNMTYuOTg3MSAzMy44MTIzVjM0LjExNjlDMTYuOTg3MSAzNC4yMzY3IDE3LjAwOTIgMzQuMzUxMyAxNy4wNTM1IDM0LjQ2MDdDMTcuMTAwMyAzNC41Njc1IDE3LjE3MDcgMzQuNjU0NyAxNy4yNjQ0IDM0LjcyMjRDMTcuMzU4MiAzNC43ODc1IDE3LjQ3NTMgMzQuODIwMSAxNy42MTYgMzQuODIwMUMxNy43NTY2IDM0LjgyMDEgMTcuODcyNSAzNC43ODc1IDE3Ljk2MzYgMzQuNzIyNEMxOC4wNTc0IDM0LjY1NDcgMTguMTI2NCAzNC41Njc1IDE4LjE3MDcgMzQuNDYwN0MxOC4yMTQ5IDM0LjM1MzkgMTguMjM3MSAzNC4yMzkzIDE4LjIzNzEgMzQuMTE2OVYzMy44MTIzQzE4LjIzNzEgMzMuNjg5OSAxOC4yMTM2IDMzLjU3NTMgMTguMTY2OCAzMy40Njg1QzE4LjEyMjUgMzMuMzYxNyAxOC4wNTM1IDMzLjI3NTggMTcuOTU5NyAzMy4yMTA3QzE3Ljg2ODYgMzMuMTQzIDE3Ljc1MTQgMzMuMTA5MSAxNy42MDgyIDMzLjEwOTFDMTcuNDcwMSAzMy4xMDkxIDE3LjM1NDMgMzMuMTQzIDE3LjI2MDUgMzMuMjEwN0MxNy4xNjk0IDMzLjI3NTggMTcuMTAwMyAzMy4zNjE3IDE3LjA1MzUgMzMuNDY4NUMxNy4wMDkyIDMzLjU3NTMgMTYuOTg3MSAzMy42ODk5IDE2Ljk4NzEgMzMuODEyM1pNMTcuNzg3OCAzMC4zMjc5TDE1LjAxMDUgMzQuNzczMkwxNC42MDQzIDM0LjUxNTRMMTcuMzgxNiAzMC4wNzAxTDE3Ljc4NzggMzAuMzI3OVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPHBhdGggZD0iTTcuMjQ2MDkgNTcuNzE4M0g3LjMwODU5VjU4LjMzMTVINy4yNDYwOUM2Ljg2MzI4IDU4LjMzMTUgNi41NDI5NyA1OC4zOTQgNi4yODUxNiA1OC41MTlDNi4wMjczNCA1OC42NDE0IDUuODIyOTIgNTguODA2OCA1LjY3MTg4IDU5LjAxNTFDNS41MjA4MyA1OS4yMjA5IDUuNDExNDYgNTkuNDUyNiA1LjM0Mzc1IDU5LjcxMDRDNS4yNzg2NSA1OS45NjgzIDUuMjQ2MDkgNjAuMjMgNS4yNDYwOSA2MC40OTU2VjYxLjMzMTVDNS4yNDYwOSA2MS41ODQxIDUuMjc2MDQgNjEuODA4MSA1LjMzNTk0IDYyLjAwMzRDNS4zOTU4MyA2Mi4xOTYxIDUuNDc3ODYgNjIuMzU4OSA1LjU4MjAzIDYyLjQ5MTdDNS42ODYyIDYyLjYyNDUgNS44MDMzOSA2Mi43MjQ4IDUuOTMzNTkgNjIuNzkyNUM2LjA2NjQxIDYyLjg2MDIgNi4yMDQ0MyA2Mi44OTQgNi4zNDc2NiA2Mi44OTRDNi41MTQzMiA2Mi44OTQgNi42NjI3NiA2Mi44NjI4IDYuNzkyOTcgNjIuODAwM0M2LjkyMzE4IDYyLjczNTIgNy4wMzI1NSA2Mi42NDUzIDcuMTIxMDkgNjIuNTMwOEM3LjIxMjI0IDYyLjQxMzYgNy4yODEyNSA2Mi4yNzU2IDcuMzI4MTIgNjIuMTE2N0M3LjM3NSA2MS45NTc4IDcuMzk4NDQgNjEuNzgzNCA3LjM5ODQ0IDYxLjU5MzNDNy4zOTg0NCA2MS40MjQgNy4zNzc2IDYxLjI2MTIgNy4zMzU5NCA2MS4xMDVDNy4yOTQyNyA2MC45NDYxIDcuMjMwNDcgNjAuODA1NSA3LjE0NDUzIDYwLjY4MzFDNy4wNTg1OSA2MC41NTgxIDYuOTUwNTIgNjAuNDYwNCA2LjgyMDMxIDYwLjM5MDFDNi42OTI3MSA2MC4zMTcyIDYuNTQwMzYgNjAuMjgwOCA2LjM2MzI4IDYwLjI4MDhDNi4xNjI3NiA2MC4yODA4IDUuOTc1MjYgNjAuMzMwMiA1LjgwMDc4IDYwLjQyOTJDNS42Mjg5MSA2MC41MjU2IDUuNDg2OTggNjAuNjUzMiA1LjM3NSA2MC44MTJDNS4yNjU2MiA2MC45NjgzIDUuMjAzMTIgNjEuMTM4OCA1LjE4NzUgNjEuMzIzN0w0LjgwNDY5IDYxLjMxOThDNC44NDExNSA2MS4wMjgyIDQuOTA4ODUgNjAuNzc5NSA1LjAwNzgxIDYwLjU3MzdDNS4xMDkzOCA2MC4zNjU0IDUuMjM0MzggNjAuMTk2MSA1LjM4MjgxIDYwLjA2NTlDNS41MzM4NSA1OS45MzMxIDUuNzAxODIgNTkuODM2OCA1Ljg4NjcyIDU5Ljc3NjlDNi4wNzQyMiA1OS43MTQ0IDYuMjcyMTQgNTkuNjgzMSA2LjQ4MDQ3IDU5LjY4MzFDNi43NjQzMiA1OS42ODMxIDcuMDA5MTEgNTkuNzM2NSA3LjIxNDg0IDU5Ljg0MzNDNy40MjA1NyA1OS45NSA3LjU4OTg0IDYwLjA5MzMgNy43MjI2NiA2MC4yNzI5QzcuODU1NDcgNjAuNDUgNy45NTMxMiA2MC42NTA2IDguMDE1NjIgNjAuODc0NUM4LjA4MDczIDYxLjA5NTkgOC4xMTMyOCA2MS4zMjM3IDguMTEzMjggNjEuNTU4MUM4LjExMzI4IDYxLjgyNjMgOC4wNzU1MiA2Mi4wNzc2IDggNjIuMzEyQzcuOTI0NDggNjIuNTQ2NCA3LjgxMTIgNjIuNzUyMSA3LjY2MDE2IDYyLjkyOTJDNy41MTE3MiA2My4xMDYzIDcuMzI4MTIgNjMuMjQ0MyA3LjEwOTM4IDYzLjM0MzNDNi44OTA2MiA2My40NDIyIDYuNjM2NzIgNjMuNDkxNyA2LjM0NzY2IDYzLjQ5MTdDNi4wNDAzNiA2My40OTE3IDUuNzcyMTQgNjMuNDI5MiA1LjU0Mjk3IDYzLjMwNDJDNS4zMTM4IDYzLjE3NjYgNS4xMjM3IDYzLjAwNzMgNC45NzI2NiA2Mi43OTY0QzQuODIxNjEgNjIuNTg1NCA0LjcwODMzIDYyLjM1MTEgNC42MzI4MSA2Mi4wOTMzQzQuNTU3MjkgNjEuODM1NCA0LjUxOTUzIDYxLjU3MzcgNC41MTk1MyA2MS4zMDgxVjYwLjk2ODNDNC41MTk1MyA2MC41NjcyIDQuNTU5OSA2MC4xNzQgNC42NDA2MiA1OS43ODg2QzQuNzIxMzUgNTkuNDAzMiA0Ljg2MDY4IDU5LjA1NDIgNS4wNTg1OSA1OC43NDE3QzUuMjU5MTEgNTguNDI5MiA1LjUzNjQ2IDU4LjE4MDUgNS44OTA2MiA1Ny45OTU2QzYuMjQ0NzkgNTcuODEwNyA2LjY5NjYxIDU3LjcxODMgNy4yNDYwOSA1Ny43MTgzWk0xMi42NzUyIDYwLjExNjdWNjAuOTgzOUMxMi42NzUyIDYxLjQ1IDEyLjYzMzUgNjEuODQzMyAxMi41NTAyIDYyLjE2MzZDMTIuNDY2OCA2Mi40ODM5IDEyLjM0NyA2Mi43NDE3IDEyLjE5MDggNjIuOTM3QzEyLjAzNDUgNjMuMTMyMyAxMS44NDU3IDYzLjI3NDMgMTEuNjI0NCA2My4zNjI4QzExLjQwNTYgNjMuNDQ4NyAxMS4xNTgyIDYzLjQ5MTcgMTAuODgyMiA2My40OTE3QzEwLjY2MzUgNjMuNDkxNyAxMC40NjE2IDYzLjQ2NDQgMTAuMjc2NyA2My40MDk3QzEwLjA5MTggNjMuMzU1IDkuOTI1MTcgNjMuMjY3NyA5Ljc3NjczIDYzLjE0NzlDOS42MzA5IDYzLjAyNTYgOS41MDU5IDYyLjg2NjcgOS40MDE3MyA2Mi42NzE0QzkuMjk3NTcgNjIuNDc2MSA5LjIxODE0IDYyLjIzOTEgOS4xNjM0NSA2MS45NjA0QzkuMTA4NzcgNjEuNjgxOCA5LjA4MTQyIDYxLjM1NjMgOS4wODE0MiA2MC45ODM5VjYwLjExNjdDOS4wODE0MiA1OS42NTA2IDkuMTIzMDkgNTkuMjU5OSA5LjIwNjQyIDU4Ljk0NDhDOS4yOTIzNiA1OC42Mjk3IDkuNDEzNDUgNTguMzc3MSA5LjU2OTcgNTguMTg3QzkuNzI1OTUgNTcuOTk0MyA5LjkxMzQ1IDU3Ljg1NjMgMTAuMTMyMiA1Ny43NzI5QzEwLjM1MzYgNTcuNjg5NiAxMC42MDEgNTcuNjQ3OSAxMC44NzQ0IDU3LjY0NzlDMTEuMDk1NyA1Ny42NDc5IDExLjI5ODkgNTcuNjc1MyAxMS40ODM4IDU3LjczQzExLjY3MTMgNTcuNzgyMSAxMS44Mzc5IDU3Ljg2NjcgMTEuOTgzOCA1Ny45ODM5QzEyLjEyOTYgNTguMDk4NSAxMi4yNTMzIDU4LjI1MjEgMTIuMzU0OSA1OC40NDQ4QzEyLjQ1OSA1OC42MzQ5IDEyLjUzODUgNTguODY4IDEyLjU5MzEgNTkuMTQ0QzEyLjY0NzggNTkuNDIwMSAxMi42NzUyIDU5Ljc0NDMgMTIuNjc1MiA2MC4xMTY3Wk0xMS45NDg2IDYxLjEwMTFWNTkuOTk1NkMxMS45NDg2IDU5Ljc0MDQgMTEuOTMzIDU5LjUxNjQgMTEuOTAxNyA1OS4zMjM3QzExLjg3MzEgNTkuMTI4NCAxMS44MzAxIDU4Ljk2MTggMTEuNzcyOCA1OC44MjM3QzExLjcxNTUgNTguNjg1NyAxMS42NDI2IDU4LjU3MzcgMTEuNTU0MSA1OC40ODc4QzExLjQ2ODEgNTguNDAxOSAxMS4zNjc5IDU4LjMzOTQgMTEuMjUzMyA1OC4zMDAzQzExLjE0MTMgNTguMjU4NiAxMS4wMTUgNTguMjM3OCAxMC44NzQ0IDU4LjIzNzhDMTAuNzAyNSA1OC4yMzc4IDEwLjU1MDIgNTguMjcwMyAxMC40MTc0IDU4LjMzNTRDMTAuMjg0NSA1OC4zOTc5IDEwLjE3MjYgNTguNDk4MiAxMC4wODE0IDU4LjYzNjJDOS45OTI4OCA1OC43NzQzIDkuOTI1MTcgNTguOTU1MiA5Ljg3ODMgNTkuMTc5MkM5LjgzMTQyIDU5LjQwMzIgOS44MDc5OCA1OS42NzUzIDkuODA3OTggNTkuOTk1NlY2MS4xMDExQzkuODA3OTggNjEuMzU2MyA5LjgyMjMxIDYxLjU4MTUgOS44NTA5NSA2MS43NzY5QzkuODgyMiA2MS45NzIyIDkuOTI3NzggNjIuMTQxNCA5Ljk4NzY3IDYyLjI4NDdDMTAuMDQ3NiA2Mi40MjUzIDEwLjEyMDUgNjIuNTQxMiAxMC4yMDY0IDYyLjYzMjNDMTAuMjkyNCA2Mi43MjM1IDEwLjM5MTMgNjIuNzkxMiAxMC41MDMzIDYyLjgzNTRDMTAuNjE3OSA2Mi44NzcxIDEwLjc0NDIgNjIuODk3OSAxMC44ODIyIDYyLjg5NzlDMTEuMDU5MyA2Mi44OTc5IDExLjIxNDIgNjIuODY0MSAxMS4zNDcgNjIuNzk2NEMxMS40Nzk5IDYyLjcyODcgMTEuNTkwNSA2Mi42MjMyIDExLjY3OTEgNjIuNDhDMTEuNzcwMiA2Mi4zMzQxIDExLjgzNzkgNjIuMTQ3OSAxMS44ODIyIDYxLjkyMTRDMTEuOTI2NSA2MS42OTIyIDExLjk0ODYgNjEuNDE4OCAxMS45NDg2IDYxLjEwMTFaTTEzLjY3NDYgNTkuMTIwNlY1OC44MTk4QzEzLjY3NDYgNTguNjAzNyAxMy43MjE0IDU4LjQwNzEgMTMuODE1MiA1OC4yM0MxMy45MDg5IDU4LjA1MjkgMTQuMDQzMSA1Ny45MTEgMTQuMjE3NSA1Ny44MDQyQzE0LjM5MiA1Ny42OTc0IDE0LjU5OSA1Ny42NDQgMTQuODM4NiA1Ny42NDRDMTUuMDgzNCA1Ny42NDQgMTUuMjkxOCA1Ny42OTc0IDE1LjQ2MzYgNTcuODA0MkMxNS42MzgxIDU3LjkxMSAxNS43NzIyIDU4LjA1MjkgMTUuODY2IDU4LjIzQzE1Ljk1OTcgNTguNDA3MSAxNi4wMDY2IDU4LjYwMzcgMTYuMDA2NiA1OC44MTk4VjU5LjEyMDZDMTYuMDA2NiA1OS4zMzE1IDE1Ljk1OTcgNTkuNTI1NiAxNS44NjYgNTkuNzAyNkMxNS43NzQ4IDU5Ljg3OTcgMTUuNjQyIDYwLjAyMTYgMTUuNDY3NSA2MC4xMjg0QzE1LjI5NTcgNjAuMjM1MiAxNS4wODg2IDYwLjI4ODYgMTQuODQ2NCA2MC4yODg2QzE0LjYwNDMgNjAuMjg4NiAxNC4zOTQ2IDYwLjIzNTIgMTQuMjE3NSA2MC4xMjg0QzE0LjA0MzEgNjAuMDIxNiAxMy45MDg5IDU5Ljg3OTcgMTMuODE1MiA1OS43MDI2QzEzLjcyMTQgNTkuNTI1NiAxMy42NzQ2IDU5LjMzMTUgMTMuNjc0NiA1OS4xMjA2Wk0xNC4yMTc1IDU4LjgxOThWNTkuMTIwNkMxNC4yMTc1IDU5LjI0MDQgMTQuMjM5NyA1OS4zNTM3IDE0LjI4MzkgNTkuNDYwNEMxNC4zMzA4IDU5LjU2NzIgMTQuNDAxMSA1OS42NTQ1IDE0LjQ5NDkgNTkuNzIyMkMxNC41ODg2IDU5Ljc4NzMgMTQuNzA1OCA1OS44MTk4IDE0Ljg0NjQgNTkuODE5OEMxNC45ODcxIDU5LjgxOTggMTUuMTAyOSA1OS43ODczIDE1LjE5NDEgNTkuNzIyMkMxNS4yODUyIDU5LjY1NDUgMTUuMzUyOSA1OS41NjcyIDE1LjM5NzIgNTkuNDYwNEMxNS40NDE1IDU5LjM1MzcgMTUuNDYzNiA1OS4yNDA0IDE1LjQ2MzYgNTkuMTIwNlY1OC44MTk4QzE1LjQ2MzYgNTguNjk3NCAxNS40NDAyIDU4LjU4MjggMTUuMzkzMyA1OC40NzYxQzE1LjM0OSA1OC4zNjY3IDE1LjI4IDU4LjI3OTUgMTUuMTg2MyA1OC4yMTQ0QzE1LjA5NTEgNTguMTQ2NiAxNC45NzkzIDU4LjExMjggMTQuODM4NiA1OC4xMTI4QzE0LjcwMDYgNTguMTEyOCAxNC41ODQ3IDU4LjE0NjYgMTQuNDkxIDU4LjIxNDRDMTQuMzk5OCA1OC4yNzk1IDE0LjMzMDggNTguMzY2NyAxNC4yODM5IDU4LjQ3NjFDMTQuMjM5NyA1OC41ODI4IDE0LjIxNzUgNTguNjk3NCAxNC4yMTc1IDU4LjgxOThaTTE2LjQ0NDEgNjIuMzIzN1Y2Mi4wMTlDMTYuNDQ0MSA2MS44MDU1IDE2LjQ5MSA2MS42MTAyIDE2LjU4NDcgNjEuNDMzMUMxNi42Nzg1IDYxLjI1NiAxNi44MTI2IDYxLjExNDEgMTYuOTg3MSA2MS4wMDczQzE3LjE2MTUgNjAuOTAwNiAxNy4zNjg2IDYwLjg0NzIgMTcuNjA4MiA2MC44NDcyQzE3Ljg1MjkgNjAuODQ3MiAxOC4wNjEzIDYwLjkwMDYgMTguMjMzMiA2MS4wMDczQzE4LjQwNzYgNjEuMTE0MSAxOC41NDE4IDYxLjI1NiAxOC42MzU1IDYxLjQzMzFDMTguNzI5MyA2MS42MTAyIDE4Ljc3NjEgNjEuODA1NSAxOC43NzYxIDYyLjAxOVY2Mi4zMjM3QzE4Ljc3NjEgNjIuNTM3MyAxOC43MjkzIDYyLjczMjYgMTguNjM1NSA2Mi45MDk3QzE4LjU0NDQgNjMuMDg2OCAxOC40MTE1IDYzLjIyODcgMTguMjM3MSA2My4zMzU0QzE4LjA2NTIgNjMuNDQyMiAxNy44NTgyIDYzLjQ5NTYgMTcuNjE2IDYzLjQ5NTZDMTcuMzczOCA2My40OTU2IDE3LjE2NTQgNjMuNDQyMiAxNi45OTEgNjMuMzM1NEMxNi44MTY1IDYzLjIyODcgMTYuNjgxMSA2My4wODY4IDE2LjU4NDcgNjIuOTA5N0MxNi40OTEgNjIuNzMyNiAxNi40NDQxIDYyLjUzNzMgMTYuNDQ0MSA2Mi4zMjM3Wk0xNi45ODcxIDYyLjAxOVY2Mi4zMjM3QzE2Ljk4NzEgNjIuNDQzNSAxNy4wMDkyIDYyLjU1ODEgMTcuMDUzNSA2Mi42Njc1QzE3LjEwMDMgNjIuNzc0MyAxNy4xNzA3IDYyLjg2MTUgMTcuMjY0NCA2Mi45MjkyQzE3LjM1ODIgNjIuOTk0MyAxNy40NzUzIDYzLjAyNjkgMTcuNjE2IDYzLjAyNjlDMTcuNzU2NiA2My4wMjY5IDE3Ljg3MjUgNjIuOTk0MyAxNy45NjM2IDYyLjkyOTJDMTguMDU3NCA2Mi44NjE1IDE4LjEyNjQgNjIuNzc0MyAxOC4xNzA3IDYyLjY2NzVDMTguMjE0OSA2Mi41NjA3IDE4LjIzNzEgNjIuNDQ2MSAxOC4yMzcxIDYyLjMyMzdWNjIuMDE5QzE4LjIzNzEgNjEuODk2NiAxOC4yMTM2IDYxLjc4MjEgMTguMTY2OCA2MS42NzUzQzE4LjEyMjUgNjEuNTY4NSAxOC4wNTM1IDYxLjQ4MjYgMTcuOTU5NyA2MS40MTc1QzE3Ljg2ODYgNjEuMzQ5OCAxNy43NTE0IDYxLjMxNTkgMTcuNjA4MiA2MS4zMTU5QzE3LjQ3MDEgNjEuMzE1OSAxNy4zNTQzIDYxLjM0OTggMTcuMjYwNSA2MS40MTc1QzE3LjE2OTQgNjEuNDgyNiAxNy4xMDAzIDYxLjU2ODUgMTcuMDUzNSA2MS42NzUzQzE3LjAwOTIgNjEuNzgyMSAxNi45ODcxIDYxLjg5NjYgMTYuOTg3MSA2Mi4wMTlaTTE3Ljc4NzggNTguNTM0N0wxNS4wMTA1IDYyLjk4TDE0LjYwNDMgNjIuNzIyMkwxNy4zODE2IDU4LjI3NjlMMTcuNzg3OCA1OC41MzQ3WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8cGF0aCBkPSJNOC4zMTY0MSA4OS43MDYzVjkwLjNINC4yMDcwM1Y4OS44NzQzTDYuNzUzOTEgODUuOTMyOUg3LjM0Mzc1TDYuNzEwOTQgODcuMDczNUw1LjAyNzM0IDg5LjcwNjNIOC4zMTY0MVpNNy41MjM0NCA4NS45MzI5VjkxLjYyMDRINi44MDA3OFY4NS45MzI5SDcuNTIzNDRaTTEyLjY3NTIgODguMzIzNVY4OS4xOTA3QzEyLjY3NTIgODkuNjU2OCAxMi42MzM1IDkwLjA1IDEyLjU1MDIgOTAuMzcwNEMxMi40NjY4IDkwLjY5MDcgMTIuMzQ3IDkwLjk0ODUgMTIuMTkwOCA5MS4xNDM4QzEyLjAzNDUgOTEuMzM5MSAxMS44NDU3IDkxLjQ4MSAxMS42MjQ0IDkxLjU2OTZDMTEuNDA1NiA5MS42NTU1IDExLjE1ODIgOTEuNjk4NSAxMC44ODIyIDkxLjY5ODVDMTAuNjYzNSA5MS42OTg1IDEwLjQ2MTYgOTEuNjcxMSAxMC4yNzY3IDkxLjYxNjVDMTAuMDkxOCA5MS41NjE4IDkuOTI1MTcgOTEuNDc0NSA5Ljc3NjczIDkxLjM1NDdDOS42MzA5IDkxLjIzMjMgOS41MDU5IDkxLjA3MzUgOS40MDE3MyA5MC44NzgyQzkuMjk3NTcgOTAuNjgyOSA5LjIxODE0IDkwLjQ0NTkgOS4xNjM0NSA5MC4xNjcyQzkuMTA4NzcgODkuODg4NiA5LjA4MTQyIDg5LjU2MzEgOS4wODE0MiA4OS4xOTA3Vjg4LjMyMzVDOS4wODE0MiA4Ny44NTczIDkuMTIzMDkgODcuNDY2NyA5LjIwNjQyIDg3LjE1MTZDOS4yOTIzNiA4Ni44MzY1IDkuNDEzNDUgODYuNTgzOSA5LjU2OTcgODYuMzkzOEM5LjcyNTk1IDg2LjIwMTEgOS45MTM0NSA4Ni4wNjMxIDEwLjEzMjIgODUuOTc5N0MxMC4zNTM2IDg1Ljg5NjQgMTAuNjAxIDg1Ljg1NDcgMTAuODc0NCA4NS44NTQ3QzExLjA5NTcgODUuODU0NyAxMS4yOTg5IDg1Ljg4MjEgMTEuNDgzOCA4NS45MzY4QzExLjY3MTMgODUuOTg4OSAxMS44Mzc5IDg2LjA3MzUgMTEuOTgzOCA4Ni4xOTA3QzEyLjEyOTYgODYuMzA1MyAxMi4yNTMzIDg2LjQ1ODkgMTIuMzU0OSA4Ni42NTE2QzEyLjQ1OSA4Ni44NDE3IDEyLjUzODUgODcuMDc0OCAxMi41OTMxIDg3LjM1MDhDMTIuNjQ3OCA4Ny42MjY5IDEyLjY3NTIgODcuOTUxMSAxMi42NzUyIDg4LjMyMzVaTTExLjk0ODYgODkuMzA3OVY4OC4yMDI0QzExLjk0ODYgODcuOTQ3MiAxMS45MzMgODcuNzIzMiAxMS45MDE3IDg3LjUzMDVDMTEuODczMSA4Ny4zMzUyIDExLjgzMDEgODcuMTY4NSAxMS43NzI4IDg3LjAzMDVDMTEuNzE1NSA4Ni44OTI1IDExLjY0MjYgODYuNzgwNSAxMS41NTQxIDg2LjY5NDZDMTEuNDY4MSA4Ni42MDg2IDExLjM2NzkgODYuNTQ2MSAxMS4yNTMzIDg2LjUwNzFDMTEuMTQxMyA4Ni40NjU0IDExLjAxNSA4Ni40NDQ2IDEwLjg3NDQgODYuNDQ0NkMxMC43MDI1IDg2LjQ0NDYgMTAuNTUwMiA4Ni40NzcxIDEwLjQxNzQgODYuNTQyMkMxMC4yODQ1IDg2LjYwNDcgMTAuMTcyNiA4Ni43MDUgMTAuMDgxNCA4Ni44NDNDOS45OTI4OCA4Ni45ODEgOS45MjUxNyA4Ny4xNjIgOS44NzgzIDg3LjM4NkM5LjgzMTQyIDg3LjYwOTkgOS44MDc5OCA4Ny44ODIxIDkuODA3OTggODguMjAyNFY4OS4zMDc5QzkuODA3OTggODkuNTYzMSA5LjgyMjMxIDg5Ljc4ODMgOS44NTA5NSA4OS45ODM2QzkuODgyMiA5MC4xNzkgOS45Mjc3OCA5MC4zNDgyIDkuOTg3NjcgOTAuNDkxNUMxMC4wNDc2IDkwLjYzMjEgMTAuMTIwNSA5MC43NDggMTAuMjA2NCA5MC44MzkxQzEwLjI5MjQgOTAuOTMwMyAxMC4zOTEzIDkwLjk5OCAxMC41MDMzIDkxLjA0MjJDMTAuNjE3OSA5MS4wODM5IDEwLjc0NDIgOTEuMTA0NyAxMC44ODIyIDkxLjEwNDdDMTEuMDU5MyA5MS4xMDQ3IDExLjIxNDIgOTEuMDcwOSAxMS4zNDcgOTEuMDAzMkMxMS40Nzk5IDkwLjkzNTUgMTEuNTkwNSA5MC44MyAxMS42NzkxIDkwLjY4NjhDMTEuNzcwMiA5MC41NDA5IDExLjgzNzkgOTAuMzU0NyAxMS44ODIyIDkwLjEyODJDMTEuOTI2NSA4OS44OTkgMTEuOTQ4NiA4OS42MjU2IDExLjk0ODYgODkuMzA3OVpNMTMuNjc0NiA4Ny4zMjc0Vjg3LjAyNjZDMTMuNjc0NiA4Ni44MTA1IDEzLjcyMTQgODYuNjEzOSAxMy44MTUyIDg2LjQzNjhDMTMuOTA4OSA4Ni4yNTk3IDE0LjA0MzEgODYuMTE3OCAxNC4yMTc1IDg2LjAxMUMxNC4zOTIgODUuOTA0MiAxNC41OTkgODUuODUwOCAxNC44Mzg2IDg1Ljg1MDhDMTUuMDgzNCA4NS44NTA4IDE1LjI5MTggODUuOTA0MiAxNS40NjM2IDg2LjAxMUMxNS42MzgxIDg2LjExNzggMTUuNzcyMiA4Ni4yNTk3IDE1Ljg2NiA4Ni40MzY4QzE1Ljk1OTcgODYuNjEzOSAxNi4wMDY2IDg2LjgxMDUgMTYuMDA2NiA4Ny4wMjY2Vjg3LjMyNzRDMTYuMDA2NiA4Ny41MzgzIDE1Ljk1OTcgODcuNzMyMyAxNS44NjYgODcuOTA5NEMxNS43NzQ4IDg4LjA4NjUgMTUuNjQyIDg4LjIyODQgMTUuNDY3NSA4OC4zMzUyQzE1LjI5NTcgODguNDQyIDE1LjA4ODYgODguNDk1NCAxNC44NDY0IDg4LjQ5NTRDMTQuNjA0MyA4OC40OTU0IDE0LjM5NDYgODguNDQyIDE0LjIxNzUgODguMzM1MkMxNC4wNDMxIDg4LjIyODQgMTMuOTA4OSA4OC4wODY1IDEzLjgxNTIgODcuOTA5NEMxMy43MjE0IDg3LjczMjMgMTMuNjc0NiA4Ny41MzgzIDEzLjY3NDYgODcuMzI3NFpNMTQuMjE3NSA4Ny4wMjY2Vjg3LjMyNzRDMTQuMjE3NSA4Ny40NDcyIDE0LjIzOTcgODcuNTYwNSAxNC4yODM5IDg3LjY2NzJDMTQuMzMwOCA4Ny43NzQgMTQuNDAxMSA4Ny44NjEyIDE0LjQ5NDkgODcuOTI5QzE0LjU4ODYgODcuOTk0MSAxNC43MDU4IDg4LjAyNjYgMTQuODQ2NCA4OC4wMjY2QzE0Ljk4NzEgODguMDI2NiAxNS4xMDI5IDg3Ljk5NDEgMTUuMTk0MSA4Ny45MjlDMTUuMjg1MiA4Ny44NjEyIDE1LjM1MjkgODcuNzc0IDE1LjM5NzIgODcuNjY3MkMxNS40NDE1IDg3LjU2MDUgMTUuNDYzNiA4Ny40NDcyIDE1LjQ2MzYgODcuMzI3NFY4Ny4wMjY2QzE1LjQ2MzYgODYuOTA0MiAxNS40NDAyIDg2Ljc4OTYgMTUuMzkzMyA4Ni42ODI5QzE1LjM0OSA4Ni41NzM1IDE1LjI4IDg2LjQ4NjIgMTUuMTg2MyA4Ni40MjExQzE1LjA5NTEgODYuMzUzNCAxNC45NzkzIDg2LjMxOTYgMTQuODM4NiA4Ni4zMTk2QzE0LjcwMDYgODYuMzE5NiAxNC41ODQ3IDg2LjM1MzQgMTQuNDkxIDg2LjQyMTFDMTQuMzk5OCA4Ni40ODYyIDE0LjMzMDggODYuNTczNSAxNC4yODM5IDg2LjY4MjlDMTQuMjM5NyA4Ni43ODk2IDE0LjIxNzUgODYuOTA0MiAxNC4yMTc1IDg3LjAyNjZaTTE2LjQ0NDEgOTAuNTMwNVY5MC4yMjU4QzE2LjQ0NDEgOTAuMDEyMyAxNi40OTEgODkuODE3IDE2LjU4NDcgODkuNjM5OUMxNi42Nzg1IDg5LjQ2MjggMTYuODEyNiA4OS4zMjA5IDE2Ljk4NzEgODkuMjE0MUMxNy4xNjE1IDg5LjEwNzMgMTcuMzY4NiA4OS4wNTQgMTcuNjA4MiA4OS4wNTRDMTcuODUyOSA4OS4wNTQgMTguMDYxMyA4OS4xMDczIDE4LjIzMzIgODkuMjE0MUMxOC40MDc2IDg5LjMyMDkgMTguNTQxOCA4OS40NjI4IDE4LjYzNTUgODkuNjM5OUMxOC43MjkzIDg5LjgxNyAxOC43NzYxIDkwLjAxMjMgMTguNzc2MSA5MC4yMjU4VjkwLjUzMDVDMTguNzc2MSA5MC43NDQxIDE4LjcyOTMgOTAuOTM5NCAxOC42MzU1IDkxLjExNjVDMTguNTQ0NCA5MS4yOTM1IDE4LjQxMTUgOTEuNDM1NSAxOC4yMzcxIDkxLjU0MjJDMTguMDY1MiA5MS42NDkgMTcuODU4MiA5MS43MDI0IDE3LjYxNiA5MS43MDI0QzE3LjM3MzggOTEuNzAyNCAxNy4xNjU0IDkxLjY0OSAxNi45OTEgOTEuNTQyMkMxNi44MTY1IDkxLjQzNTUgMTYuNjgxMSA5MS4yOTM1IDE2LjU4NDcgOTEuMTE2NUMxNi40OTEgOTAuOTM5NCAxNi40NDQxIDkwLjc0NDEgMTYuNDQ0MSA5MC41MzA1Wk0xNi45ODcxIDkwLjIyNThWOTAuNTMwNUMxNi45ODcxIDkwLjY1MDMgMTcuMDA5MiA5MC43NjQ5IDE3LjA1MzUgOTAuODc0M0MxNy4xMDAzIDkwLjk4MSAxNy4xNzA3IDkxLjA2ODMgMTcuMjY0NCA5MS4xMzZDMTcuMzU4MiA5MS4yMDExIDE3LjQ3NTMgOTEuMjMzNiAxNy42MTYgOTEuMjMzNkMxNy43NTY2IDkxLjIzMzYgMTcuODcyNSA5MS4yMDExIDE3Ljk2MzYgOTEuMTM2QzE4LjA1NzQgOTEuMDY4MyAxOC4xMjY0IDkwLjk4MSAxOC4xNzA3IDkwLjg3NDNDMTguMjE0OSA5MC43Njc1IDE4LjIzNzEgOTAuNjUyOSAxOC4yMzcxIDkwLjUzMDVWOTAuMjI1OEMxOC4yMzcxIDkwLjEwMzQgMTguMjEzNiA4OS45ODg5IDE4LjE2NjggODkuODgyMUMxOC4xMjI1IDg5Ljc3NTMgMTguMDUzNSA4OS42ODk0IDE3Ljk1OTcgODkuNjI0M0MxNy44Njg2IDg5LjU1NjYgMTcuNzUxNCA4OS41MjI3IDE3LjYwODIgODkuNTIyN0MxNy40NzAxIDg5LjUyMjcgMTcuMzU0MyA4OS41NTY2IDE3LjI2MDUgODkuNjI0M0MxNy4xNjk0IDg5LjY4OTQgMTcuMTAwMyA4OS43NzUzIDE3LjA1MzUgODkuODgyMUMxNy4wMDkyIDg5Ljk4ODkgMTYuOTg3MSA5MC4xMDM0IDE2Ljk4NzEgOTAuMjI1OFpNMTcuNzg3OCA4Ni43NDE1TDE1LjAxMDUgOTEuMTg2OEwxNC42MDQzIDkwLjkyOUwxNy4zODE2IDg2LjQ4MzZMMTcuNzg3OCA4Ni43NDE1WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8cGF0aCBkPSJNOC4xOTkyMiAxMTkuMjMzVjExOS44MjdINC40NzY1NlYxMTkuMzA4TDYuMzM5ODQgMTE3LjIzM0M2LjU2OTAxIDExNi45NzggNi43NDYwOSAxMTYuNzYyIDYuODcxMDkgMTE2LjU4NUM2Ljk5ODcgMTE2LjQwNSA3LjA4NzI0IDExNi4yNDUgNy4xMzY3MiAxMTYuMTA0QzcuMTg4OCAxMTUuOTYxIDcuMjE0ODQgMTE1LjgxNSA3LjIxNDg0IDExNS42NjdDNy4yMTQ4NCAxMTUuNDc5IDcuMTc1NzggMTE1LjMxIDcuMDk3NjYgMTE1LjE1OUM3LjAyMjE0IDExNS4wMDYgNi45MTAxNiAxMTQuODgzIDYuNzYxNzIgMTE0Ljc5MkM2LjYxMzI4IDExNC43MDEgNi40MzM1OSAxMTQuNjU1IDYuMjIyNjYgMTE0LjY1NUM1Ljk3MDA1IDExNC42NTUgNS43NTkxMSAxMTQuNzA1IDUuNTg5ODQgMTE0LjgwNEM1LjQyMzE4IDExNC45IDUuMjk4MTggMTE1LjAzNSA1LjIxNDg0IDExNS4yMUM1LjEzMTUxIDExNS4zODQgNS4wODk4NCAxMTUuNTg1IDUuMDg5ODQgMTE1LjgxMkg0LjM2NzE5QzQuMzY3MTkgMTE1LjQ5MSA0LjQzNzUgMTE1LjE5OCA0LjU3ODEyIDExNC45MzNDNC43MTg3NSAxMTQuNjY3IDQuOTI3MDggMTE0LjQ1NiA1LjIwMzEyIDExNC4zQzUuNDc5MTcgMTE0LjE0MSA1LjgxOTAxIDExNC4wNjIgNi4yMjI2NiAxMTQuMDYyQzYuNTgyMDMgMTE0LjA2MiA2Ljg4OTMyIDExNC4xMjUgNy4xNDQ1MyAxMTQuMjUzQzcuMzk5NzQgMTE0LjM3OCA3LjU5NTA1IDExNC41NTUgNy43MzA0NyAxMTQuNzg0QzcuODY4NDkgMTE1LjAxMSA3LjkzNzUgMTE1LjI3NiA3LjkzNzUgMTE1LjU4MUM3LjkzNzUgMTE1Ljc0OCA3LjkwODg1IDExNS45MTcgNy44NTE1NiAxMTYuMDg5QzcuNzk2ODggMTE2LjI1OCA3LjcyMDA1IDExNi40MjcgNy42MjEwOSAxMTYuNTk3QzcuNTI0NzQgMTE2Ljc2NiA3LjQxMTQ2IDExNi45MzMgNy4yODEyNSAxMTcuMDk3QzcuMTUzNjUgMTE3LjI2MSA3LjAxNjkzIDExNy40MjIgNi44NzEwOSAxMTcuNTgxTDUuMzQ3NjYgMTE5LjIzM0g4LjE5OTIyWk0xMi42NzUyIDExNi41M1YxMTcuMzk3QzEyLjY3NTIgMTE3Ljg2NCAxMi42MzM1IDExOC4yNTcgMTIuNTUwMiAxMTguNTc3QzEyLjQ2NjggMTE4Ljg5NyAxMi4zNDcgMTE5LjE1NSAxMi4xOTA4IDExOS4zNTFDMTIuMDM0NSAxMTkuNTQ2IDExLjg0NTcgMTE5LjY4OCAxMS42MjQ0IDExOS43NzZDMTEuNDA1NiAxMTkuODYyIDExLjE1ODIgMTE5LjkwNSAxMC44ODIyIDExOS45MDVDMTAuNjYzNSAxMTkuOTA1IDEwLjQ2MTYgMTE5Ljg3OCAxMC4yNzY3IDExOS44MjNDMTAuMDkxOCAxMTkuNzY5IDkuOTI1MTcgMTE5LjY4MSA5Ljc3NjczIDExOS41NjJDOS42MzA5IDExOS40MzkgOS41MDU5IDExOS4yOCA5LjQwMTczIDExOS4wODVDOS4yOTc1NyAxMTguODkgOS4yMTgxNCAxMTguNjUzIDkuMTYzNDUgMTE4LjM3NEM5LjEwODc3IDExOC4wOTUgOS4wODE0MiAxMTcuNzcgOS4wODE0MiAxMTcuMzk3VjExNi41M0M5LjA4MTQyIDExNi4wNjQgOS4xMjMwOSAxMTUuNjc0IDkuMjA2NDIgMTE1LjM1OEM5LjI5MjM2IDExNS4wNDMgOS40MTM0NSAxMTQuNzkxIDkuNTY5NyAxMTQuNjAxQzkuNzI1OTUgMTE0LjQwOCA5LjkxMzQ1IDExNC4yNyAxMC4xMzIyIDExNC4xODdDMTAuMzUzNiAxMTQuMTAzIDEwLjYwMSAxMTQuMDYyIDEwLjg3NDQgMTE0LjA2MkMxMS4wOTU3IDExNC4wNjIgMTEuMjk4OSAxMTQuMDg5IDExLjQ4MzggMTE0LjE0NEMxMS42NzEzIDExNC4xOTYgMTEuODM3OSAxMTQuMjggMTEuOTgzOCAxMTQuMzk3QzEyLjEyOTYgMTE0LjUxMiAxMi4yNTMzIDExNC42NjYgMTIuMzU0OSAxMTQuODU4QzEyLjQ1OSAxMTUuMDQ5IDEyLjUzODUgMTE1LjI4MiAxMi41OTMxIDExNS41NThDMTIuNjQ3OCAxMTUuODM0IDEyLjY3NTIgMTE2LjE1OCAxMi42NzUyIDExNi41M1pNMTEuOTQ4NiAxMTcuNTE1VjExNi40MDlDMTEuOTQ4NiAxMTYuMTU0IDExLjkzMyAxMTUuOTMgMTEuOTAxNyAxMTUuNzM3QzExLjg3MzEgMTE1LjU0MiAxMS44MzAxIDExNS4zNzUgMTEuNzcyOCAxMTUuMjM3QzExLjcxNTUgMTE1LjA5OSAxMS42NDI2IDExNC45ODcgMTEuNTU0MSAxMTQuOTAxQzExLjQ2ODEgMTE0LjgxNSAxMS4zNjc5IDExNC43NTMgMTEuMjUzMyAxMTQuNzE0QzExLjE0MTMgMTE0LjY3MiAxMS4wMTUgMTE0LjY1MSAxMC44NzQ0IDExNC42NTFDMTAuNzAyNSAxMTQuNjUxIDEwLjU1MDIgMTE0LjY4NCAxMC40MTc0IDExNC43NDlDMTAuMjg0NSAxMTQuODEyIDEwLjE3MjYgMTE0LjkxMiAxMC4wODE0IDExNS4wNUM5Ljk5Mjg4IDExNS4xODggOS45MjUxNyAxMTUuMzY5IDkuODc4MyAxMTUuNTkzQzkuODMxNDIgMTE1LjgxNyA5LjgwNzk4IDExNi4wODkgOS44MDc5OCAxMTYuNDA5VjExNy41MTVDOS44MDc5OCAxMTcuNzcgOS44MjIzMSAxMTcuOTk1IDkuODUwOTUgMTE4LjE5QzkuODgyMiAxMTguMzg2IDkuOTI3NzggMTE4LjU1NSA5Ljk4NzY3IDExOC42OThDMTAuMDQ3NiAxMTguODM5IDEwLjEyMDUgMTE4Ljk1NSAxMC4yMDY0IDExOS4wNDZDMTAuMjkyNCAxMTkuMTM3IDEwLjM5MTMgMTE5LjIwNSAxMC41MDMzIDExOS4yNDlDMTAuNjE3OSAxMTkuMjkxIDEwLjc0NDIgMTE5LjMxMiAxMC44ODIyIDExOS4zMTJDMTEuMDU5MyAxMTkuMzEyIDExLjIxNDIgMTE5LjI3OCAxMS4zNDcgMTE5LjIxQzExLjQ3OTkgMTE5LjE0MiAxMS41OTA1IDExOS4wMzcgMTEuNjc5MSAxMTguODk0QzExLjc3MDIgMTE4Ljc0OCAxMS44Mzc5IDExOC41NjIgMTEuODgyMiAxMTguMzM1QzExLjkyNjUgMTE4LjEwNiAxMS45NDg2IDExNy44MzIgMTEuOTQ4NiAxMTcuNTE1Wk0xMy42NzQ2IDExNS41MzRWMTE1LjIzM0MxMy42NzQ2IDExNS4wMTcgMTMuNzIxNCAxMTQuODIxIDEzLjgxNTIgMTE0LjY0NEMxMy45MDg5IDExNC40NjYgMTQuMDQzMSAxMTQuMzI1IDE0LjIxNzUgMTE0LjIxOEMxNC4zOTIgMTE0LjExMSAxNC41OTkgMTE0LjA1OCAxNC44Mzg2IDExNC4wNThDMTUuMDgzNCAxMTQuMDU4IDE1LjI5MTggMTE0LjExMSAxNS40NjM2IDExNC4yMThDMTUuNjM4MSAxMTQuMzI1IDE1Ljc3MjIgMTE0LjQ2NiAxNS44NjYgMTE0LjY0NEMxNS45NTk3IDExNC44MjEgMTYuMDA2NiAxMTUuMDE3IDE2LjAwNjYgMTE1LjIzM1YxMTUuNTM0QzE2LjAwNjYgMTE1Ljc0NSAxNS45NTk3IDExNS45MzkgMTUuODY2IDExNi4xMTZDMTUuNzc0OCAxMTYuMjkzIDE1LjY0MiAxMTYuNDM1IDE1LjQ2NzUgMTE2LjU0MkMxNS4yOTU3IDExNi42NDkgMTUuMDg4NiAxMTYuNzAyIDE0Ljg0NjQgMTE2LjcwMkMxNC42MDQzIDExNi43MDIgMTQuMzk0NiAxMTYuNjQ5IDE0LjIxNzUgMTE2LjU0MkMxNC4wNDMxIDExNi40MzUgMTMuOTA4OSAxMTYuMjkzIDEzLjgxNTIgMTE2LjExNkMxMy43MjE0IDExNS45MzkgMTMuNjc0NiAxMTUuNzQ1IDEzLjY3NDYgMTE1LjUzNFpNMTQuMjE3NSAxMTUuMjMzVjExNS41MzRDMTQuMjE3NSAxMTUuNjU0IDE0LjIzOTcgMTE1Ljc2NyAxNC4yODM5IDExNS44NzRDMTQuMzMwOCAxMTUuOTgxIDE0LjQwMTEgMTE2LjA2OCAxNC40OTQ5IDExNi4xMzZDMTQuNTg4NiAxMTYuMjAxIDE0LjcwNTggMTE2LjIzMyAxNC44NDY0IDExNi4yMzNDMTQuOTg3MSAxMTYuMjMzIDE1LjEwMjkgMTE2LjIwMSAxNS4xOTQxIDExNi4xMzZDMTUuMjg1MiAxMTYuMDY4IDE1LjM1MjkgMTE1Ljk4MSAxNS4zOTcyIDExNS44NzRDMTUuNDQxNSAxMTUuNzY3IDE1LjQ2MzYgMTE1LjY1NCAxNS40NjM2IDExNS41MzRWMTE1LjIzM0MxNS40NjM2IDExNS4xMTEgMTUuNDQwMiAxMTQuOTk2IDE1LjM5MzMgMTE0Ljg5QzE1LjM0OSAxMTQuNzggMTUuMjggMTE0LjY5MyAxNS4xODYzIDExNC42MjhDMTUuMDk1MSAxMTQuNTYgMTQuOTc5MyAxMTQuNTI2IDE0LjgzODYgMTE0LjUyNkMxNC43MDA2IDExNC41MjYgMTQuNTg0NyAxMTQuNTYgMTQuNDkxIDExNC42MjhDMTQuMzk5OCAxMTQuNjkzIDE0LjMzMDggMTE0Ljc4IDE0LjI4MzkgMTE0Ljg5QzE0LjIzOTcgMTE0Ljk5NiAxNC4yMTc1IDExNS4xMTEgMTQuMjE3NSAxMTUuMjMzWk0xNi40NDQxIDExOC43MzdWMTE4LjQzM0MxNi40NDQxIDExOC4yMTkgMTYuNDkxIDExOC4wMjQgMTYuNTg0NyAxMTcuODQ3QzE2LjY3ODUgMTE3LjY3IDE2LjgxMjYgMTE3LjUyOCAxNi45ODcxIDExNy40MjFDMTcuMTYxNSAxMTcuMzE0IDE3LjM2ODYgMTE3LjI2MSAxNy42MDgyIDExNy4yNjFDMTcuODUyOSAxMTcuMjYxIDE4LjA2MTMgMTE3LjMxNCAxOC4yMzMyIDExNy40MjFDMTguNDA3NiAxMTcuNTI4IDE4LjU0MTggMTE3LjY3IDE4LjYzNTUgMTE3Ljg0N0MxOC43MjkzIDExOC4wMjQgMTguNzc2MSAxMTguMjE5IDE4Ljc3NjEgMTE4LjQzM1YxMTguNzM3QzE4Ljc3NjEgMTE4Ljk1MSAxOC43MjkzIDExOS4xNDYgMTguNjM1NSAxMTkuMzIzQzE4LjU0NDQgMTE5LjUgMTguNDExNSAxMTkuNjQyIDE4LjIzNzEgMTE5Ljc0OUMxOC4wNjUyIDExOS44NTYgMTcuODU4MiAxMTkuOTA5IDE3LjYxNiAxMTkuOTA5QzE3LjM3MzggMTE5LjkwOSAxNy4xNjU0IDExOS44NTYgMTYuOTkxIDExOS43NDlDMTYuODE2NSAxMTkuNjQyIDE2LjY4MTEgMTE5LjUgMTYuNTg0NyAxMTkuMzIzQzE2LjQ5MSAxMTkuMTQ2IDE2LjQ0NDEgMTE4Ljk1MSAxNi40NDQxIDExOC43MzdaTTE2Ljk4NzEgMTE4LjQzM1YxMTguNzM3QzE2Ljk4NzEgMTE4Ljg1NyAxNy4wMDkyIDExOC45NzIgMTcuMDUzNSAxMTkuMDgxQzE3LjEwMDMgMTE5LjE4OCAxNy4xNzA3IDExOS4yNzUgMTcuMjY0NCAxMTkuMzQzQzE3LjM1ODIgMTE5LjQwOCAxNy40NzUzIDExOS40NCAxNy42MTYgMTE5LjQ0QzE3Ljc1NjYgMTE5LjQ0IDE3Ljg3MjUgMTE5LjQwOCAxNy45NjM2IDExOS4zNDNDMTguMDU3NCAxMTkuMjc1IDE4LjEyNjQgMTE5LjE4OCAxOC4xNzA3IDExOS4wODFDMTguMjE0OSAxMTguOTc0IDE4LjIzNzEgMTE4Ljg2IDE4LjIzNzEgMTE4LjczN1YxMTguNDMzQzE4LjIzNzEgMTE4LjMxIDE4LjIxMzYgMTE4LjE5NiAxOC4xNjY4IDExOC4wODlDMTguMTIyNSAxMTcuOTgyIDE4LjA1MzUgMTE3Ljg5NiAxNy45NTk3IDExNy44MzFDMTcuODY4NiAxMTcuNzYzIDE3Ljc1MTQgMTE3LjcyOSAxNy42MDgyIDExNy43MjlDMTcuNDcwMSAxMTcuNzI5IDE3LjM1NDMgMTE3Ljc2MyAxNy4yNjA1IDExNy44MzFDMTcuMTY5NCAxMTcuODk2IDE3LjEwMDMgMTE3Ljk4MiAxNy4wNTM1IDExOC4wODlDMTcuMDA5MiAxMTguMTk2IDE2Ljk4NzEgMTE4LjMxIDE2Ljk4NzEgMTE4LjQzM1pNMTcuNzg3OCAxMTQuOTQ4TDE1LjAxMDUgMTE5LjM5NEwxNC42MDQzIDExOS4xMzZMMTcuMzgxNiAxMTQuNjlMMTcuNzg3OCAxMTQuOTQ4WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8cGF0aCBkPSJNMTMuMDQzIDE0NC43MzdWMTQ1LjYwNEMxMy4wNDMgMTQ2LjA3IDEzLjAwMTMgMTQ2LjQ2NCAxMi45MTggMTQ2Ljc4NEMxMi44MzQ2IDE0Ny4xMDQgMTIuNzE0OCAxNDcuMzYyIDEyLjU1ODYgMTQ3LjU1N0MxMi40MDIzIDE0Ny43NTMgMTIuMjEzNSAxNDcuODk1IDExLjk5MjIgMTQ3Ljk4M0MxMS43NzM0IDE0OC4wNjkgMTEuNTI2IDE0OC4xMTIgMTEuMjUgMTQ4LjExMkMxMS4wMzEyIDE0OC4xMTIgMTAuODI5NCAxNDguMDg1IDEwLjY0NDUgMTQ4LjAzQzEwLjQ1OTYgMTQ3Ljk3NSAxMC4yOTMgMTQ3Ljg4OCAxMC4xNDQ1IDE0Ny43NjhDOS45OTg3IDE0Ny42NDYgOS44NzM3IDE0Ny40ODcgOS43Njk1MyAxNDcuMjkyQzkuNjY1MzYgMTQ3LjA5NiA5LjU4NTk0IDE0Ni44NTkgOS41MzEyNSAxNDYuNTgxQzkuNDc2NTYgMTQ2LjMwMiA5LjQ0OTIyIDE0NS45NzcgOS40NDkyMiAxNDUuNjA0VjE0NC43MzdDOS40NDkyMiAxNDQuMjcxIDkuNDkwODkgMTQzLjg4IDkuNTc0MjIgMTQzLjU2NUM5LjY2MDE2IDE0My4yNSA5Ljc4MTI1IDE0Mi45OTcgOS45Mzc1IDE0Mi44MDdDMTAuMDkzOCAxNDIuNjE1IDEwLjI4MTIgMTQyLjQ3NyAxMC41IDE0Mi4zOTNDMTAuNzIxNCAxNDIuMzEgMTAuOTY4OCAxNDIuMjY4IDExLjI0MjIgMTQyLjI2OEMxMS40NjM1IDE0Mi4yNjggMTEuNjY2NyAxNDIuMjk2IDExLjg1MTYgMTQyLjM1QzEyLjAzOTEgMTQyLjQwMiAxMi4yMDU3IDE0Mi40ODcgMTIuMzUxNiAxNDIuNjA0QzEyLjQ5NzQgMTQyLjcxOSAxMi42MjExIDE0Mi44NzIgMTIuNzIyNyAxNDMuMDY1QzEyLjgyNjggMTQzLjI1NSAxMi45MDYyIDE0My40ODggMTIuOTYwOSAxNDMuNzY0QzEzLjAxNTYgMTQ0LjA0IDEzLjA0MyAxNDQuMzY1IDEzLjA0MyAxNDQuNzM3Wk0xMi4zMTY0IDE0NS43MjFWMTQ0LjYxNkMxMi4zMTY0IDE0NC4zNjEgMTIuMzAwOCAxNDQuMTM3IDEyLjI2OTUgMTQzLjk0NEMxMi4yNDA5IDE0My43NDkgMTIuMTk3OSAxNDMuNTgyIDEyLjE0MDYgMTQzLjQ0NEMxMi4wODMzIDE0My4zMDYgMTIuMDEwNCAxNDMuMTk0IDExLjkyMTkgMTQzLjEwOEMxMS44MzU5IDE0My4wMjIgMTEuNzM1NyAxNDIuOTYgMTEuNjIxMSAxNDIuOTIxQzExLjUwOTEgMTQyLjg3OSAxMS4zODI4IDE0Mi44NTggMTEuMjQyMiAxNDIuODU4QzExLjA3MDMgMTQyLjg1OCAxMC45MTggMTQyLjg5MSAxMC43ODUyIDE0Mi45NTZDMTAuNjUyMyAxNDMuMDE4IDEwLjU0MDQgMTQzLjExOSAxMC40NDkyIDE0My4yNTdDMTAuMzYwNyAxNDMuMzk1IDEwLjI5MyAxNDMuNTc2IDEwLjI0NjEgMTQzLjhDMTAuMTk5MiAxNDQuMDI0IDEwLjE3NTggMTQ0LjI5NiAxMC4xNzU4IDE0NC42MTZWMTQ1LjcyMUMxMC4xNzU4IDE0NS45NzcgMTAuMTkwMSAxNDYuMjAyIDEwLjIxODggMTQ2LjM5N0MxMC4yNSAxNDYuNTkzIDEwLjI5NTYgMTQ2Ljc2MiAxMC4zNTU1IDE0Ni45MDVDMTAuNDE1NCAxNDcuMDQ2IDEwLjQ4ODMgMTQ3LjE2MiAxMC41NzQyIDE0Ny4yNTNDMTAuNjYwMiAxNDcuMzQ0IDEwLjc1OTEgMTQ3LjQxMiAxMC44NzExIDE0Ny40NTZDMTAuOTg1NyAxNDcuNDk3IDExLjExMiAxNDcuNTE4IDExLjI1IDE0Ny41MThDMTEuNDI3MSAxNDcuNTE4IDExLjU4MiAxNDcuNDg0IDExLjcxNDggMTQ3LjQxN0MxMS44NDc3IDE0Ny4zNDkgMTEuOTU4MyAxNDcuMjQ0IDEyLjA0NjkgMTQ3LjFDMTIuMTM4IDE0Ni45NTUgMTIuMjA1NyAxNDYuNzY4IDEyLjI1IDE0Ni41NDJDMTIuMjk0MyAxNDYuMzEzIDEyLjMxNjQgMTQ2LjAzOSAxMi4zMTY0IDE0NS43MjFaTTE0LjA0MjQgMTQzLjc0MVYxNDMuNDRDMTQuMDQyNCAxNDMuMjI0IDE0LjA4OTIgMTQzLjAyNyAxNC4xODMgMTQyLjg1QzE0LjI3NjcgMTQyLjY3MyAxNC40MTA4IDE0Mi41MzEgMTQuNTg1MyAxNDIuNDI1QzE0Ljc1OTggMTQyLjMxOCAxNC45NjY4IDE0Mi4yNjQgMTUuMjA2NCAxNDIuMjY0QzE1LjQ1MTIgMTQyLjI2NCAxNS42NTk1IDE0Mi4zMTggMTUuODMxNCAxNDIuNDI1QzE2LjAwNTkgMTQyLjUzMSAxNi4xNCAxNDIuNjczIDE2LjIzMzggMTQyLjg1QzE2LjMyNzUgMTQzLjAyNyAxNi4zNzQ0IDE0My4yMjQgMTYuMzc0NCAxNDMuNDRWMTQzLjc0MUMxNi4zNzQ0IDE0My45NTIgMTYuMzI3NSAxNDQuMTQ2IDE2LjIzMzggMTQ0LjMyM0MxNi4xNDI2IDE0NC41IDE2LjAwOTggMTQ0LjY0MiAxNS44MzUzIDE0NC43NDlDMTUuNjYzNSAxNDQuODU2IDE1LjQ1NjQgMTQ0LjkwOSAxNS4yMTQyIDE0NC45MDlDMTQuOTcyIDE0NC45MDkgMTQuNzYyNCAxNDQuODU2IDE0LjU4NTMgMTQ0Ljc0OUMxNC40MTA4IDE0NC42NDIgMTQuMjc2NyAxNDQuNSAxNC4xODMgMTQ0LjMyM0MxNC4wODkyIDE0NC4xNDYgMTQuMDQyNCAxNDMuOTUyIDE0LjA0MjQgMTQzLjc0MVpNMTQuNTg1MyAxNDMuNDRWMTQzLjc0MUMxNC41ODUzIDE0My44NjEgMTQuNjA3NSAxNDMuOTc0IDE0LjY1MTcgMTQ0LjA4MUMxNC42OTg2IDE0NC4xODggMTQuNzY4OSAxNDQuMjc1IDE0Ljg2MjcgMTQ0LjM0M0MxNC45NTY0IDE0NC40MDggMTUuMDczNiAxNDQuNDQgMTUuMjE0MiAxNDQuNDRDMTUuMzU0OSAxNDQuNDQgMTUuNDcwNyAxNDQuNDA4IDE1LjU2MTkgMTQ0LjM0M0MxNS42NTMgMTQ0LjI3NSAxNS43MjA3IDE0NC4xODggMTUuNzY1IDE0NC4wODFDMTUuODA5MyAxNDMuOTc0IDE1LjgzMTQgMTQzLjg2MSAxNS44MzE0IDE0My43NDFWMTQzLjQ0QzE1LjgzMTQgMTQzLjMxOCAxNS44MDggMTQzLjIwMyAxNS43NjExIDE0My4wOTZDMTUuNzE2OCAxNDIuOTg3IDE1LjY0NzggMTQyLjkgMTUuNTU0MSAxNDIuODM1QzE1LjQ2MjkgMTQyLjc2NyAxNS4zNDcgMTQyLjczMyAxNS4yMDY0IDE0Mi43MzNDMTUuMDY4NCAxNDIuNzMzIDE0Ljk1MjUgMTQyLjc2NyAxNC44NTg4IDE0Mi44MzVDMTQuNzY3NiAxNDIuOSAxNC42OTg2IDE0Mi45ODcgMTQuNjUxNyAxNDMuMDk2QzE0LjYwNzUgMTQzLjIwMyAxNC41ODUzIDE0My4zMTggMTQuNTg1MyAxNDMuNDRaTTE2LjgxMTkgMTQ2Ljk0NFYxNDYuNjM5QzE2LjgxMTkgMTQ2LjQyNiAxNi44NTg4IDE0Ni4yMzEgMTYuOTUyNSAxNDYuMDUzQzE3LjA0NjMgMTQ1Ljg3NiAxNy4xODA0IDE0NS43MzQgMTcuMzU0OSAxNDUuNjI4QzE3LjUyOTMgMTQ1LjUyMSAxNy43MzY0IDE0NS40NjggMTcuOTc2IDE0NS40NjhDMTguMjIwNyAxNDUuNDY4IDE4LjQyOTEgMTQ1LjUyMSAxOC42MDEgMTQ1LjYyOEMxOC43NzU0IDE0NS43MzQgMTguOTA5NSAxNDUuODc2IDE5LjAwMzMgMTQ2LjA1M0MxOS4wOTcgMTQ2LjIzMSAxOS4xNDM5IDE0Ni40MjYgMTkuMTQzOSAxNDYuNjM5VjE0Ni45NDRDMTkuMTQzOSAxNDcuMTU4IDE5LjA5NyAxNDcuMzUzIDE5LjAwMzMgMTQ3LjUzQzE4LjkxMjIgMTQ3LjcwNyAxOC43NzkzIDE0Ny44NDkgMTguNjA0OSAxNDcuOTU2QzE4LjQzMyAxNDguMDYzIDE4LjIyNiAxNDguMTE2IDE3Ljk4MzggMTQ4LjExNkMxNy43NDE2IDE0OC4xMTYgMTcuNTMzMiAxNDguMDYzIDE3LjM1ODggMTQ3Ljk1NkMxNy4xODQzIDE0Ny44NDkgMTcuMDQ4OSAxNDcuNzA3IDE2Ljk1MjUgMTQ3LjUzQzE2Ljg1ODggMTQ3LjM1MyAxNi44MTE5IDE0Ny4xNTggMTYuODExOSAxNDYuOTQ0Wk0xNy4zNTQ5IDE0Ni42MzlWMTQ2Ljk0NEMxNy4zNTQ5IDE0Ny4wNjQgMTcuMzc3IDE0Ny4xNzggMTcuNDIxMyAxNDcuMjg4QzE3LjQ2ODEgMTQ3LjM5NSAxNy41Mzg1IDE0Ny40ODIgMTcuNjMyMiAxNDcuNTVDMTcuNzI2IDE0Ny42MTUgMTcuODQzMSAxNDcuNjQ3IDE3Ljk4MzggMTQ3LjY0N0MxOC4xMjQ0IDE0Ny42NDcgMTguMjQwMyAxNDcuNjE1IDE4LjMzMTQgMTQ3LjU1QzE4LjQyNTIgMTQ3LjQ4MiAxOC40OTQyIDE0Ny4zOTUgMTguNTM4NSAxNDcuMjg4QzE4LjU4MjcgMTQ3LjE4MSAxOC42MDQ5IDE0Ny4wNjYgMTguNjA0OSAxNDYuOTQ0VjE0Ni42MzlDMTguNjA0OSAxNDYuNTE3IDE4LjU4MTQgMTQ2LjQwMiAxOC41MzQ1IDE0Ni4yOTZDMTguNDkwMyAxNDYuMTg5IDE4LjQyMTMgMTQ2LjEwMyAxOC4zMjc1IDE0Ni4wMzhDMTguMjM2NCAxNDUuOTcgMTguMTE5MiAxNDUuOTM2IDE3Ljk3NiAxNDUuOTM2QzE3LjgzNzkgMTQ1LjkzNiAxNy43MjIgMTQ1Ljk3IDE3LjYyODMgMTQ2LjAzOEMxNy41MzcyIDE0Ni4xMDMgMTcuNDY4MSAxNDYuMTg5IDE3LjQyMTMgMTQ2LjI5NkMxNy4zNzcgMTQ2LjQwMiAxNy4zNTQ5IDE0Ni41MTcgMTcuMzU0OSAxNDYuNjM5Wk0xOC4xNTU2IDE0My4xNTVMMTUuMzc4MyAxNDcuNkwxNC45NzIgMTQ3LjM0M0wxNy43NDk0IDE0Mi44OTdMMTguMTU1NiAxNDMuMTU1WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8cGF0aCBkPSJNMjUgNC4xNjExM0wyMDAgNC4xNjExNiIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiLz4KPHBhdGggZD0iTTI1IDMzLjE2MTFMMjAwIDMzLjE2MTIiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjEyIi8+CjxwYXRoIGQ9Ik0yNSA2MS4xNjExTDIwMCA2MS4xNjEyIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC4xMiIvPgo8cGF0aCBkPSJNMjUgODkuMTYxMUwyMDAgODkuMTYxMiIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiLz4KPHBhdGggZD0iTTI1IDExOC4xNjFMMjAwIDExOC4xNjEiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjEyIi8+CjxsaW5lIHgxPSIyMy4yIiB5MT0iMTQ1Ljk2MSIgeDI9IjIwMi44IiB5Mj0iMTQ1Ljk2MSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuNyIgc3Ryb2tlLXdpZHRoPSIwLjQiIHN0cm9rZS1saW5lY2FwPSJzcXVhcmUiLz4KPGxpbmUgeDE9IjQwLjQ1IiB5MT0iMTQ4LjA3MiIgeDI9IjQwLjQ1IiB5Mj0iMTQ3LjI1IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8cGF0aCBkPSJNMzIuNTA5MSAxNTIuMDI1VjE1Mi44OTNDMzIuNTA5MSAxNTMuMzU5IDMyLjQ2NzQgMTUzLjc1MiAzMi4zODQxIDE1NC4wNzJDMzIuMzAwNyAxNTQuMzkzIDMyLjE4MDkgMTU0LjY1IDMyLjAyNDcgMTU0Ljg0NkMzMS44Njg0IDE1NS4wNDEgMzEuNjc5NiAxNTUuMTgzIDMxLjQ1ODMgMTU1LjI3MUMzMS4yMzk1IDE1NS4zNTcgMzAuOTkyMSAxNTUuNCAzMC43MTYxIDE1NS40QzMwLjQ5NzMgMTU1LjQgMzAuMjk1NSAxNTUuMzczIDMwLjExMDYgMTU1LjMxOEMyOS45MjU3IDE1NS4yNjQgMjkuNzU5MSAxNTUuMTc2IDI5LjYxMDYgMTU1LjA1N0MyOS40NjQ4IDE1NC45MzQgMjkuMzM5OCAxNTQuNzc1IDI5LjIzNTYgMTU0LjU4QzI5LjEzMTUgMTU0LjM4NSAyOS4wNTIgMTU0LjE0OCAyOC45OTczIDE1My44NjlDMjguOTQyNyAxNTMuNTkgMjguOTE1MyAxNTMuMjY1IDI4LjkxNTMgMTUyLjg5M1YxNTIuMDI1QzI4LjkxNTMgMTUxLjU1OSAyOC45NTcgMTUxLjE2OSAyOS4wNDAzIDE1MC44NTRDMjkuMTI2MiAxNTAuNTM4IDI5LjI0NzMgMTUwLjI4NiAyOS40MDM2IDE1MC4wOTZDMjkuNTU5OCAxNDkuOTAzIDI5Ljc0NzMgMTQ5Ljc2NSAyOS45NjYxIDE0OS42ODJDMzAuMTg3NCAxNDkuNTk4IDMwLjQzNDggMTQ5LjU1NyAzMC43MDgzIDE0OS41NTdDMzAuOTI5NiAxNDkuNTU3IDMxLjEzMjggMTQ5LjU4NCAzMS4zMTc3IDE0OS42MzlDMzEuNTA1MiAxNDkuNjkxIDMxLjY3MTggMTQ5Ljc3NSAzMS44MTc3IDE0OS44OTNDMzEuOTYzNSAxNTAuMDA3IDMyLjA4NzIgMTUwLjE2MSAzMi4xODg3IDE1MC4zNTRDMzIuMjkyOSAxNTAuNTQ0IDMyLjM3MjMgMTUwLjc3NyAzMi40MjcgMTUxLjA1M0MzMi40ODE3IDE1MS4zMjkgMzIuNTA5MSAxNTEuNjUzIDMyLjUwOTEgMTUyLjAyNVpNMzEuNzgyNSAxNTMuMDFWMTUxLjkwNEMzMS43ODI1IDE1MS42NDkgMzEuNzY2OSAxNTEuNDI1IDMxLjczNTYgMTUxLjIzMkMzMS43MDcgMTUxLjAzNyAzMS42NjQgMTUwLjg3IDMxLjYwNjcgMTUwLjczMkMzMS41NDk0IDE1MC41OTQgMzEuNDc2NSAxNTAuNDgyIDMxLjM4OCAxNTAuMzk2QzMxLjMwMiAxNTAuMzExIDMxLjIwMTggMTUwLjI0OCAzMS4wODcyIDE1MC4yMDlDMzAuOTc1MiAxNTAuMTY3IDMwLjg0ODkgMTUwLjE0NiAzMC43MDgzIDE1MC4xNDZDMzAuNTM2NCAxNTAuMTQ2IDMwLjM4NDEgMTUwLjE3OSAzMC4yNTEyIDE1MC4yNDRDMzAuMTE4NCAxNTAuMzA3IDMwLjAwNjUgMTUwLjQwNyAyOS45MTUzIDE1MC41NDVDMjkuODI2OCAxNTAuNjgzIDI5Ljc1OTEgMTUwLjg2NCAyOS43MTIyIDE1MS4wODhDMjkuNjY1MyAxNTEuMzEyIDI5LjY0MTkgMTUxLjU4NCAyOS42NDE5IDE1MS45MDRWMTUzLjAxQzI5LjY0MTkgMTUzLjI2NSAyOS42NTYyIDE1My40OSAyOS42ODQ4IDE1My42ODZDMjkuNzE2MSAxNTMuODgxIDI5Ljc2MTcgMTU0LjA1IDI5LjgyMTYgMTU0LjE5M0MyOS44ODE1IDE1NC4zMzQgMjkuOTU0NCAxNTQuNDUgMzAuMDQwMyAxNTQuNTQxQzMwLjEyNjIgMTU0LjYzMiAzMC4yMjUyIDE1NC43IDMwLjMzNzIgMTU0Ljc0NEMzMC40NTE4IDE1NC43ODYgMzAuNTc4MSAxNTQuODA3IDMwLjcxNjEgMTU0LjgwN0MzMC44OTMyIDE1NC44MDcgMzEuMDQ4MSAxNTQuNzczIDMxLjE4MDkgMTU0LjcwNUMzMS4zMTM3IDE1NC42MzcgMzEuNDI0NCAxNTQuNTMyIDMxLjUxMyAxNTQuMzg5QzMxLjYwNDEgMTU0LjI0MyAzMS42NzE4IDE1NC4wNTcgMzEuNzE2MSAxNTMuODNDMzEuNzYwNCAxNTMuNjAxIDMxLjc4MjUgMTUzLjMyNyAzMS43ODI1IDE1My4wMVpNMzUuODk2NCAxNDkuNjA0VjE1NS4zMjJIMzUuMTczN1YxNTAuNTA2TDMzLjcxNjcgMTUxLjAzN1YxNTAuMzg1TDM1Ljc4MzEgMTQ5LjYwNEgzNS44OTY0Wk00MS4xMTI0IDE0OS42MzVWMTU1LjMyMkg0MC4zNTg1VjE0OS42MzVINDEuMTEyNFpNNDMuNDk1MiAxNTIuMTkzVjE1Mi44MTFINDAuOTQ4M1YxNTIuMTkzSDQzLjQ5NTJaTTQzLjg4MTkgMTQ5LjYzNVYxNTAuMjUySDQwLjk0ODNWMTQ5LjYzNUg0My44ODE5Wk00Ni40MjE2IDE1NS40QzQ2LjEyNzMgMTU1LjQgNDUuODYwNCAxNTUuMzUxIDQ1LjYyMDggMTU1LjI1MkM0NS4zODM4IDE1NS4xNSA0NS4xNzk0IDE1NS4wMDggNDUuMDA3NSAxNTQuODI2QzQ0LjgzODMgMTU0LjY0NCA0NC43MDgxIDE1NC40MjggNDQuNjE2OSAxNTQuMTc4QzQ0LjUyNTggMTUzLjkyOCA0NC40ODAyIDE1My42NTQgNDQuNDgwMiAxNTMuMzU3VjE1My4xOTNDNDQuNDgwMiAxNTIuODUgNDQuNTMxIDE1Mi41NDQgNDQuNjMyNSAxNTIuMjc1QzQ0LjczNDEgMTUyLjAwNSA0NC44NzIxIDE1MS43NzUgNDUuMDQ2NiAxNTEuNTg4QzQ1LjIyMTEgMTUxLjQgNDUuNDE5IDE1MS4yNTggNDUuNjQwMyAxNTEuMTYyQzQ1Ljg2MTcgMTUxLjA2NiA0Ni4wOTA5IDE1MS4wMTggNDYuMzI3OCAxNTEuMDE4QzQ2LjYyOTkgMTUxLjAxOCA0Ni44OTAzIDE1MS4wNyA0Ny4xMDkxIDE1MS4xNzRDNDcuMzMwNSAxNTEuMjc4IDQ3LjUxMTQgMTUxLjQyNCA0Ny42NTIxIDE1MS42MTFDNDcuNzkyNyAxNTEuNzk2IDQ3Ljg5NjkgMTUyLjAxNSA0Ny45NjQ2IDE1Mi4yNjhDNDguMDMyMyAxNTIuNTE4IDQ4LjA2NjEgMTUyLjc5MSA0OC4wNjYxIDE1My4wODhWMTUzLjQxMkg0NC45MDk5VjE1Mi44MjJINDcuMzQzNVYxNTIuNzY4QzQ3LjMzMzEgMTUyLjU4IDQ3LjI5NCAxNTIuMzk4IDQ3LjIyNjMgMTUyLjIyMUM0Ny4xNjEyIDE1Mi4wNDQgNDcuMDU3IDE1MS44OTggNDYuOTEzOCAxNTEuNzgzQzQ2Ljc3MDYgMTUxLjY2OSA0Ni41NzUyIDE1MS42MTEgNDYuMzI3OCAxNTEuNjExQzQ2LjE2MzggMTUxLjYxMSA0Ni4wMTI3IDE1MS42NDYgNDUuODc0NyAxNTEuNzE3QzQ1LjczNjcgMTUxLjc4NSA0NS42MTgyIDE1MS44ODYgNDUuNTE5MyAxNTIuMDIxQzQ1LjQyMDMgMTUyLjE1NyA0NS4zNDM1IDE1Mi4zMjIgNDUuMjg4OCAxNTIuNTE4QzQ1LjIzNDEgMTUyLjcxMyA0NS4yMDY4IDE1Mi45MzggNDUuMjA2OCAxNTMuMTkzVjE1My4zNTdDNDUuMjA2OCAxNTMuNTU4IDQ1LjIzNDEgMTUzLjc0NyA0NS4yODg4IDE1My45MjRDNDUuMzQ2MSAxNTQuMDk4IDQ1LjQyODEgMTU0LjI1MiA0NS41MzQ5IDE1NC4zODVDNDUuNjQ0MyAxNTQuNTE4IDQ1Ljc3NTggMTU0LjYyMiA0NS45Mjk0IDE1NC42OTdDNDYuMDg1NyAxNTQuNzczIDQ2LjI2MjcgMTU0LjgxMSA0Ni40NjA3IDE1NC44MTFDNDYuNzE1OSAxNTQuODExIDQ2LjkzMiAxNTQuNzU4IDQ3LjEwOTEgMTU0LjY1NEM0Ny4yODYyIDE1NC41NSA0Ny40NDExIDE1NC40MTEgNDcuNTczOSAxNTQuMjM2TDQ4LjAxMTQgMTU0LjU4NEM0Ny45MjAzIDE1NC43MjIgNDcuODA0NCAxNTQuODU0IDQ3LjY2MzggMTU0Ljk3OUM0Ny41MjMyIDE1NS4xMDQgNDcuMzUgMTU1LjIwNSA0Ny4xNDQzIDE1NS4yODNDNDYuOTQxMSAxNTUuMzYxIDQ2LjcwMDIgMTU1LjQgNDYuNDIxNiAxNTUuNFpNNDguOTg4NiAxNDkuMzIySDQ5LjcxNTJWMTU0LjUwMkw0OS42NTI3IDE1NS4zMjJINDguOTg4NlYxNDkuMzIyWk01Mi41NzA2IDE1My4xNzRWMTUzLjI1NkM1Mi41NzA2IDE1My41NjMgNTIuNTM0MiAxNTMuODQ4IDUyLjQ2MTMgMTU0LjExMUM1Mi4zODgzIDE1NC4zNzIgNTIuMjgxNiAxNTQuNTk4IDUyLjE0MDkgMTU0Ljc5MUM1Mi4wMDAzIDE1NC45ODQgNTEuODI4NCAxNTUuMTMzIDUxLjYyNTMgMTU1LjI0QzUxLjQyMjIgMTU1LjM0NyA1MS4xODkxIDE1NS40IDUwLjkyNjEgMTU1LjRDNTAuNjU3OSAxNTUuNCA1MC40MjIyIDE1NS4zNTUgNTAuMjE5MSAxNTUuMjY0QzUwLjAxODUgMTU1LjE3IDQ5Ljg0OTMgMTU1LjAzNiA0OS43MTEzIDE1NC44NjFDNDkuNTczMiAxNTQuNjg3IDQ5LjQ2MjYgMTU0LjQ3NiA0OS4zNzkyIDE1NC4yMjlDNDkuMjk4NSAxNTMuOTgxIDQ5LjI0MjUgMTUzLjcwMiA0OS4yMTEzIDE1My4zOTNWMTUzLjAzM0M0OS4yNDI1IDE1Mi43MjEgNDkuMjk4NSAxNTIuNDQxIDQ5LjM3OTIgMTUyLjE5M0M0OS40NjI2IDE1MS45NDYgNDkuNTczMiAxNTEuNzM1IDQ5LjcxMTMgMTUxLjU2MUM0OS44NDkzIDE1MS4zODMgNTAuMDE4NSAxNTEuMjQ5IDUwLjIxOTEgMTUxLjE1OEM1MC40MTk2IDE1MS4wNjQgNTAuNjUyNyAxNTEuMDE4IDUwLjkxODMgMTUxLjAxOEM1MS4xODM5IDE1MS4wMTggNTEuNDE5NiAxNTEuMDcgNTEuNjI1MyAxNTEuMTc0QzUxLjgzMSAxNTEuMjc1IDUyLjAwMjkgMTUxLjQyMSA1Mi4xNDA5IDE1MS42MTFDNTIuMjgxNiAxNTEuODAxIDUyLjM4ODMgMTUyLjAyOSA1Mi40NjEzIDE1Mi4yOTVDNTIuNTM0MiAxNTIuNTU4IDUyLjU3MDYgMTUyLjg1MSA1Mi41NzA2IDE1My4xNzRaTTUxLjg0NDEgMTUzLjI1NlYxNTMuMTc0QzUxLjg0NDEgMTUyLjk2MyA1MS44MjQ1IDE1Mi43NjUgNTEuNzg1NSAxNTIuNThDNTEuNzQ2NCAxNTIuMzkzIDUxLjY4MzkgMTUyLjIyOSA1MS41OTggMTUyLjA4OEM1MS41MTIgMTUxLjk0NSA1MS4zOTg4IDE1MS44MzMgNTEuMjU4MSAxNTEuNzUyQzUxLjExNzUgMTUxLjY2OSA1MC45NDQzIDE1MS42MjcgNTAuNzM4NiAxNTEuNjI3QzUwLjU1NjMgMTUxLjYyNyA1MC4zOTc1IDE1MS42NTggNTAuMjYyIDE1MS43MjFDNTAuMTI5MiAxNTEuNzgzIDUwLjAxNTkgMTUxLjg2OCA0OS45MjIyIDE1MS45NzVDNDkuODI4NCAxNTIuMDc5IDQ5Ljc1MTYgMTUyLjE5OSA0OS42OTE3IDE1Mi4zMzRDNDkuNjM0NCAxNTIuNDY3IDQ5LjU5MTUgMTUyLjYwNSA0OS41NjI4IDE1Mi43NDhWMTUzLjY4OUM0OS42MDQ1IDE1My44NzIgNDkuNjcyMiAxNTQuMDQ4IDQ5Ljc2NTkgMTU0LjIxN0M0OS44NjIzIDE1NC4zODMgNDkuOTg5OSAxNTQuNTIgNTAuMTQ4OCAxNTQuNjI3QzUwLjMxMDIgMTU0LjczNCA1MC41MDk0IDE1NC43ODcgNTAuNzQ2NCAxNTQuNzg3QzUwLjk0MTcgMTU0Ljc4NyA1MS4xMDg0IDE1NC43NDggNTEuMjQ2NCAxNTQuNjdDNTEuMzg3IDE1NC41ODkgNTEuNTAwMyAxNTQuNDc5IDUxLjU4NjMgMTU0LjMzOEM1MS42NzQ4IDE1NC4xOTcgNTEuNzM5OSAxNTQuMDM1IDUxLjc4MTYgMTUzLjg1QzUxLjgyMzIgMTUzLjY2NSA1MS44NDQxIDE1My40NjcgNTEuODQ0MSAxNTMuMjU2WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8ZyBjbGlwLXBhdGg9InVybCgjY2xpcDFfNDE4NF85NDUyNykiPgo8bGluZSB4MT0iNzUuODQ5OSIgeTE9IjE0Ny4wNzIiIHgyPSI3NS44NDk5IiB5Mj0iMTQ2LjI1IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8cGF0aCBkPSJNNjcuOTA5IDE1Mi4wMjVWMTUyLjg5MkM2Ny45MDkgMTUzLjM1OCA2Ny44NjczIDE1My43NTIgNjcuNzg0IDE1NC4wNzJDNjcuNzAwNiAxNTQuMzkyIDY3LjU4MDggMTU0LjY1IDY3LjQyNDYgMTU0Ljg0NUM2Ny4yNjgzIDE1NS4wNDEgNjcuMDc5NSAxNTUuMTgzIDY2Ljg1ODIgMTU1LjI3MUM2Ni42Mzk0IDE1NS4zNTcgNjYuMzkyIDE1NS40IDY2LjExNiAxNTUuNEM2NS44OTcyIDE1NS40IDY1LjY5NTQgMTU1LjM3MyA2NS41MTA1IDE1NS4zMThDNjUuMzI1NiAxNTUuMjYzIDY1LjE1OSAxNTUuMTc2IDY1LjAxMDUgMTU1LjA1NkM2NC44NjQ3IDE1NC45MzQgNjQuNzM5NyAxNTQuNzc1IDY0LjYzNTUgMTU0LjU4QzY0LjUzMTQgMTU0LjM4NSA2NC40NTE5IDE1NC4xNDggNjQuMzk3MiAxNTMuODY5QzY0LjM0MjYgMTUzLjU5IDY0LjMxNTIgMTUzLjI2NSA2NC4zMTUyIDE1Mi44OTJWMTUyLjAyNUM2NC4zMTUyIDE1MS41NTkgNjQuMzU2OSAxNTEuMTY4IDY0LjQ0MDIgMTUwLjg1M0M2NC41MjYxIDE1MC41MzggNjQuNjQ3MiAxNTAuMjg2IDY0LjgwMzUgMTUwLjA5NUM2NC45NTk3IDE0OS45MDMgNjUuMTQ3MiAxNDkuNzY1IDY1LjM2NiAxNDkuNjgxQzY1LjU4NzMgMTQ5LjU5OCA2NS44MzQ3IDE0OS41NTYgNjYuMTA4MiAxNDkuNTU2QzY2LjMyOTUgMTQ5LjU1NiA2Ni41MzI3IDE0OS41ODQgNjYuNzE3NiAxNDkuNjM4QzY2LjkwNTEgMTQ5LjY5MSA2Ny4wNzE3IDE0OS43NzUgNjcuMjE3NiAxNDkuODkyQzY3LjM2MzQgMTUwLjAwNyA2Ny40ODcxIDE1MC4xNjEgNjcuNTg4NiAxNTAuMzUzQzY3LjY5MjggMTUwLjU0MyA2Ny43NzIyIDE1MC43NzYgNjcuODI2OSAxNTEuMDUyQzY3Ljg4MTYgMTUxLjMyOSA2Ny45MDkgMTUxLjY1MyA2Ny45MDkgMTUyLjAyNVpNNjcuMTgyNCAxNTMuMDFWMTUxLjkwNEM2Ny4xODI0IDE1MS42NDkgNjcuMTY2OCAxNTEuNDI1IDY3LjEzNTUgMTUxLjIzMkM2Ny4xMDY5IDE1MS4wMzcgNjcuMDYzOSAxNTAuODcgNjcuMDA2NiAxNTAuNzMyQzY2Ljk0OTMgMTUwLjU5NCA2Ni44NzY0IDE1MC40ODIgNjYuNzg3OSAxNTAuMzk2QzY2LjcwMTkgMTUwLjMxIDY2LjYwMTcgMTUwLjI0OCA2Ni40ODcxIDE1MC4yMDlDNjYuMzc1MSAxNTAuMTY3IDY2LjI0ODggMTUwLjE0NiA2Ni4xMDgyIDE1MC4xNDZDNjUuOTM2MyAxNTAuMTQ2IDY1Ljc4NCAxNTAuMTc5IDY1LjY1MTEgMTUwLjI0NEM2NS41MTgzIDE1MC4zMDYgNjUuNDA2NCAxNTAuNDA3IDY1LjMxNTIgMTUwLjU0NUM2NS4yMjY3IDE1MC42ODMgNjUuMTU5IDE1MC44NjQgNjUuMTEyMSAxNTEuMDg4QzY1LjA2NTIgMTUxLjMxMiA2NS4wNDE4IDE1MS41ODQgNjUuMDQxOCAxNTEuOTA0VjE1My4wMUM2NS4wNDE4IDE1My4yNjUgNjUuMDU2MSAxNTMuNDkgNjUuMDg0NyAxNTMuNjg1QzY1LjExNiAxNTMuODgxIDY1LjE2MTYgMTU0LjA1IDY1LjIyMTUgMTU0LjE5M0M2NS4yODE0IDE1NC4zMzQgNjUuMzU0MyAxNTQuNDUgNjUuNDQwMiAxNTQuNTQxQzY1LjUyNjEgMTU0LjYzMiA2NS42MjUxIDE1NC43IDY1LjczNzEgMTU0Ljc0NEM2NS44NTE3IDE1NC43ODYgNjUuOTc4IDE1NC44MDYgNjYuMTE2IDE1NC44MDZDNjYuMjkzMSAxNTQuODA2IDY2LjQ0OCAxNTQuNzczIDY2LjU4MDggMTU0LjcwNUM2Ni43MTM2IDE1NC42MzcgNjYuODI0MyAxNTQuNTMyIDY2LjkxMjkgMTU0LjM4OEM2Ny4wMDQgMTU0LjI0MyA2Ny4wNzE3IDE1NC4wNTYgNjcuMTE2IDE1My44M0M2Ny4xNjAzIDE1My42MDEgNjcuMTgyNCAxNTMuMzI3IDY3LjE4MjQgMTUzLjAxWk03Mi42NDc4IDE1NC43MjhWMTU1LjMyMkg2OC45MjUyVjE1NC44MDJMNzAuNzg4NSAxNTIuNzI4QzcxLjAxNzYgMTUyLjQ3MyA3MS4xOTQ3IDE1Mi4yNTcgNzEuMzE5NyAxNTIuMDhDNzEuNDQ3MyAxNTEuOSA3MS41MzU5IDE1MS43NCA3MS41ODUzIDE1MS41OTlDNzEuNjM3NCAxNTEuNDU2IDcxLjY2MzUgMTUxLjMxIDcxLjY2MzUgMTUxLjE2MkM3MS42NjM1IDE1MC45NzQgNzEuNjI0NCAxNTAuODA1IDcxLjU0NjMgMTUwLjY1NEM3MS40NzA4IDE1MC41IDcxLjM1ODggMTUwLjM3OCA3MS4yMTAzIDE1MC4yODdDNzEuMDYxOSAxNTAuMTk2IDcwLjg4MjIgMTUwLjE1IDcwLjY3MTMgMTUwLjE1QzcwLjQxODcgMTUwLjE1IDcwLjIwNzcgMTUwLjIgNzAuMDM4NSAxNTAuMjk5QzY5Ljg3MTggMTUwLjM5NSA2OS43NDY4IDE1MC41MyA2OS42NjM1IDE1MC43MDVDNjkuNTgwMSAxNTAuODc5IDY5LjUzODUgMTUxLjA4IDY5LjUzODUgMTUxLjMwNkg2OC44MTU4QzY4LjgxNTggMTUwLjk4NiA2OC44ODYxIDE1MC42OTMgNjkuMDI2NyAxNTAuNDI3QzY5LjE2NzQgMTUwLjE2MiA2OS4zNzU3IDE0OS45NTEgNjkuNjUxNyAxNDkuNzk1QzY5LjkyNzggMTQ5LjYzNiA3MC4yNjc2IDE0OS41NTYgNzAuNjcxMyAxNDkuNTU2QzcxLjAzMDcgMTQ5LjU1NiA3MS4zMzc5IDE0OS42MiA3MS41OTMyIDE0OS43NDhDNzEuODQ4NCAxNDkuODczIDcyLjA0MzcgMTUwLjA1IDcyLjE3OTEgMTUwLjI3OUM3Mi4zMTcxIDE1MC41MDYgNzIuMzg2MSAxNTAuNzcxIDcyLjM4NjEgMTUxLjA3NkM3Mi4zODYxIDE1MS4yNDMgNzIuMzU3NSAxNTEuNDEyIDcyLjMwMDIgMTUxLjU4NEM3Mi4yNDU1IDE1MS43NTMgNzIuMTY4NyAxNTEuOTIyIDcyLjA2OTcgMTUyLjA5MkM3MS45NzM0IDE1Mi4yNjEgNzEuODYwMSAxNTIuNDI3IDcxLjcyOTkgMTUyLjU5MkM3MS42MDIzIDE1Mi43NTYgNzEuNDY1NSAxNTIuOTE3IDcxLjMxOTcgMTUzLjA3Nkw2OS43OTYzIDE1NC43MjhINzIuNjQ3OFpNNzYuNTEyMyAxNDkuNjM1VjE1NS4zMjJINzUuNzU4NFYxNDkuNjM1SDc2LjUxMjNaTTc4Ljg5NTEgMTUyLjE5M1YxNTIuODFINzYuMzQ4MlYxNTIuMTkzSDc4Ljg5NTFaTTc5LjI4MTggMTQ5LjYzNVYxNTAuMjUySDc2LjM0ODJWMTQ5LjYzNUg3OS4yODE4Wk04MS44MjE1IDE1NS40QzgxLjUyNzIgMTU1LjQgODEuMjYwMyAxNTUuMzUxIDgxLjAyMDcgMTU1LjI1MkM4MC43ODM3IDE1NS4xNSA4MC41NzkzIDE1NS4wMDggODAuNDA3NCAxNTQuODI2QzgwLjIzODIgMTU0LjY0NCA4MC4xMDggMTU0LjQyNyA4MC4wMTY4IDE1NC4xNzdDNzkuOTI1NyAxNTMuOTI3IDc5Ljg4MDEgMTUzLjY1NCA3OS44ODAxIDE1My4zNTdWMTUzLjE5M0M3OS44ODAxIDE1Mi44NDkgNzkuOTMwOSAxNTIuNTQzIDgwLjAzMjQgMTUyLjI3NUM4MC4xMzQgMTUyLjAwNCA4MC4yNzIgMTUxLjc3NSA4MC40NDY1IDE1MS41ODhDODAuNjIxIDE1MS40IDgwLjgxODkgMTUxLjI1OCA4MS4wNDAyIDE1MS4xNjJDODEuMjYxNiAxNTEuMDY2IDgxLjQ5MDggMTUxLjAxNyA4MS43Mjc3IDE1MS4wMTdDODIuMDI5OCAxNTEuMDE3IDgyLjI5MDIgMTUxLjA2OSA4Mi41MDkgMTUxLjE3NEM4Mi43MzA0IDE1MS4yNzggODIuOTExMyAxNTEuNDI0IDgzLjA1MiAxNTEuNjExQzgzLjE5MjYgMTUxLjc5NiA4My4yOTY4IDE1Mi4wMTUgODMuMzY0NSAxNTIuMjY3QzgzLjQzMjIgMTUyLjUxNyA4My40NjYgMTUyLjc5MSA4My40NjYgMTUzLjA4OFYxNTMuNDEySDgwLjMwOThWMTUyLjgyMkg4Mi43NDM0VjE1Mi43NjdDODIuNzMzIDE1Mi41OCA4Mi42OTM5IDE1Mi4zOTggODIuNjI2MiAxNTIuMjJDODIuNTYxMSAxNTIuMDQzIDgyLjQ1NjkgMTUxLjg5OCA4Mi4zMTM3IDE1MS43ODNDODIuMTcwNSAxNTEuNjY4IDgxLjk3NTEgMTUxLjYxMSA4MS43Mjc3IDE1MS42MTFDODEuNTYzNyAxNTEuNjExIDgxLjQxMjYgMTUxLjY0NiA4MS4yNzQ2IDE1MS43MTdDODEuMTM2NiAxNTEuNzg0IDgxLjAxODEgMTUxLjg4NiA4MC45MTkyIDE1Mi4wMjFDODAuODIwMiAxNTIuMTU3IDgwLjc0MzQgMTUyLjMyMiA4MC42ODg3IDE1Mi41MTdDODAuNjM0IDE1Mi43MTMgODAuNjA2NyAxNTIuOTM4IDgwLjYwNjcgMTUzLjE5M1YxNTMuMzU3QzgwLjYwNjcgMTUzLjU1OCA4MC42MzQgMTUzLjc0NyA4MC42ODg3IDE1My45MjRDODAuNzQ2IDE1NC4wOTggODAuODI4IDE1NC4yNTIgODAuOTM0OCAxNTQuMzg1QzgxLjA0NDIgMTU0LjUxNyA4MS4xNzU3IDE1NC42MjIgODEuMzI5MyAxNTQuNjk3QzgxLjQ4NTYgMTU0Ljc3MyA4MS42NjI2IDE1NC44MSA4MS44NjA2IDE1NC44MUM4Mi4xMTU4IDE1NC44MSA4Mi4zMzE5IDE1NC43NTggODIuNTA5IDE1NC42NTRDODIuNjg2MSAxNTQuNTUgODIuODQxIDE1NC40MTEgODIuOTczOCAxNTQuMjM2TDgzLjQxMTMgMTU0LjU4NEM4My4zMjAyIDE1NC43MjIgODMuMjA0MyAxNTQuODUzIDgzLjA2MzcgMTU0Ljk3OEM4Mi45MjMxIDE1NS4xMDMgODIuNzQ5OSAxNTUuMjA1IDgyLjU0NDIgMTU1LjI4M0M4Mi4zNDEgMTU1LjM2MSA4Mi4xMDAxIDE1NS40IDgxLjgyMTUgMTU1LjRaTTg0LjM4ODUgMTQ5LjMyMkg4NS4xMTUxVjE1NC41MDJMODUuMDUyNiAxNTUuMzIySDg0LjM4ODVWMTQ5LjMyMlpNODcuOTcwNSAxNTMuMTc0VjE1My4yNTZDODcuOTcwNSAxNTMuNTYzIDg3LjkzNDEgMTUzLjg0OCA4Ny44NjEyIDE1NC4xMTFDODcuNzg4MiAxNTQuMzcyIDg3LjY4MTUgMTU0LjU5OCA4Ny41NDA4IDE1NC43OTFDODcuNDAwMiAxNTQuOTgzIDg3LjIyODMgMTU1LjEzMyA4Ny4wMjUyIDE1NS4yNEM4Ni44MjIxIDE1NS4zNDcgODYuNTg5IDE1NS40IDg2LjMyNiAxNTUuNEM4Ni4wNTc4IDE1NS40IDg1LjgyMjEgMTU1LjM1NSA4NS42MTkgMTU1LjI2M0M4NS40MTg1IDE1NS4xNyA4NS4yNDkyIDE1NS4wMzYgODUuMTExMiAxNTQuODYxQzg0Ljk3MzEgMTU0LjY4NyA4NC44NjI1IDE1NC40NzYgODQuNzc5MSAxNTQuMjI4Qzg0LjY5ODQgMTUzLjk4MSA4NC42NDI0IDE1My43MDIgODQuNjExMiAxNTMuMzkyVjE1My4wMzNDODQuNjQyNCAxNTIuNzIgODQuNjk4NCAxNTIuNDQxIDg0Ljc3OTEgMTUyLjE5M0M4NC44NjI1IDE1MS45NDYgODQuOTczMSAxNTEuNzM1IDg1LjExMTIgMTUxLjU2Qzg1LjI0OTIgMTUxLjM4MyA4NS40MTg1IDE1MS4yNDkgODUuNjE5IDE1MS4xNThDODUuODE5NSAxNTEuMDY0IDg2LjA1MjYgMTUxLjAxNyA4Ni4zMTgyIDE1MS4wMTdDODYuNTgzOCAxNTEuMDE3IDg2LjgxOTUgMTUxLjA2OSA4Ny4wMjUyIDE1MS4xNzRDODcuMjMxIDE1MS4yNzUgODcuNDAyOCAxNTEuNDIxIDg3LjU0MDggMTUxLjYxMUM4Ny42ODE1IDE1MS44MDEgODcuNzg4MiAxNTIuMDI5IDg3Ljg2MTIgMTUyLjI5NUM4Ny45MzQxIDE1Mi41NTggODcuOTcwNSAxNTIuODUxIDg3Ljk3MDUgMTUzLjE3NFpNODcuMjQ0IDE1My4yNTZWMTUzLjE3NEM4Ny4yNDQgMTUyLjk2MyA4Ny4yMjQ0IDE1Mi43NjUgODcuMTg1NCAxNTIuNThDODcuMTQ2MyAxNTIuMzkyIDg3LjA4MzggMTUyLjIyOCA4Ni45OTc5IDE1Mi4wODhDODYuOTExOSAxNTEuOTQ0IDg2Ljc5ODcgMTUxLjgzMiA4Ni42NTggMTUxLjc1MkM4Ni41MTc0IDE1MS42NjggODYuMzQ0MiAxNTEuNjI3IDg2LjEzODUgMTUxLjYyN0M4NS45NTYyIDE1MS42MjcgODUuNzk3NCAxNTEuNjU4IDg1LjY2MTkgMTUxLjcyQzg1LjUyOTEgMTUxLjc4MyA4NS40MTU4IDE1MS44NjggODUuMzIyMSAxNTEuOTc0Qzg1LjIyODMgMTUyLjA3OSA4NS4xNTE1IDE1Mi4xOTggODUuMDkxNiAxNTIuMzM0Qzg1LjAzNDMgMTUyLjQ2NyA4NC45OTE0IDE1Mi42MDUgODQuOTYyNyAxNTIuNzQ4VjE1My42ODlDODUuMDA0NCAxNTMuODcyIDg1LjA3MjEgMTU0LjA0NyA4NS4xNjU4IDE1NC4yMTdDODUuMjYyMiAxNTQuMzgzIDg1LjM4OTggMTU0LjUyIDg1LjU0ODcgMTU0LjYyN0M4NS43MTAxIDE1NC43MzMgODUuOTA5MyAxNTQuNzg3IDg2LjE0NjMgMTU0Ljc4N0M4Ni4zNDE2IDE1NC43ODcgODYuNTA4MyAxNTQuNzQ4IDg2LjY0NjMgMTU0LjY3Qzg2Ljc4NjkgMTU0LjU4OSA4Ni45MDAyIDE1NC40NzggODYuOTg2MiAxNTQuMzM4Qzg3LjA3NDcgMTU0LjE5NyA4Ny4xMzk4IDE1NC4wMzQgODcuMTgxNSAxNTMuODQ5Qzg3LjIyMzEgMTUzLjY2NCA4Ny4yNDQgMTUzLjQ2NyA4Ny4yNDQgMTUzLjI1NloiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPC9nPgo8ZyBjbGlwLXBhdGg9InVybCgjY2xpcDJfNDE4NF85NDUyNykiPgo8bGluZSB4MT0iMTExLjI1IiB5MT0iMTQ3LjA3MiIgeDI9IjExMS4yNSIgeTI9IjE0Ni4yNSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuNSIgc3Ryb2tlLXdpZHRoPSIwLjUiIHN0cm9rZS1saW5lY2FwPSJzcXVhcmUiLz4KPHBhdGggZD0iTTEwMy4zMDkgMTUyLjAyNVYxNTIuODkyQzEwMy4zMDkgMTUzLjM1OCAxMDMuMjY3IDE1My43NTIgMTAzLjE4NCAxNTQuMDcyQzEwMy4xMDEgMTU0LjM5MiAxMDIuOTgxIDE1NC42NSAxMDIuODI1IDE1NC44NDVDMTAyLjY2OCAxNTUuMDQxIDEwMi40OCAxNTUuMTgzIDEwMi4yNTggMTU1LjI3MUMxMDIuMDQgMTU1LjM1NyAxMDEuNzkyIDE1NS40IDEwMS41MTYgMTU1LjRDMTAxLjI5NyAxNTUuNCAxMDEuMDk2IDE1NS4zNzMgMTAwLjkxMSAxNTUuMzE4QzEwMC43MjYgMTU1LjI2MyAxMDAuNTU5IDE1NS4xNzYgMTAwLjQxMSAxNTUuMDU2QzEwMC4yNjUgMTU0LjkzNCAxMDAuMTQgMTU0Ljc3NSAxMDAuMDM2IDE1NC41OEM5OS45MzE1IDE1NC4zODUgOTkuODUyMSAxNTQuMTQ4IDk5Ljc5NzQgMTUzLjg2OUM5OS43NDI3IDE1My41OSA5OS43MTU0IDE1My4yNjUgOTkuNzE1NCAxNTIuODkyVjE1Mi4wMjVDOTkuNzE1NCAxNTEuNTU5IDk5Ljc1NyAxNTEuMTY4IDk5Ljg0MDQgMTUwLjg1M0M5OS45MjYzIDE1MC41MzggMTAwLjA0NyAxNTAuMjg2IDEwMC4yMDQgMTUwLjA5NUMxMDAuMzYgMTQ5LjkwMyAxMDAuNTQ3IDE0OS43NjUgMTAwLjc2NiAxNDkuNjgxQzEwMC45ODcgMTQ5LjU5OCAxMDEuMjM1IDE0OS41NTYgMTAxLjUwOCAxNDkuNTU2QzEwMS43MyAxNDkuNTU2IDEwMS45MzMgMTQ5LjU4NCAxMDIuMTE4IDE0OS42MzhDMTAyLjMwNSAxNDkuNjkxIDEwMi40NzIgMTQ5Ljc3NSAxMDIuNjE4IDE0OS44OTJDMTAyLjc2NCAxNTAuMDA3IDEwMi44ODcgMTUwLjE2MSAxMDIuOTg5IDE1MC4zNTNDMTAzLjA5MyAxNTAuNTQzIDEwMy4xNzIgMTUwLjc3NiAxMDMuMjI3IDE1MS4wNTJDMTAzLjI4MiAxNTEuMzI5IDEwMy4zMDkgMTUxLjY1MyAxMDMuMzA5IDE1Mi4wMjVaTTEwMi41ODMgMTUzLjAxVjE1MS45MDRDMTAyLjU4MyAxNTEuNjQ5IDEwMi41NjcgMTUxLjQyNSAxMDIuNTM2IDE1MS4yMzJDMTAyLjUwNyAxNTEuMDM3IDEwMi40NjQgMTUwLjg3IDEwMi40MDcgMTUwLjczMkMxMDIuMzQ5IDE1MC41OTQgMTAyLjI3NyAxNTAuNDgyIDEwMi4xODggMTUwLjM5NkMxMDIuMTAyIDE1MC4zMSAxMDIuMDAyIDE1MC4yNDggMTAxLjg4NyAxNTAuMjA5QzEwMS43NzUgMTUwLjE2NyAxMDEuNjQ5IDE1MC4xNDYgMTAxLjUwOCAxNTAuMTQ2QzEwMS4zMzYgMTUwLjE0NiAxMDEuMTg0IDE1MC4xNzkgMTAxLjA1MSAxNTAuMjQ0QzEwMC45MTggMTUwLjMwNiAxMDAuODA3IDE1MC40MDcgMTAwLjcxNSAxNTAuNTQ1QzEwMC42MjcgMTUwLjY4MyAxMDAuNTU5IDE1MC44NjQgMTAwLjUxMiAxNTEuMDg4QzEwMC40NjUgMTUxLjMxMiAxMDAuNDQyIDE1MS41ODQgMTAwLjQ0MiAxNTEuOTA0VjE1My4wMUMxMDAuNDQyIDE1My4yNjUgMTAwLjQ1NiAxNTMuNDkgMTAwLjQ4NSAxNTMuNjg1QzEwMC41MTYgMTUzLjg4MSAxMDAuNTYyIDE1NC4wNSAxMDAuNjIyIDE1NC4xOTNDMTAwLjY4MiAxNTQuMzM0IDEwMC43NTQgMTU0LjQ1IDEwMC44NCAxNTQuNTQxQzEwMC45MjYgMTU0LjYzMiAxMDEuMDI1IDE1NC43IDEwMS4xMzcgMTU0Ljc0NEMxMDEuMjUyIDE1NC43ODYgMTAxLjM3OCAxNTQuODA2IDEwMS41MTYgMTU0LjgwNkMxMDEuNjkzIDE1NC44MDYgMTAxLjg0OCAxNTQuNzczIDEwMS45ODEgMTU0LjcwNUMxMDIuMTE0IDE1NC42MzcgMTAyLjIyNCAxNTQuNTMyIDEwMi4zMTMgMTU0LjM4OEMxMDIuNDA0IDE1NC4yNDMgMTAyLjQ3MiAxNTQuMDU2IDEwMi41MTYgMTUzLjgzQzEwMi41NiAxNTMuNjAxIDEwMi41ODMgMTUzLjMyNyAxMDIuNTgzIDE1My4wMVpNMTA1LjM3NiAxNTIuMTIzSDEwNS44OTJDMTA2LjE0NCAxNTIuMTIzIDEwNi4zNTMgMTUyLjA4MSAxMDYuNTE3IDE1MS45OThDMTA2LjY4MyAxNTEuOTEyIDEwNi44MDcgMTUxLjc5NiAxMDYuODg4IDE1MS42NUMxMDYuOTcxIDE1MS41MDIgMTA3LjAxMyAxNTEuMzM1IDEwNy4wMTMgMTUxLjE1QzEwNy4wMTMgMTUwLjkzMSAxMDYuOTc2IDE1MC43NDggMTA2LjkwMyAxNTAuNTk5QzEwNi44MzEgMTUwLjQ1MSAxMDYuNzIxIDE1MC4zMzkgMTA2LjU3NSAxNTAuMjYzQzEwNi40MjkgMTUwLjE4OCAxMDYuMjQ1IDE1MC4xNSAxMDYuMDIxIDE1MC4xNUMxMDUuODE4IDE1MC4xNSAxMDUuNjM4IDE1MC4xOTEgMTA1LjQ4MiAxNTAuMjcxQzEwNS4zMjggMTUwLjM0OSAxMDUuMjA3IDE1MC40NjEgMTA1LjExOCAxNTAuNjA3QzEwNS4wMzIgMTUwLjc1MyAxMDQuOTg5IDE1MC45MjUgMTA0Ljk4OSAxNTEuMTIzSDEwNC4yNjdDMTA0LjI2NyAxNTAuODM0IDEwNC4zNCAxNTAuNTcxIDEwNC40ODUgMTUwLjMzNEMxMDQuNjMxIDE1MC4wOTcgMTA0LjgzNiAxNDkuOTA4IDEwNS4wOTkgMTQ5Ljc2N0MxMDUuMzY0IDE0OS42MjcgMTA1LjY3MiAxNDkuNTU2IDEwNi4wMjEgMTQ5LjU1NkMxMDYuMzY0IDE0OS41NTYgMTA2LjY2NSAxNDkuNjE4IDEwNi45MjMgMTQ5Ljc0QzEwNy4xODEgMTQ5Ljg2IDEwNy4zODEgMTUwLjAzOSAxMDcuNTI1IDE1MC4yNzlDMTA3LjY2OCAxNTAuNTE2IDEwNy43MzkgMTUwLjgxMiAxMDcuNzM5IDE1MS4xNjZDMTA3LjczOSAxNTEuMzA5IDEwNy43MDYgMTUxLjQ2MyAxMDcuNjM4IDE1MS42MjdDMTA3LjU3MyAxNTEuNzg4IDEwNy40NyAxNTEuOTM5IDEwNy4zMjkgMTUyLjA4QzEwNy4xOTEgMTUyLjIyIDEwNy4wMTIgMTUyLjMzNiAxMDYuNzkgMTUyLjQyN0MxMDYuNTY5IDE1Mi41MTYgMTA2LjMwMyAxNTIuNTYgMTA1Ljk5MyAxNTIuNTZIMTA1LjM3NlYxNTIuMTIzWk0xMDUuMzc2IDE1Mi43MTdWMTUyLjI4M0gxMDUuOTkzQzEwNi4zNTUgMTUyLjI4MyAxMDYuNjU1IDE1Mi4zMjYgMTA2Ljg5MiAxNTIuNDEyQzEwNy4xMjkgMTUyLjQ5OCAxMDcuMzE1IDE1Mi42MTIgMTA3LjQ1IDE1Mi43NTZDMTA3LjU4OCAxNTIuODk5IDEwNy42ODUgMTUzLjA1NiAxMDcuNzM5IDE1My4yMjhDMTA3Ljc5NyAxNTMuMzk4IDEwNy44MjUgMTUzLjU2NyAxMDcuODI1IDE1My43MzZDMTA3LjgyNSAxNTQuMDAyIDEwNy43OCAxNTQuMjM3IDEwNy42ODkgMTU0LjQ0M0MxMDcuNiAxNTQuNjQ5IDEwNy40NzQgMTU0LjgyMyAxMDcuMzEgMTU0Ljk2N0MxMDcuMTQ4IDE1NS4xMSAxMDYuOTU4IDE1NS4yMTggMTA2LjczOSAxNTUuMjkxQzEwNi41MjEgMTU1LjM2NCAxMDYuMjgyIDE1NS40IDEwNi4wMjUgMTU1LjRDMTA1Ljc3NyAxNTUuNCAxMDUuNTQ0IDE1NS4zNjUgMTA1LjMyNSAxNTUuMjk1QzEwNS4xMDkgMTU1LjIyNCAxMDQuOTE4IDE1NS4xMjMgMTA0Ljc1MSAxNTQuOTlDMTA0LjU4NCAxNTQuODU1IDEwNC40NTQgMTU0LjY4OSAxMDQuMzYgMTU0LjQ5NEMxMDQuMjY3IDE1NC4yOTYgMTA0LjIyIDE1NC4wNzEgMTA0LjIyIDE1My44MThIMTA0Ljk0M0MxMDQuOTQzIDE1NC4wMTYgMTA0Ljk4NSAxNTQuMTg5IDEwNS4wNzEgMTU0LjMzOEMxMDUuMTYgMTU0LjQ4NiAxMDUuMjg1IDE1NC42MDIgMTA1LjQ0NiAxNTQuNjg1QzEwNS42MSAxNTQuNzY2IDEwNS44MDMgMTU0LjgwNiAxMDYuMDI1IDE1NC44MDZDMTA2LjI0NiAxNTQuODA2IDEwNi40MzYgMTU0Ljc2OSAxMDYuNTk1IDE1NC42OTNDMTA2Ljc1NiAxNTQuNjE1IDEwNi44OCAxNTQuNDk4IDEwNi45NjYgMTU0LjM0MkMxMDcuMDU0IDE1NC4xODUgMTA3LjA5OSAxNTMuOTg5IDEwNy4wOTkgMTUzLjc1MkMxMDcuMDk5IDE1My41MTUgMTA3LjA0OSAxNTMuMzIxIDEwNi45NSAxNTMuMTdDMTA2Ljg1MSAxNTMuMDE2IDEwNi43MTEgMTUyLjkwMyAxMDYuNTI4IDE1Mi44M0MxMDYuMzQ5IDE1Mi43NTQgMTA2LjEzNyAxNTIuNzE3IDEwNS44OTIgMTUyLjcxN0gxMDUuMzc2Wk0xMTEuOTEyIDE0OS42MzVWMTU1LjMyMkgxMTEuMTU5VjE0OS42MzVIMTExLjkxMlpNMTE0LjI5NSAxNTIuMTkzVjE1Mi44MUgxMTEuNzQ4VjE1Mi4xOTNIMTE0LjI5NVpNMTE0LjY4MiAxNDkuNjM1VjE1MC4yNTJIMTExLjc0OFYxNDkuNjM1SDExNC42ODJaTTExNy4yMjIgMTU1LjRDMTE2LjkyNyAxNTUuNCAxMTYuNjYgMTU1LjM1MSAxMTYuNDIxIDE1NS4yNTJDMTE2LjE4NCAxNTUuMTUgMTE1Ljk3OSAxNTUuMDA4IDExNS44MDggMTU0LjgyNkMxMTUuNjM4IDE1NC42NDQgMTE1LjUwOCAxNTQuNDI3IDExNS40MTcgMTU0LjE3N0MxMTUuMzI2IDE1My45MjcgMTE1LjI4IDE1My42NTQgMTE1LjI4IDE1My4zNTdWMTUzLjE5M0MxMTUuMjggMTUyLjg0OSAxMTUuMzMxIDE1Mi41NDMgMTE1LjQzMyAxNTIuMjc1QzExNS41MzQgMTUyLjAwNCAxMTUuNjcyIDE1MS43NzUgMTE1Ljg0NyAxNTEuNTg4QzExNi4wMjEgMTUxLjQgMTE2LjIxOSAxNTEuMjU4IDExNi40NCAxNTEuMTYyQzExNi42NjIgMTUxLjA2NiAxMTYuODkxIDE1MS4wMTcgMTE3LjEyOCAxNTEuMDE3QzExNy40MyAxNTEuMDE3IDExNy42OSAxNTEuMDY5IDExNy45MDkgMTUxLjE3NEMxMTguMTMgMTUxLjI3OCAxMTguMzExIDE1MS40MjQgMTE4LjQ1MiAxNTEuNjExQzExOC41OTMgMTUxLjc5NiAxMTguNjk3IDE1Mi4wMTUgMTE4Ljc2NSAxNTIuMjY3QzExOC44MzIgMTUyLjUxNyAxMTguODY2IDE1Mi43OTEgMTE4Ljg2NiAxNTMuMDg4VjE1My40MTJIMTE1LjcxVjE1Mi44MjJIMTE4LjE0NFYxNTIuNzY3QzExOC4xMzMgMTUyLjU4IDExOC4wOTQgMTUyLjM5OCAxMTguMDI2IDE1Mi4yMkMxMTcuOTYxIDE1Mi4wNDMgMTE3Ljg1NyAxNTEuODk4IDExNy43MTQgMTUxLjc4M0MxMTcuNTcxIDE1MS42NjggMTE3LjM3NSAxNTEuNjExIDExNy4xMjggMTUxLjYxMUMxMTYuOTY0IDE1MS42MTEgMTE2LjgxMyAxNTEuNjQ2IDExNi42NzUgMTUxLjcxN0MxMTYuNTM3IDE1MS43ODQgMTE2LjQxOCAxNTEuODg2IDExNi4zMTkgMTUyLjAyMUMxMTYuMjIgMTUyLjE1NyAxMTYuMTQ0IDE1Mi4zMjIgMTE2LjA4OSAxNTIuNTE3QzExNi4wMzQgMTUyLjcxMyAxMTYuMDA3IDE1Mi45MzggMTE2LjAwNyAxNTMuMTkzVjE1My4zNTdDMTE2LjAwNyAxNTMuNTU4IDExNi4wMzQgMTUzLjc0NyAxMTYuMDg5IDE1My45MjRDMTE2LjE0NiAxNTQuMDk4IDExNi4yMjggMTU0LjI1MiAxMTYuMzM1IDE1NC4zODVDMTE2LjQ0NCAxNTQuNTE3IDExNi41NzYgMTU0LjYyMiAxMTYuNzI5IDE1NC42OTdDMTE2Ljg4NiAxNTQuNzczIDExNy4wNjMgMTU0LjgxIDExNy4yNjEgMTU0LjgxQzExNy41MTYgMTU0LjgxIDExNy43MzIgMTU0Ljc1OCAxMTcuOTA5IDE1NC42NTRDMTE4LjA4NiAxNTQuNTUgMTE4LjI0MSAxNTQuNDExIDExOC4zNzQgMTU0LjIzNkwxMTguODExIDE1NC41ODRDMTE4LjcyIDE1NC43MjIgMTE4LjYwNCAxNTQuODUzIDExOC40NjQgMTU0Ljk3OEMxMTguMzIzIDE1NS4xMDMgMTE4LjE1IDE1NS4yMDUgMTE3Ljk0NCAxNTUuMjgzQzExNy43NDEgMTU1LjM2MSAxMTcuNSAxNTUuNCAxMTcuMjIyIDE1NS40Wk0xMTkuNzg5IDE0OS4zMjJIMTIwLjUxNVYxNTQuNTAyTDEyMC40NTMgMTU1LjMyMkgxMTkuNzg5VjE0OS4zMjJaTTEyMy4zNzEgMTUzLjE3NFYxNTMuMjU2QzEyMy4zNzEgMTUzLjU2MyAxMjMuMzM0IDE1My44NDggMTIzLjI2MSAxNTQuMTExQzEyMy4xODggMTU0LjM3MiAxMjMuMDgyIDE1NC41OTggMTIyLjk0MSAxNTQuNzkxQzEyMi44IDE1NC45ODMgMTIyLjYyOCAxNTUuMTMzIDEyMi40MjUgMTU1LjI0QzEyMi4yMjIgMTU1LjM0NyAxMjEuOTg5IDE1NS40IDEyMS43MjYgMTU1LjRDMTIxLjQ1OCAxNTUuNCAxMjEuMjIyIDE1NS4zNTUgMTIxLjAxOSAxNTUuMjYzQzEyMC44MTkgMTU1LjE3IDEyMC42NDkgMTU1LjAzNiAxMjAuNTExIDE1NC44NjFDMTIwLjM3MyAxNTQuNjg3IDEyMC4yNjMgMTU0LjQ3NiAxMjAuMTc5IDE1NC4yMjhDMTIwLjA5OSAxNTMuOTgxIDEyMC4wNDMgMTUzLjcwMiAxMjAuMDExIDE1My4zOTJWMTUzLjAzM0MxMjAuMDQzIDE1Mi43MiAxMjAuMDk5IDE1Mi40NDEgMTIwLjE3OSAxNTIuMTkzQzEyMC4yNjMgMTUxLjk0NiAxMjAuMzczIDE1MS43MzUgMTIwLjUxMSAxNTEuNTZDMTIwLjY0OSAxNTEuMzgzIDEyMC44MTkgMTUxLjI0OSAxMjEuMDE5IDE1MS4xNThDMTIxLjIyIDE1MS4wNjQgMTIxLjQ1MyAxNTEuMDE3IDEyMS43MTggMTUxLjAxN0MxMjEuOTg0IDE1MS4wMTcgMTIyLjIyIDE1MS4wNjkgMTIyLjQyNSAxNTEuMTc0QzEyMi42MzEgMTUxLjI3NSAxMjIuODAzIDE1MS40MjEgMTIyLjk0MSAxNTEuNjExQzEyMy4wODIgMTUxLjgwMSAxMjMuMTg4IDE1Mi4wMjkgMTIzLjI2MSAxNTIuMjk1QzEyMy4zMzQgMTUyLjU1OCAxMjMuMzcxIDE1Mi44NTEgMTIzLjM3MSAxNTMuMTc0Wk0xMjIuNjQ0IDE1My4yNTZWMTUzLjE3NEMxMjIuNjQ0IDE1Mi45NjMgMTIyLjYyNSAxNTIuNzY1IDEyMi41ODYgMTUyLjU4QzEyMi41NDYgMTUyLjM5MiAxMjIuNDg0IDE1Mi4yMjggMTIyLjM5OCAxNTIuMDg4QzEyMi4zMTIgMTUxLjk0NCAxMjIuMTk5IDE1MS44MzIgMTIyLjA1OCAxNTEuNzUyQzEyMS45MTggMTUxLjY2OCAxMjEuNzQ0IDE1MS42MjcgMTIxLjUzOSAxNTEuNjI3QzEyMS4zNTYgMTUxLjYyNyAxMjEuMTk4IDE1MS42NTggMTIxLjA2MiAxNTEuNzJDMTIwLjkyOSAxNTEuNzgzIDEyMC44MTYgMTUxLjg2OCAxMjAuNzIyIDE1MS45NzRDMTIwLjYyOCAxNTIuMDc5IDEyMC41NTIgMTUyLjE5OCAxMjAuNDkyIDE1Mi4zMzRDMTIwLjQzNCAxNTIuNDY3IDEyMC4zOTIgMTUyLjYwNSAxMjAuMzYzIDE1Mi43NDhWMTUzLjY4OUMxMjAuNDA1IDE1My44NzIgMTIwLjQ3MiAxNTQuMDQ3IDEyMC41NjYgMTU0LjIxN0MxMjAuNjYyIDE1NC4zODMgMTIwLjc5IDE1NC41MiAxMjAuOTQ5IDE1NC42MjdDMTIxLjExIDE1NC43MzMgMTIxLjMwOSAxNTQuNzg3IDEyMS41NDYgMTU0Ljc4N0MxMjEuNzQyIDE1NC43ODcgMTIxLjkwOCAxNTQuNzQ4IDEyMi4wNDYgMTU0LjY3QzEyMi4xODcgMTU0LjU4OSAxMjIuMyAxNTQuNDc4IDEyMi4zODYgMTU0LjMzOEMxMjIuNDc1IDE1NC4xOTcgMTIyLjU0IDE1NC4wMzQgMTIyLjU4MiAxNTMuODQ5QzEyMi42MjMgMTUzLjY2NCAxMjIuNjQ0IDE1My40NjcgMTIyLjY0NCAxNTMuMjU2WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8L2c+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwM180MTg0Xzk0NTI3KSI+CjxsaW5lIHgxPSIxNDYuNjUiIHkxPSIxNDcuMDcyIiB4Mj0iMTQ2LjY1IiB5Mj0iMTQ2LjI1IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8cGF0aCBkPSJNMTM4LjcwOSAxNTIuMDI1VjE1Mi44OTJDMTM4LjcwOSAxNTMuMzU4IDEzOC42NjcgMTUzLjc1MiAxMzguNTg0IDE1NC4wNzJDMTM4LjUwMSAxNTQuMzkyIDEzOC4zODEgMTU0LjY1IDEzOC4yMjUgMTU0Ljg0NUMxMzguMDY4IDE1NS4wNDEgMTM3Ljg4IDE1NS4xODMgMTM3LjY1OCAxNTUuMjcxQzEzNy40MzkgMTU1LjM1NyAxMzcuMTkyIDE1NS40IDEzNi45MTYgMTU1LjRDMTM2LjY5NyAxNTUuNCAxMzYuNDk1IDE1NS4zNzMgMTM2LjMxMSAxNTUuMzE4QzEzNi4xMjYgMTU1LjI2MyAxMzUuOTU5IDE1NS4xNzYgMTM1LjgxMSAxNTUuMDU2QzEzNS42NjUgMTU0LjkzNCAxMzUuNTQgMTU0Ljc3NSAxMzUuNDM2IDE1NC41OEMxMzUuMzMxIDE1NC4zODUgMTM1LjI1MiAxNTQuMTQ4IDEzNS4xOTcgMTUzLjg2OUMxMzUuMTQzIDE1My41OSAxMzUuMTE1IDE1My4yNjUgMTM1LjExNSAxNTIuODkyVjE1Mi4wMjVDMTM1LjExNSAxNTEuNTU5IDEzNS4xNTcgMTUxLjE2OCAxMzUuMjQgMTUwLjg1M0MxMzUuMzI2IDE1MC41MzggMTM1LjQ0NyAxNTAuMjg2IDEzNS42MDQgMTUwLjA5NUMxMzUuNzYgMTQ5LjkwMyAxMzUuOTQ3IDE0OS43NjUgMTM2LjE2NiAxNDkuNjgxQzEzNi4zODcgMTQ5LjU5OCAxMzYuNjM1IDE0OS41NTYgMTM2LjkwOCAxNDkuNTU2QzEzNy4xMyAxNDkuNTU2IDEzNy4zMzMgMTQ5LjU4NCAxMzcuNTE4IDE0OS42MzhDMTM3LjcwNSAxNDkuNjkxIDEzNy44NzIgMTQ5Ljc3NSAxMzguMDE4IDE0OS44OTJDMTM4LjE2MyAxNTAuMDA3IDEzOC4yODcgMTUwLjE2MSAxMzguMzg5IDE1MC4zNTNDMTM4LjQ5MyAxNTAuNTQzIDEzOC41NzIgMTUwLjc3NiAxMzguNjI3IDE1MS4wNTJDMTM4LjY4MiAxNTEuMzI5IDEzOC43MDkgMTUxLjY1MyAxMzguNzA5IDE1Mi4wMjVaTTEzNy45ODIgMTUzLjAxVjE1MS45MDRDMTM3Ljk4MiAxNTEuNjQ5IDEzNy45NjcgMTUxLjQyNSAxMzcuOTM2IDE1MS4yMzJDMTM3LjkwNyAxNTEuMDM3IDEzNy44NjQgMTUwLjg3IDEzNy44MDcgMTUwLjczMkMxMzcuNzQ5IDE1MC41OTQgMTM3LjY3NiAxNTAuNDgyIDEzNy41ODggMTUwLjM5NkMxMzcuNTAyIDE1MC4zMSAxMzcuNDAyIDE1MC4yNDggMTM3LjI4NyAxNTAuMjA5QzEzNy4xNzUgMTUwLjE2NyAxMzcuMDQ5IDE1MC4xNDYgMTM2LjkwOCAxNTAuMTQ2QzEzNi43MzYgMTUwLjE0NiAxMzYuNTg0IDE1MC4xNzkgMTM2LjQ1MSAxNTAuMjQ0QzEzNi4zMTggMTUwLjMwNiAxMzYuMjA2IDE1MC40MDcgMTM2LjExNSAxNTAuNTQ1QzEzNi4wMjcgMTUwLjY4MyAxMzUuOTU5IDE1MC44NjQgMTM1LjkxMiAxNTEuMDg4QzEzNS44NjUgMTUxLjMxMiAxMzUuODQyIDE1MS41ODQgMTM1Ljg0MiAxNTEuOTA0VjE1My4wMUMxMzUuODQyIDE1My4yNjUgMTM1Ljg1NiAxNTMuNDkgMTM1Ljg4NSAxNTMuNjg1QzEzNS45MTYgMTUzLjg4MSAxMzUuOTYyIDE1NC4wNSAxMzYuMDIyIDE1NC4xOTNDMTM2LjA4MSAxNTQuMzM0IDEzNi4xNTQgMTU0LjQ1IDEzNi4yNCAxNTQuNTQxQzEzNi4zMjYgMTU0LjYzMiAxMzYuNDI1IDE1NC43IDEzNi41MzcgMTU0Ljc0NEMxMzYuNjUyIDE1NC43ODYgMTM2Ljc3OCAxNTQuODA2IDEzNi45MTYgMTU0LjgwNkMxMzcuMDkzIDE1NC44MDYgMTM3LjI0OCAxNTQuNzczIDEzNy4zODEgMTU0LjcwNUMxMzcuNTE0IDE1NC42MzcgMTM3LjYyNCAxNTQuNTMyIDEzNy43MTMgMTU0LjM4OEMxMzcuODA0IDE1NC4yNDMgMTM3Ljg3MiAxNTQuMDU2IDEzNy45MTYgMTUzLjgzQzEzNy45NiAxNTMuNjAxIDEzNy45ODIgMTUzLjMyNyAxMzcuOTgyIDE1My4wMVpNMTQzLjU2NSAxNTMuNDA4VjE1NC4wMDJIMTM5LjQ1NlYxNTMuNTc2TDE0Mi4wMDMgMTQ5LjYzNUgxNDIuNTkyTDE0MS45NiAxNTAuNzc1TDE0MC4yNzYgMTUzLjQwOEgxNDMuNTY1Wk0xNDIuNzcyIDE0OS42MzVWMTU1LjMyMkgxNDIuMDQ5VjE0OS42MzVIMTQyLjc3MlpNMTQ3LjMxMiAxNDkuNjM1VjE1NS4zMjJIMTQ2LjU1OFYxNDkuNjM1SDE0Ny4zMTJaTTE0OS42OTUgMTUyLjE5M1YxNTIuODFIMTQ3LjE0OFYxNTIuMTkzSDE0OS42OTVaTTE1MC4wODIgMTQ5LjYzNVYxNTAuMjUySDE0Ny4xNDhWMTQ5LjYzNUgxNTAuMDgyWk0xNTIuNjIyIDE1NS40QzE1Mi4zMjcgMTU1LjQgMTUyLjA2IDE1NS4zNTEgMTUxLjgyMSAxNTUuMjUyQzE1MS41ODQgMTU1LjE1IDE1MS4zNzkgMTU1LjAwOCAxNTEuMjA3IDE1NC44MjZDMTUxLjAzOCAxNTQuNjQ0IDE1MC45MDggMTU0LjQyNyAxNTAuODE3IDE1NC4xNzdDMTUwLjcyNiAxNTMuOTI3IDE1MC42OCAxNTMuNjU0IDE1MC42OCAxNTMuMzU3VjE1My4xOTNDMTUwLjY4IDE1Mi44NDkgMTUwLjczMSAxNTIuNTQzIDE1MC44MzIgMTUyLjI3NUMxNTAuOTM0IDE1Mi4wMDQgMTUxLjA3MiAxNTEuNzc1IDE1MS4yNDcgMTUxLjU4OEMxNTEuNDIxIDE1MS40IDE1MS42MTkgMTUxLjI1OCAxNTEuODQgMTUxLjE2MkMxNTIuMDYyIDE1MS4wNjYgMTUyLjI5MSAxNTEuMDE3IDE1Mi41MjggMTUxLjAxN0MxNTIuODMgMTUxLjAxNyAxNTMuMDkgMTUxLjA2OSAxNTMuMzA5IDE1MS4xNzRDMTUzLjUzIDE1MS4yNzggMTUzLjcxMSAxNTEuNDI0IDE1My44NTIgMTUxLjYxMUMxNTMuOTkzIDE1MS43OTYgMTU0LjA5NyAxNTIuMDE1IDE1NC4xNjUgMTUyLjI2N0MxNTQuMjMyIDE1Mi41MTcgMTU0LjI2NiAxNTIuNzkxIDE1NC4yNjYgMTUzLjA4OFYxNTMuNDEySDE1MS4xMVYxNTIuODIySDE1My41NDNWMTUyLjc2N0MxNTMuNTMzIDE1Mi41OCAxNTMuNDk0IDE1Mi4zOTggMTUzLjQyNiAxNTIuMjJDMTUzLjM2MSAxNTIuMDQzIDE1My4yNTcgMTUxLjg5OCAxNTMuMTE0IDE1MS43ODNDMTUyLjk3MSAxNTEuNjY4IDE1Mi43NzUgMTUxLjYxMSAxNTIuNTI4IDE1MS42MTFDMTUyLjM2NCAxNTEuNjExIDE1Mi4yMTMgMTUxLjY0NiAxNTIuMDc1IDE1MS43MTdDMTUxLjkzNyAxNTEuNzg0IDE1MS44MTggMTUxLjg4NiAxNTEuNzE5IDE1Mi4wMjFDMTUxLjYyIDE1Mi4xNTcgMTUxLjU0MyAxNTIuMzIyIDE1MS40ODkgMTUyLjUxN0MxNTEuNDM0IDE1Mi43MTMgMTUxLjQwNyAxNTIuOTM4IDE1MS40MDcgMTUzLjE5M1YxNTMuMzU3QzE1MS40MDcgMTUzLjU1OCAxNTEuNDM0IDE1My43NDcgMTUxLjQ4OSAxNTMuOTI0QzE1MS41NDYgMTU0LjA5OCAxNTEuNjI4IDE1NC4yNTIgMTUxLjczNSAxNTQuMzg1QzE1MS44NDQgMTU0LjUxNyAxNTEuOTc2IDE1NC42MjIgMTUyLjEyOSAxNTQuNjk3QzE1Mi4yODYgMTU0Ljc3MyAxNTIuNDYzIDE1NC44MSAxNTIuNjYxIDE1NC44MUMxNTIuOTE2IDE1NC44MSAxNTMuMTMyIDE1NC43NTggMTUzLjMwOSAxNTQuNjU0QzE1My40ODYgMTU0LjU1IDE1My42NDEgMTU0LjQxMSAxNTMuNzc0IDE1NC4yMzZMMTU0LjIxMSAxNTQuNTg0QzE1NC4xMiAxNTQuNzIyIDE1NC4wMDQgMTU0Ljg1MyAxNTMuODY0IDE1NC45NzhDMTUzLjcyMyAxNTUuMTAzIDE1My41NSAxNTUuMjA1IDE1My4zNDQgMTU1LjI4M0MxNTMuMTQxIDE1NS4zNjEgMTUyLjkgMTU1LjQgMTUyLjYyMiAxNTUuNFpNMTU1LjE4OSAxNDkuMzIySDE1NS45MTVWMTU0LjUwMkwxNTUuODUzIDE1NS4zMjJIMTU1LjE4OVYxNDkuMzIyWk0xNTguNzcxIDE1My4xNzRWMTUzLjI1NkMxNTguNzcxIDE1My41NjMgMTU4LjczNCAxNTMuODQ4IDE1OC42NjEgMTU0LjExMUMxNTguNTg4IDE1NC4zNzIgMTU4LjQ4MiAxNTQuNTk4IDE1OC4zNDEgMTU0Ljc5MUMxNTguMiAxNTQuOTgzIDE1OC4wMjggMTU1LjEzMyAxNTcuODI1IDE1NS4yNEMxNTcuNjIyIDE1NS4zNDcgMTU3LjM4OSAxNTUuNCAxNTcuMTI2IDE1NS40QzE1Ni44NTggMTU1LjQgMTU2LjYyMiAxNTUuMzU1IDE1Ni40MTkgMTU1LjI2M0MxNTYuMjE4IDE1NS4xNyAxNTYuMDQ5IDE1NS4wMzYgMTU1LjkxMSAxNTQuODYxQzE1NS43NzMgMTU0LjY4NyAxNTUuNjYzIDE1NC40NzYgMTU1LjU3OSAxNTQuMjI4QzE1NS40OTggMTUzLjk4MSAxNTUuNDQyIDE1My43MDIgMTU1LjQxMSAxNTMuMzkyVjE1My4wMzNDMTU1LjQ0MiAxNTIuNzIgMTU1LjQ5OCAxNTIuNDQxIDE1NS41NzkgMTUyLjE5M0MxNTUuNjYzIDE1MS45NDYgMTU1Ljc3MyAxNTEuNzM1IDE1NS45MTEgMTUxLjU2QzE1Ni4wNDkgMTUxLjM4MyAxNTYuMjE4IDE1MS4yNDkgMTU2LjQxOSAxNTEuMTU4QzE1Ni42MiAxNTEuMDY0IDE1Ni44NTMgMTUxLjAxNyAxNTcuMTE4IDE1MS4wMTdDMTU3LjM4NCAxNTEuMDE3IDE1Ny42MiAxNTEuMDY5IDE1Ny44MjUgMTUxLjE3NEMxNTguMDMxIDE1MS4yNzUgMTU4LjIwMyAxNTEuNDIxIDE1OC4zNDEgMTUxLjYxMUMxNTguNDgyIDE1MS44MDEgMTU4LjU4OCAxNTIuMDI5IDE1OC42NjEgMTUyLjI5NUMxNTguNzM0IDE1Mi41NTggMTU4Ljc3MSAxNTIuODUxIDE1OC43NzEgMTUzLjE3NFpNMTU4LjA0NCAxNTMuMjU2VjE1My4xNzRDMTU4LjA0NCAxNTIuOTYzIDE1OC4wMjQgMTUyLjc2NSAxNTcuOTg1IDE1Mi41OEMxNTcuOTQ2IDE1Mi4zOTIgMTU3Ljg4NCAxNTIuMjI4IDE1Ny43OTggMTUyLjA4OEMxNTcuNzEyIDE1MS45NDQgMTU3LjU5OSAxNTEuODMyIDE1Ny40NTggMTUxLjc1MkMxNTcuMzE3IDE1MS42NjggMTU3LjE0NCAxNTEuNjI3IDE1Ni45MzkgMTUxLjYyN0MxNTYuNzU2IDE1MS42MjcgMTU2LjU5NyAxNTEuNjU4IDE1Ni40NjIgMTUxLjcyQzE1Ni4zMjkgMTUxLjc4MyAxNTYuMjE2IDE1MS44NjggMTU2LjEyMiAxNTEuOTc0QzE1Ni4wMjggMTUyLjA3OSAxNTUuOTUyIDE1Mi4xOTggMTU1Ljg5MiAxNTIuMzM0QzE1NS44MzQgMTUyLjQ2NyAxNTUuNzkxIDE1Mi42MDUgMTU1Ljc2MyAxNTIuNzQ4VjE1My42ODlDMTU1LjgwNCAxNTMuODcyIDE1NS44NzIgMTU0LjA0NyAxNTUuOTY2IDE1NC4yMTdDMTU2LjA2MiAxNTQuMzgzIDE1Ni4xOSAxNTQuNTIgMTU2LjM0OSAxNTQuNjI3QzE1Ni41MSAxNTQuNzMzIDE1Ni43MDkgMTU0Ljc4NyAxNTYuOTQ2IDE1NC43ODdDMTU3LjE0MiAxNTQuNzg3IDE1Ny4zMDggMTU0Ljc0OCAxNTcuNDQ2IDE1NC42N0MxNTcuNTg3IDE1NC41ODkgMTU3LjcgMTU0LjQ3OCAxNTcuNzg2IDE1NC4zMzhDMTU3Ljg3NSAxNTQuMTk3IDE1Ny45NCAxNTQuMDM0IDE1Ny45ODIgMTUzLjg0OUMxNTguMDIzIDE1My42NjQgMTU4LjA0NCAxNTMuNDY3IDE1OC4wNDQgMTUzLjI1NloiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPC9nPgo8ZyBjbGlwLXBhdGg9InVybCgjY2xpcDRfNDE4NF85NDUyNykiPgo8bGluZSB4MT0iMTgyLjA1IiB5MT0iMTQ3LjA3MiIgeDI9IjE4Mi4wNSIgeTI9IjE0Ni4yNSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuNSIgc3Ryb2tlLXdpZHRoPSIwLjUiIHN0cm9rZS1saW5lY2FwPSJzcXVhcmUiLz4KPHBhdGggZD0iTTE3NC4xMDkgMTUyLjAyNVYxNTIuODkyQzE3NC4xMDkgMTUzLjM1OCAxNzQuMDY3IDE1My43NTIgMTczLjk4NCAxNTQuMDcyQzE3My45MDEgMTU0LjM5MiAxNzMuNzgxIDE1NC42NSAxNzMuNjI1IDE1NC44NDVDMTczLjQ2OSAxNTUuMDQxIDE3My4yOCAxNTUuMTgzIDE3My4wNTggMTU1LjI3MUMxNzIuODQgMTU1LjM1NyAxNzIuNTkyIDE1NS40IDE3Mi4zMTYgMTU1LjRDMTcyLjA5NyAxNTUuNCAxNzEuODk2IDE1NS4zNzMgMTcxLjcxMSAxNTUuMzE4QzE3MS41MjYgMTU1LjI2MyAxNzEuMzU5IDE1NS4xNzYgMTcxLjIxMSAxNTUuMDU2QzE3MS4wNjUgMTU0LjkzNCAxNzAuOTQgMTU0Ljc3NSAxNzAuODM2IDE1NC41OEMxNzAuNzMyIDE1NC4zODUgMTcwLjY1MiAxNTQuMTQ4IDE3MC41OTcgMTUzLjg2OUMxNzAuNTQzIDE1My41OSAxNzAuNTE1IDE1My4yNjUgMTcwLjUxNSAxNTIuODkyVjE1Mi4wMjVDMTcwLjUxNSAxNTEuNTU5IDE3MC41NTcgMTUxLjE2OCAxNzAuNjQgMTUwLjg1M0MxNzAuNzI2IDE1MC41MzggMTcwLjg0NyAxNTAuMjg2IDE3MS4wMDQgMTUwLjA5NUMxNzEuMTYgMTQ5LjkwMyAxNzEuMzQ3IDE0OS43NjUgMTcxLjU2NiAxNDkuNjgxQzE3MS43ODggMTQ5LjU5OCAxNzIuMDM1IDE0OS41NTYgMTcyLjMwOCAxNDkuNTU2QzE3Mi41MyAxNDkuNTU2IDE3Mi43MzMgMTQ5LjU4NCAxNzIuOTE4IDE0OS42MzhDMTczLjEwNSAxNDkuNjkxIDE3My4yNzIgMTQ5Ljc3NSAxNzMuNDE4IDE0OS44OTJDMTczLjU2NCAxNTAuMDA3IDE3My42ODcgMTUwLjE2MSAxNzMuNzg5IDE1MC4zNTNDMTczLjg5MyAxNTAuNTQzIDE3My45NzIgMTUwLjc3NiAxNzQuMDI3IDE1MS4wNTJDMTc0LjA4MiAxNTEuMzI5IDE3NC4xMDkgMTUxLjY1MyAxNzQuMTA5IDE1Mi4wMjVaTTE3My4zODMgMTUzLjAxVjE1MS45MDRDMTczLjM4MyAxNTEuNjQ5IDE3My4zNjcgMTUxLjQyNSAxNzMuMzM2IDE1MS4yMzJDMTczLjMwNyAxNTEuMDM3IDE3My4yNjQgMTUwLjg3IDE3My4yMDcgMTUwLjczMkMxNzMuMTUgMTUwLjU5NCAxNzMuMDc3IDE1MC40ODIgMTcyLjk4OCAxNTAuMzk2QzE3Mi45MDIgMTUwLjMxIDE3Mi44MDIgMTUwLjI0OCAxNzIuNjg3IDE1MC4yMDlDMTcyLjU3NSAxNTAuMTY3IDE3Mi40NDkgMTUwLjE0NiAxNzIuMzA4IDE1MC4xNDZDMTcyLjEzNiAxNTAuMTQ2IDE3MS45ODQgMTUwLjE3OSAxNzEuODUxIDE1MC4yNDRDMTcxLjcxOSAxNTAuMzA2IDE3MS42MDcgMTUwLjQwNyAxNzEuNTE1IDE1MC41NDVDMTcxLjQyNyAxNTAuNjgzIDE3MS4zNTkgMTUwLjg2NCAxNzEuMzEyIDE1MS4wODhDMTcxLjI2NSAxNTEuMzEyIDE3MS4yNDIgMTUxLjU4NCAxNzEuMjQyIDE1MS45MDRWMTUzLjAxQzE3MS4yNDIgMTUzLjI2NSAxNzEuMjU2IDE1My40OSAxNzEuMjg1IDE1My42ODVDMTcxLjMxNiAxNTMuODgxIDE3MS4zNjIgMTU0LjA1IDE3MS40MjIgMTU0LjE5M0MxNzEuNDgyIDE1NC4zMzQgMTcxLjU1NCAxNTQuNDUgMTcxLjY0IDE1NC41NDFDMTcxLjcyNiAxNTQuNjMyIDE3MS44MjUgMTU0LjcgMTcxLjkzNyAxNTQuNzQ0QzE3Mi4wNTIgMTU0Ljc4NiAxNzIuMTc4IDE1NC44MDYgMTcyLjMxNiAxNTQuODA2QzE3Mi40OTMgMTU0LjgwNiAxNzIuNjQ4IDE1NC43NzMgMTcyLjc4MSAxNTQuNzA1QzE3Mi45MTQgMTU0LjYzNyAxNzMuMDI1IDE1NC41MzIgMTczLjExMyAxNTQuMzg4QzE3My4yMDQgMTU0LjI0MyAxNzMuMjcyIDE1NC4wNTYgMTczLjMxNiAxNTMuODNDMTczLjM2IDE1My42MDEgMTczLjM4MyAxNTMuMzI3IDE3My4zODMgMTUzLjAxWk0xNzYuMDM2IDE1Mi42MTVMMTc1LjQ1NyAxNTIuNDY3TDE3NS43NDMgMTQ5LjYzNUgxNzguNjYxVjE1MC4zMDJIMTc2LjM1NkwxNzYuMTg0IDE1MS44NDlDMTc2LjI4OCAxNTEuNzg5IDE3Ni40MiAxNTEuNzMzIDE3Ni41NzkgMTUxLjY4MUMxNzYuNzQgMTUxLjYyOSAxNzYuOTI1IDE1MS42MDMgMTc3LjEzMyAxNTEuNjAzQzE3Ny4zOTYgMTUxLjYwMyAxNzcuNjMyIDE1MS42NDkgMTc3Ljg0IDE1MS43NEMxNzguMDQ5IDE1MS44MjkgMTc4LjIyNiAxNTEuOTU2IDE3OC4zNzEgMTUyLjEyM0MxNzguNTIgMTUyLjI4OSAxNzguNjMzIDE1Mi40OSAxNzguNzExIDE1Mi43MjRDMTc4Ljc4OSAxNTIuOTU5IDE3OC44MjkgMTUzLjIyIDE3OC44MjkgMTUzLjUxQzE3OC44MjkgMTUzLjc4MyAxNzguNzkxIDE1NC4wMzQgMTc4LjcxNSAxNTQuMjYzQzE3OC42NDIgMTU0LjQ5MyAxNzguNTMyIDE1NC42OTMgMTc4LjM4MyAxNTQuODY1QzE3OC4yMzUgMTU1LjAzNCAxNzguMDQ3IDE1NS4xNjYgMTc3LjgyMSAxNTUuMjZDMTc3LjU5NyAxNTUuMzUzIDE3Ny4zMzIgMTU1LjQgMTc3LjAyOCAxNTUuNEMxNzYuNzk5IDE1NS40IDE3Ni41ODEgMTU1LjM2OSAxNzYuMzc1IDE1NS4zMDZDMTc2LjE3MiAxNTUuMjQxIDE3NS45OSAxNTUuMTQ0IDE3NS44MjkgMTU1LjAxM0MxNzUuNjcgMTU0Ljg4MSAxNzUuNTM5IDE1NC43MTcgMTc1LjQzOCAxNTQuNTIxQzE3NS4zMzkgMTU0LjMyMyAxNzUuMjc2IDE1NC4wOTIgMTc1LjI1IDE1My44MjZIMTc1LjkzOEMxNzUuOTY5IDE1NC4wMzkgMTc2LjAzMiAxNTQuMjE5IDE3Ni4xMjUgMTU0LjM2NUMxNzYuMjE5IDE1NC41MTEgMTc2LjM0MiAxNTQuNjIyIDE3Ni40OTMgMTU0LjY5N0MxNzYuNjQ2IDE1NC43NyAxNzYuODI1IDE1NC44MDYgMTc3LjAyOCAxNTQuODA2QzE3Ny4yIDE1NC44MDYgMTc3LjM1MiAxNTQuNzc2IDE3Ny40ODUgMTU0LjcxN0MxNzcuNjE4IDE1NC42NTcgMTc3LjczIDE1NC41NzEgMTc3LjgyMSAxNTQuNDU5QzE3Ny45MTIgMTU0LjM0NyAxNzcuOTgxIDE1NC4yMTEgMTc4LjAyOCAxNTQuMDUyQzE3OC4wNzcgMTUzLjg5NCAxNzguMTAyIDE1My43MTUgMTc4LjEwMiAxNTMuNTE3QzE3OC4xMDIgMTUzLjMzOCAxNzguMDc3IDE1My4xNzEgMTc4LjAyOCAxNTMuMDE3QzE3Ny45NzggMTUyLjg2NCAxNzcuOTA0IDE1Mi43MyAxNzcuODA1IDE1Mi42MTVDMTc3LjcwOSAxNTIuNSAxNzcuNTkgMTUyLjQxMiAxNzcuNDUgMTUyLjM0OUMxNzcuMzA5IDE1Mi4yODQgMTc3LjE0OCAxNTIuMjUyIDE3Ni45NjUgMTUyLjI1MkMxNzYuNzIzIDE1Mi4yNTIgMTc2LjUzOSAxNTIuMjg0IDE3Ni40MTQgMTUyLjM0OUMxNzYuMjkyIDE1Mi40MTQgMTc2LjE2NiAxNTIuNTAzIDE3Ni4wMzYgMTUyLjYxNVpNMTgyLjcxMyAxNDkuNjM1VjE1NS4zMjJIMTgxLjk1OVYxNDkuNjM1SDE4Mi43MTNaTTE4NS4wOTUgMTUyLjE5M1YxNTIuODFIMTgyLjU0OFYxNTIuMTkzSDE4NS4wOTVaTTE4NS40ODIgMTQ5LjYzNVYxNTAuMjUySDE4Mi41NDhWMTQ5LjYzNUgxODUuNDgyWk0xODguMDIyIDE1NS40QzE4Ny43MjcgMTU1LjQgMTg3LjQ2IDE1NS4zNTEgMTg3LjIyMSAxNTUuMjUyQzE4Ni45ODQgMTU1LjE1IDE4Ni43OCAxNTUuMDA4IDE4Ni42MDggMTU0LjgyNkMxODYuNDM4IDE1NC42NDQgMTg2LjMwOCAxNTQuNDI3IDE4Ni4yMTcgMTU0LjE3N0MxODYuMTI2IDE1My45MjcgMTg2LjA4IDE1My42NTQgMTg2LjA4IDE1My4zNTdWMTUzLjE5M0MxODYuMDggMTUyLjg0OSAxODYuMTMxIDE1Mi41NDMgMTg2LjIzMyAxNTIuMjc1QzE4Ni4zMzQgMTUyLjAwNCAxODYuNDcyIDE1MS43NzUgMTg2LjY0NyAxNTEuNTg4QzE4Ni44MjEgMTUxLjQgMTg3LjAxOSAxNTEuMjU4IDE4Ny4yNCAxNTEuMTYyQzE4Ny40NjIgMTUxLjA2NiAxODcuNjkxIDE1MS4wMTcgMTg3LjkyOCAxNTEuMDE3QzE4OC4yMyAxNTEuMDE3IDE4OC40OSAxNTEuMDY5IDE4OC43MDkgMTUxLjE3NEMxODguOTMxIDE1MS4yNzggMTg5LjExMiAxNTEuNDI0IDE4OS4yNTIgMTUxLjYxMUMxODkuMzkzIDE1MS43OTYgMTg5LjQ5NyAxNTIuMDE1IDE4OS41NjUgMTUyLjI2N0MxODkuNjMyIDE1Mi41MTcgMTg5LjY2NiAxNTIuNzkxIDE4OS42NjYgMTUzLjA4OFYxNTMuNDEySDE4Ni41MVYxNTIuODIySDE4OC45NDRWMTUyLjc2N0MxODguOTMzIDE1Mi41OCAxODguODk0IDE1Mi4zOTggMTg4LjgyNiAxNTIuMjJDMTg4Ljc2MSAxNTIuMDQzIDE4OC42NTcgMTUxLjg5OCAxODguNTE0IDE1MS43ODNDMTg4LjM3MSAxNTEuNjY4IDE4OC4xNzUgMTUxLjYxMSAxODcuOTI4IDE1MS42MTFDMTg3Ljc2NCAxNTEuNjExIDE4Ny42MTMgMTUxLjY0NiAxODcuNDc1IDE1MS43MTdDMTg3LjMzNyAxNTEuNzg0IDE4Ny4yMTggMTUxLjg4NiAxODcuMTE5IDE1Mi4wMjFDMTg3LjAyIDE1Mi4xNTcgMTg2Ljk0NCAxNTIuMzIyIDE4Ni44ODkgMTUyLjUxN0MxODYuODM0IDE1Mi43MTMgMTg2LjgwNyAxNTIuOTM4IDE4Ni44MDcgMTUzLjE5M1YxNTMuMzU3QzE4Ni44MDcgMTUzLjU1OCAxODYuODM0IDE1My43NDcgMTg2Ljg4OSAxNTMuOTI0QzE4Ni45NDYgMTU0LjA5OCAxODcuMDI4IDE1NC4yNTIgMTg3LjEzNSAxNTQuMzg1QzE4Ny4yNDQgMTU0LjUxNyAxODcuMzc2IDE1NC42MjIgMTg3LjUzIDE1NC42OTdDMTg3LjY4NiAxNTQuNzczIDE4Ny44NjMgMTU0LjgxIDE4OC4wNjEgMTU0LjgxQzE4OC4zMTYgMTU0LjgxIDE4OC41MzIgMTU0Ljc1OCAxODguNzA5IDE1NC42NTRDMTg4Ljg4NiAxNTQuNTUgMTg5LjA0MSAxNTQuNDExIDE4OS4xNzQgMTU0LjIzNkwxODkuNjEyIDE1NC41ODRDMTg5LjUyIDE1NC43MjIgMTg5LjQwNSAxNTQuODUzIDE4OS4yNjQgMTU0Ljk3OEMxODkuMTIzIDE1NS4xMDMgMTg4Ljk1IDE1NS4yMDUgMTg4Ljc0NCAxNTUuMjgzQzE4OC41NDEgMTU1LjM2MSAxODguMyAxNTUuNCAxODguMDIyIDE1NS40Wk0xOTAuNTg5IDE0OS4zMjJIMTkxLjMxNVYxNTQuNTAyTDE5MS4yNTMgMTU1LjMyMkgxOTAuNTg5VjE0OS4zMjJaTTE5NC4xNzEgMTUzLjE3NFYxNTMuMjU2QzE5NC4xNzEgMTUzLjU2MyAxOTQuMTM0IDE1My44NDggMTk0LjA2MSAxNTQuMTExQzE5My45ODggMTU0LjM3MiAxOTMuODgyIDE1NC41OTggMTkzLjc0MSAxNTQuNzkxQzE5My42IDE1NC45ODMgMTkzLjQyOSAxNTUuMTMzIDE5My4yMjUgMTU1LjI0QzE5My4wMjIgMTU1LjM0NyAxOTIuNzg5IDE1NS40IDE5Mi41MjYgMTU1LjRDMTkyLjI1OCAxNTUuNCAxOTIuMDIyIDE1NS4zNTUgMTkxLjgxOSAxNTUuMjYzQzE5MS42MTkgMTU1LjE3IDE5MS40NDkgMTU1LjAzNiAxOTEuMzExIDE1NC44NjFDMTkxLjE3MyAxNTQuNjg3IDE5MS4wNjMgMTU0LjQ3NiAxOTAuOTc5IDE1NC4yMjhDMTkwLjg5OSAxNTMuOTgxIDE5MC44NDMgMTUzLjcwMiAxOTAuODExIDE1My4zOTJWMTUzLjAzM0MxOTAuODQzIDE1Mi43MiAxOTAuODk5IDE1Mi40NDEgMTkwLjk3OSAxNTIuMTkzQzE5MS4wNjMgMTUxLjk0NiAxOTEuMTczIDE1MS43MzUgMTkxLjMxMSAxNTEuNTZDMTkxLjQ0OSAxNTEuMzgzIDE5MS42MTkgMTUxLjI0OSAxOTEuODE5IDE1MS4xNThDMTkyLjAyIDE1MS4wNjQgMTkyLjI1MyAxNTEuMDE3IDE5Mi41MTggMTUxLjAxN0MxOTIuNzg0IDE1MS4wMTcgMTkzLjAyIDE1MS4wNjkgMTkzLjIyNSAxNTEuMTc0QzE5My40MzEgMTUxLjI3NSAxOTMuNjAzIDE1MS40MjEgMTkzLjc0MSAxNTEuNjExQzE5My44ODIgMTUxLjgwMSAxOTMuOTg4IDE1Mi4wMjkgMTk0LjA2MSAxNTIuMjk1QzE5NC4xMzQgMTUyLjU1OCAxOTQuMTcxIDE1Mi44NTEgMTk0LjE3MSAxNTMuMTc0Wk0xOTMuNDQ0IDE1My4yNTZWMTUzLjE3NEMxOTMuNDQ0IDE1Mi45NjMgMTkzLjQyNSAxNTIuNzY1IDE5My4zODYgMTUyLjU4QzE5My4zNDcgMTUyLjM5MiAxOTMuMjg0IDE1Mi4yMjggMTkzLjE5OCAxNTIuMDg4QzE5My4xMTIgMTUxLjk0NCAxOTIuOTk5IDE1MS44MzIgMTkyLjg1OCAxNTEuNzUyQzE5Mi43MTggMTUxLjY2OCAxOTIuNTQ0IDE1MS42MjcgMTkyLjMzOSAxNTEuNjI3QzE5Mi4xNTYgMTUxLjYyNyAxOTEuOTk4IDE1MS42NTggMTkxLjg2MiAxNTEuNzJDMTkxLjcyOSAxNTEuNzgzIDE5MS42MTYgMTUxLjg2OCAxOTEuNTIyIDE1MS45NzRDMTkxLjQyOSAxNTIuMDc5IDE5MS4zNTIgMTUyLjE5OCAxOTEuMjkyIDE1Mi4zMzRDMTkxLjIzNSAxNTIuNDY3IDE5MS4xOTIgMTUyLjYwNSAxOTEuMTYzIDE1Mi43NDhWMTUzLjY4OUMxOTEuMjA1IDE1My44NzIgMTkxLjI3MiAxNTQuMDQ3IDE5MS4zNjYgMTU0LjIxN0MxOTEuNDYyIDE1NC4zODMgMTkxLjU5IDE1NC41MiAxOTEuNzQ5IDE1NC42MjdDMTkxLjkxIDE1NC43MzMgMTkyLjExIDE1NC43ODcgMTkyLjM0NyAxNTQuNzg3QzE5Mi41NDIgMTU0Ljc4NyAxOTIuNzA4IDE1NC43NDggMTkyLjg0NyAxNTQuNjdDMTkyLjk4NyAxNTQuNTg5IDE5My4xIDE1NC40NzggMTkzLjE4NiAxNTQuMzM4QzE5My4yNzUgMTU0LjE5NyAxOTMuMzQgMTU0LjAzNCAxOTMuMzgyIDE1My44NDlDMTkzLjQyMyAxNTMuNjY0IDE5My40NDQgMTUzLjQ2NyAxOTMuNDQ0IDE1My4yNTZaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjU0Ii8+CjwvZz4KPHBhdGggZD0iTTI3IDEwNy40NDVMNDAgODguMDk2Mkw3NS45NDA3IDQ0Ljk4OTVDNzYuMjYxMSA0NC42MDUyIDc2LjgxNjEgNDQuNTE2OCA3Ny4yNCA0NC43ODI2TDExMC41NTYgNjUuNjcxNkMxMTAuODM0IDY1Ljg0NiAxMTEuMTggNjUuODcyOCAxMTEuNDgyIDY1Ljc0MzNMMTQ3IDUwLjQ5OUwxODQuMDMyIDM5LjgxODNDMTg0LjMyNyAzOS43MzMxIDE4NC41NjcgMzkuNTE2OCAxODQuNjgyIDM5LjIzMThMMTk4LjUgNSIgc3Ryb2tlPSIjRkZDMTA3IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8cGF0aCBkPSJNMjggMTI5LjE2MUw0MCAxMTIuMTRMNzYgNzkuMTYxNEwxMTEgODkuMjY3OEwxNDcgNzkuMTYxNEwxNzggMTIwLjY1MUwxOTkgODkuMjY3OCIgc3Ryb2tlPSIjNENBRjUwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8cGF0aCBkPSJNMjcgMTE4TDQwIDcxLjc3OTJMNzYgNjQuNTYzNUwxMTAuNSAxNy42NjExTDE0Ni41IDQyLjkxNjJMMTgyIDcxLjc3OTJMMTk4LjUgMTA4IiBzdHJva2U9IiMyMTk2RjMiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+CjwvZz4KPGRlZnM+CjxjbGlwUGF0aCBpZD0iY2xpcDBfNDE4NF85NDUyNyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTYwIiBmaWxsPSJ3aGl0ZSIvPgo8L2NsaXBQYXRoPgo8Y2xpcFBhdGggaWQ9ImNsaXAxXzQxODRfOTQ1MjciPgo8cmVjdCB3aWR0aD0iMzUuNCIgaGVpZ2h0PSIxMC4zMjIiIGZpbGw9IndoaXRlIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1OC4zOTk5IDE0NikiLz4KPC9jbGlwUGF0aD4KPGNsaXBQYXRoIGlkPSJjbGlwMl80MTg0Xzk0NTI3Ij4KPHJlY3Qgd2lkdGg9IjM1LjQiIGhlaWdodD0iMTAuMzIyIiBmaWxsPSJ3aGl0ZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoOTMuOCAxNDYpIi8+CjwvY2xpcFBhdGg+CjxjbGlwUGF0aCBpZD0iY2xpcDNfNDE4NF85NDUyNyI+CjxyZWN0IHdpZHRoPSIzNS40IiBoZWlnaHQ9IjEwLjMyMiIgZmlsbD0id2hpdGUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDEyOS4yIDE0NikiLz4KPC9jbGlwUGF0aD4KPGNsaXBQYXRoIGlkPSJjbGlwNF80MTg0Xzk0NTI3Ij4KPHJlY3Qgd2lkdGg9IjM1LjQiIGhlaWdodD0iMTAuMzIyIiBmaWxsPSJ3aGl0ZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTY0LjYgMTQ2KSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo=", - "description": "Displays changes to time-series data over time—for example, temperature or humidity readings.", + "description": "Displays changes to time series data over time—for example, temperature or humidity readings.", "descriptor": { "type": "timeseries", "sizeX": 8, diff --git a/application/src/main/data/json/system/widget_types/markdown_html_card.json b/application/src/main/data/json/system/widget_types/markdown_html_card.json index b7c6aa972e..526381bc02 100644 --- a/application/src/main/data/json/system/widget_types/markdown_html_card.json +++ b/application/src/main/data/json/system/widget_types/markdown_html_card.json @@ -3,7 +3,7 @@ "name": "Markdown/HTML Card", "deprecated": false, "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAABdFBMVEX////u7u7g4ODf398nJydISEiCs/Tx8fE/Pz+amppgYGC6urr7+/upqan6+vrW1tbKysr19fX9/f3j4+M5OTn39/fs7OxSUlJxcXHFxcVCQkI8PDyMjIxFRUV+fn40NDTa2trp6el3d3dVVVXAwMCKiorQ0NClpaX6/P8vLy+SkpJbW1srKyvl5eWhoaHn5+dMTEytra2Hh4dlZWVPT0+3t7eenp6Pj49CjO7j7v3MzMx7e3uBgYE2NjabwvbR0dHIyMjV5vvY2NgyMjJra2tUl/A0hO3a6fzA2fqEhIQ4hu1XV1f3+v7Dw8OXl5eUlJT0+P6y0Pi0tLTc3NzV1dWysrKwsLCnyfeIt/U+ie6vr690dHRJSUkqfezy9/641Pnz8/NkoPJIj++rq6vg7fyszfh8r/Tt7e28vLySvfZZmvDR4/uNuvV2q/Pp8v1ppPJMku9oaGifxfctf+zT09NnZ2dfnvHL3/vG3fpup/Lt9P4vgO3lU88CAAAQR0lEQVR42uyby2/aQBDGJ7jINti8DOb9dAA1lEASHhIQJWp7QSiJ1JYcaA6VckivkdL/v4aPLbs1bkqLlZTmOwzKesa7P8WKP80QIq1s+P5xGeWQzWGYez+L/jFpsqFR2dz750GI5DIZe7sAohnk2wkQ8r2ArBQ6o6XCUXpEMS7TS5DmLQcyjaVNIS6UmBFdC8eQ/LSUX3psu6K0NjP2absg5538CiSRp6QpxIVKr3zGq7d/CJJQE2sz5fhWQcz7+gokeJxX3wX5iIqLRu+wkaaj1HBAdH6c+tyyQUKXTaoNC3GJSoVRifQy0Z0u51KFMEFYCedyBtEy8+0oVZXJN6pEyBi/zuV823y0HlYgtUrl0qrxERXX6sm9ek1vS8HTL3RyUIskJH/ivUrNbPBrUTKVYFcxP5+3moOUpNSOsiFUYcVMdyK0zGxmu2eKZOaDEcWQg8V0WvYIhIb7el+I0GG98rneIznYzZfoJE1EktI4IqoX5g/MPA71/mxU+dCX/KtHCCtEgQjLPFxEPdDtBo7waHkEcnU6ydxc8REVB7pp6ld7mWovG16CZK0DIrU6P9g8VtXZQaBxNZ2DJMOowgpAlpnHi9hQVbXmKYisyFY4ykdUHM+IZtXIhMhiIP7EtxxF2lH7YOfJUKgTKSnqpVLiQbBC9D5Iy8wzJfwpLkU6i9tqrxNbBUkpbzIXS5DwSUyJCREqTImmhUSgHSiWGAiFrBnlFOudQceZTJW0otkqajwIVoiC8c7+MvM68L5o0FXWurHXh5nk/hO9EOVyiESFFivRKP1K0btlZixEzdO5+8Z9ZPnp3+x/pmYmf/PheVmUZ6EdBQkW+h93ASQcOCp0dgHEVvmNuRsg9cBu/EaCmfBOgKSVwU781bq7+bobf37DrzKZzHQHQHbnhfgC8vRyAdlqLwuKkSBPQczZqC+tQD49IG7WyxJrhY4WL09ByuPbyoSBaL7DnqEhbtbLEmuFjpYoLx+tj0UGIrUtqy0hbtjLEmqxz7KjNaikrgjC3bwC0U8uGQiVG4EW4sa9LL4WQkcrEb/4eE4Q7uYVyEHg3mQgh7p+iLhxL4uvhdAIit1UDWLC3bx6tO7a+wBh+vNeFiSAkKz6RwThbt6A2L+MVubcCbJ5L8shdLRiIdKyPvyMu3kDEslPlJHwHvmLXpYodLTKmUb7RMPPuJtHj1brobX43GIvS+xoaS1ZuNvTv9mfnf4HkNbt7YDM5dwiJrgsp9xz3N2apyCRDz9A9i19QGeFbER0SpLfZernmuMUcrwEOcsGViAp9vcfTok7gHPq55LzVCDliboWBE4JUz/ptFJ4u5z6wV9hDuiWg30Gw1x/JDvdWvmQqLcnV1Nj3zZBqr2va0HglDD1k7KRo6yGqR/8FeaAbjlLb3A1HI9Up1sLJ+feQFLObrPmFpsPlrkeBBFTv/kjkffhsYG/whzQLYeZnPpld+xwawsQ5Ff07YEcZxqd00tXEEz98O7GIeGvMAd05ogg1/Xu2OnWSh2Wn1O3ByI9POgTSQCBR0LE1A+HxNQP/gpzQLccBkLUHTvdmlz8MgfJx7RkeksgkPhoMY+EiKkfDompH/wV5oBuOTzIGrfWVyw7/40/M/TohQgQeCRENvWDuKkf5oAuOY+6tYSp2VBRmTwDuYhbqnPq58lkUPK/eK1HQeCyNtHg9raFp4fmmkZ/x00hE1H0YLHNHJoIct7r9VoAgcvaCES39oloL9ldWE4lxjsuRKeQiYgcVLn0wXD1cZDxZwbC3h0bKbU/Dz20Lo55x4XoFDIRkYMqOLc/Bxle20EAgTtCtwo+Cu4I69x3rhhIqY1nohEh3nEhwlOx7lYwyDIRkYMqODd+X1RJ8dGwRuik4QxrQe5TelkEgTtCtwo+CqYC6/x3rpYgl2jAmVmNeMeFCE/FulsTi2UiIgdVcG78vqiSil+D2RY6aTjD+tGbfp8sCyBwR+hWwUcBBOvCd64AMpwSETpevONChKdi3a1WE5mIyGFV2J3fd16FlUIdnTScwQkCtfcFELgjvKnho+COsC585wogYx21F0So4kHgqfjuFjIRkYMq7M7vi6r5SlVFJw1nWAticn0teCe4I9wQPgruCOvCd64AclTBsUNEvONChKdi3a16HZmIyGFV2J3fF1X2SqIdQScNZ1gHUspwfS14J7gj3BA+Cu4I68J3rgAiZw2ycT7bgXdciPBUrLsVaCCTReSgCrvz+6LKeNdQcoROGs7wWF8L3klwR/BRcEdYF75zBRC6tWSi+6BgsGREeCp0t9ANQybiKpPtLuyLKjTSUIszbG5R4KPcV1QrfrH41Cl6Gv09T4VMxF/7txevxSRMCWMOj4SrLg6q/IVc5TGIGTx0gvglZ+cK7ghXXR3ULOluO70FCXfe99xA4H9Ej+QOgqv9CrnKU5BJffVoofuEKaEwB4TPgUfCVazIl6mKAUfEHJQGSo/lBKkV+/0BA0H3CVNCfg4InwN3xM8QE+1cLS3DEeGqrQOVvJcTZHZzVI+XfoCkiU0J+fEZfA4++RniRYOITQlxxdYUH95LBJme2AZYF0DU6s8g8DlwR/wMcZoiYlNCXLWlV8l7OUHCSuuu81UAwZQQvgj+Bz4H7oifIfriBrEpIa7aGl6T5xJBoFy+k9oTQDAlhC+C/4HPgTvCVbaSTbbZlBBXqalEyVO5/48VfnKdEkbvBI8kXo21ovBR7Gp0sr1fiCcWBe7o8at6j9z1TECenV5Anpt2CeQ7u3b0mjYQwHH8l/kTE03aNUZtrVFrO0Ps1TnbKjilYvIioxaq60v7IPRBXwvr/79LRre6MdhgGemW70vCwR33gQsEkn8kSfknSiBxK4HErQQStxJI3EogcSuBxK3/DTLR8njqvPcby28hSNEG+K6IIVbNCfddq13gefe8xFOP7q+vzj6C5mzh+ybzKCGCJci6ZPNPQPRZ/acQ9UOkkBEngG5ZAWQ7/OicN1LOpYTImz2YudnrEDK+ck6AG8m73AOObnD/eqI5O5AVjRRM4xV0Y5AxLoCTdnsQQF71nKKxDSjObIyCMbKNQoSQW68LrFmVkDbv0mwjx31OJWRlW69wS8v1JKTKsueNcWrDVPdNlE/hqp7KLGRL0UWTQ2zTCI5WfSRUT0KanlAtaphbns1K0RaurUQJeW/p8NOOhOxWTbNjS8hpannPXMlb4IINM2O7GHMKxS6bPb79RC4OpNctK/CZgez4EH2W4fAkgOyKBQwJybopOBKSHR2gK/JRHy1/wLUitJ6EoNmu2J6EXAP3LPMcaHMQPiNvKGe1mJrwulXqXJ2zDvcxGJlDdiV2Og1OdtOQkHDDc7ZM9wFYUNtiyTCmzEUNyeLOb4viTEKmwn8GEWIXmLEeQobMfFGpw1KrX5qqeA5p8h0ndzN1GkJqjyFki40QsqJVkv0FiCbSPlps7ogGcPoV0uvzBjfMyTE3NKDKAzRULi5YbmxA9JFaRsXmOoSc2SbqbGH/EPhEDbVjADqihxQF1wFky+2Me8J9glzqh7V8xkuvg7Fi7WzPCLb+kappqrzegMDnFE2KZQjR2D3KSsgb9uVVk9fKQLNXOLOOCpFC8GDpAQQfPd5lefIEwdz9oB9Z7GRdYM8mfQVQOASGXG1CDI5hqscIIYVbIaoSsvQpHiSk0HXpXQGGYD4yyGZ6ET9krvAlZYlfbVkIZxYzhWuef/tLeJlB2Et7aXSs/sxSNzb/MiHLSid9m8fXXixkswSSQF5KCSRuJZC4lUDiVgL53N699jQNxXEc/6HHTWVDGXL1Ak6Ezg0F5dISLRTsWmvFFZWuKqut28LuczO7wJv3dJTBiGgMooh8k2U77U6yT5om/ydLT1udEO/u3zOXbs1hv14yg936iR9HNt6N7xRNMnEVu/FpHFFdgxsr/BZIHyErAJ6T4K9DhsePgGQOQfQ8DpczD0Gy0nEhq4+BrsgqhYz51/sxMHfB76WQt3O9uO5/40CWJ4cDWP6KT3P0bamve+BzdwC0lWvAxOR4AE6P3s94XUjAZKBqWlGmEEEXkCvUGskcUN9GTAYkvQE466Rel5Jgc2YqwZgKzx0TErzhw8ORcBCTkScjsz3D5NnqAJn5unoL8+TeZQp5Ebq6OoQ7YWyQCdyc95PpMLkF2q3HmJy+QneCgm6v3Rnpda/IZgy1Wl5kwbOFfDQnW6JWF8EoFcgWtDKfzaNSQE6JFwwerBgvZRleiUvHhHSvbmHw9b0gNrawSN4Ok/foJQs3PgR8o4/paXpR1unx5e5Qz1Bo4QIZ85MLGBp0Ic8n0fWgBZkAPl5vQxosgwaFlIA0Dz5NFYmGWEO8wigSElGVQpo8nBcrQ6VyVsBxIcHnK2RqOoi+4PRlcn2YvKGQCLmIKbLl3CMbpAtYveKNLI9emX1/M+An/XhxyYUsjoQnP4HWvxAeDI23IXoTEFhHgHim9dY0PWZ5W0wmq5Zl7UgUUq47ypagWvwNkPXrkQ9XcTuIl0/uruxBwrNhXxeZcyDjZAo9kYd4efVSb+hJEB0QBCaC90BbCPZjcB+ilQ5DUh5bzVdsFHeSHMcxFCI2fjMkcJusO5DLQwMLZHkXMjMWWsN0eMpP/N5nT7vWIhfxgNzHF/KuEzL7ClMRH4ChBxgb3WpDOIVDpQ2hvxrFaBM5xQOUU2B0B5IvQBXbELt+bAjWRvsdyEaIfCGTLgTzkYmlm+RqyI/hy+TZDLBIFjFDBjohr27P3ngI2tLNkcHZ+TYEFcW22hAhWgLEFMDKgFQuGy1bosba+5C4oh0D0lnPJ3QW8KJVlw9H5vV1ftctwahmFnupsY5zKpwYNYGahr22mdM1omQ8rRQxG7U8P8xSsmUlvb/mTxfELaFVJPwkSU/FzofG/wsS22TwneJ5nKbOKCQhcykZgt4AwKU0FaqcTDkQoQ5niDXB5QRdaEOSKXo00Sim5NaGhiRB3gZMZ6Mute7gnMABgl7HCdcJkaKlDGuVMoYG2eYLIrgd0UMhdUPA5jaKVWhKM6+YLkQrV5oWBKWUsXlIiidtZGDngCinimneqKOh5JuGDr3MizwOdbIQhYHGMqgU0JAQiHLczjZim3Ujhz1IFtBEFyInoVYTghKDVkMhAxT2IEUNyHhQ00FfapRDMcqgs5OFGIAsAqkSEvGsWJW4KCjEKKANsQAu6kK2PVlxsyiwgJyFKAPxPQhMK8umYUhAUxd2LMuqcujsT0EKeQaGC0mx5kGIZLiQEs+gugepmQcgOZsDn6YLB5JUONrfuiLZFOo7wi6EyRkcjDpkCrFjiDddSNmEvMm5kEyJUbMZ1FKQqpwpMqpVQNqDhK0ztgZVx6H+FERWWIs1XQh4kUkpdsmBiHaZcyGmwpaUhguJWYYtZug+u0Zv9prBNrMoimy5rEOgI+6fvNk7YxJo1Tm0ahYOHGdiBz8nAiW9vS/BOFvoQpTd1cnWCfH8NIs96kyJjq/RdOexmpEtG+5nE0f0d2YtyfzR+JrAoYSKpuKHnQ+N55BT3jnktHUOOW2dIUifD2cgXx/uenEG8t5FT5/3n78mzkO0z8hjzX34BpPgTEZLPbVzAAAAAElFTkSuQmCC", - "description": "Renders markdown/HTML using the configurable pattern or function with applied attributes or time-series values.", + "description": "Renders markdown/HTML using the configurable pattern or function with applied attributes or time series values.", "descriptor": { "type": "latest", "sizeX": 5, diff --git a/application/src/main/data/json/system/widget_types/pie.json b/application/src/main/data/json/system/widget_types/pie.json index 4d54a874b5..77f55ba202 100644 --- a/application/src/main/data/json/system/widget_types/pie.json +++ b/application/src/main/data/json/system/widget_types/pie.json @@ -3,7 +3,7 @@ "name": "Pie", "deprecated": false, "image": "tb-image:cGllX2NoYXJ0LnN2Zw==:IlBpZSIgc3lzdGVtIHdpZGdldCBpbWFnZQ==;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAxIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMSAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGZpbHRlcj0idXJsKCNmaWx0ZXIwX2RfNDUzOF8zNTMwNikiPgo8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgcng9IjIuMzg4MDYiIGZpbGw9IndoaXRlIiBzaGFwZS1yZW5kZXJpbmc9ImNyaXNwRWRnZXMiLz4KPHBhdGggZD0iTTE0OC45NzUgMTI5LjQ5N0MxNTYuNzc0IDEyMS42OTggMTYyLjYyIDExMi4xNjYgMTY2LjAzNiAxMDEuNjc4TDk5LjQ3NzUgODBMMTQ4Ljk3NSAxMjkuNDk3WiIgZmlsbD0iI0ZGREUzMCIgc3Ryb2tlPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNOTkuNDc3NSAxMEM4Ny45NjYgMTAgNzYuNjMyMSAxMi44MzkgNjYuNDc5OCAxOC4yNjU1QzU2LjMyNzUgMjMuNjkyIDQ3LjY3MDIgMzEuNTM4NiA0MS4yNzQ3IDQxLjExMDFDMzQuODc5MiA1MC42ODE2IDMwLjk0MjkgNjEuNjgyNyAyOS44MTQ2IDczLjEzODhDMjguNjg2MyA4NC41OTQ5IDMwLjQwMDcgOTYuMTUyNSAzNC44MDYgMTA2Ljc4OEMzOS4yMTEzIDExNy40MjMgNDYuMTcxNCAxMjYuODA4IDU1LjA3IDEzNC4xMTFDNjMuOTY4NiAxNDEuNDE0IDc0LjUzMDggMTQ2LjQwOSA4NS44MjEyIDE0OC42NTVDOTcuMTExNiAxNTAuOTAxIDEwOC43ODIgMTUwLjMyNyAxMTkuNzk3IDE0Ni45ODZDMTMwLjgxMyAxNDMuNjQ0IDE0MC44MzUgMTM3LjYzNyAxNDguOTc1IDEyOS40OTdMOTkuNDc3NSA4MFYxMFoiIGZpbGw9IiMwODg3MkIiIHN0cm9rZT0id2hpdGUiLz4KPGcgZmlsdGVyPSJ1cmwoI2ZpbHRlcjFfZF80NTM4XzM1MzA2KSI+CjxwYXRoIGQ9Ik0xNjcuNTYyIDEwMi4wNTdDMTcxLjAxNCA5MS4yNTc1IDE3MS44ODIgNzkuNzg5OCAxNzAuMDk2IDY4LjU4ODVDMTY4LjMxMSA1Ny4zODcxIDE2My45MjIgNDYuNzY5MiAxNTcuMjg4IDM3LjYwMDJDMTUwLjY1MyAyOC40MzEyIDE0MS45NjEgMjAuOTcwNiAxMzEuOTIgMTUuODI2NkMxMjEuODc5IDEwLjY4MjYgMTEwLjc3MiA4LjAwMDc4IDk5LjUwNjQgNy45OTk3Nkw5OS41IDc5Ljk5OThMMTY3LjU2MiAxMDIuMDU3WiIgZmlsbD0iI0ZGNEQ1QSIvPgo8cGF0aCBkPSJNMTY3LjU2MiAxMDIuMDU3QzE3MS4wMTQgOTEuMjU3NSAxNzEuODgyIDc5Ljc4OTggMTcwLjA5NiA2OC41ODg1QzE2OC4zMTEgNTcuMzg3MSAxNjMuOTIyIDQ2Ljc2OTIgMTU3LjI4OCAzNy42MDAyQzE1MC42NTMgMjguNDMxMiAxNDEuOTYxIDIwLjk3MDYgMTMxLjkyIDE1LjgyNjZDMTIxLjg3OSAxMC42ODI2IDExMC43NzIgOC4wMDA3OCA5OS41MDY0IDcuOTk5NzZMOTkuNSA3OS45OTk4TDE2Ny41NjIgMTAyLjA1N1oiIHN0cm9rZT0id2hpdGUiLz4KPC9nPgo8L2c+CjxkZWZzPgo8ZmlsdGVyIGlkPSJmaWx0ZXIwX2RfNDUzOF8zNTMwNiIgeD0iLTcuMTY0MTgiIHk9Ii00Ljc3NjEyIiB3aWR0aD0iMjE0LjMyOCIgaGVpZ2h0PSIxNzQuMzI4IiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiI+CjxmZUZsb29kIGZsb29kLW9wYWNpdHk9IjAiIHJlc3VsdD0iQmFja2dyb3VuZEltYWdlRml4Ii8+CjxmZUNvbG9yTWF0cml4IGluPSJTb3VyY2VBbHBoYSIgdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDEyNyAwIiByZXN1bHQ9ImhhcmRBbHBoYSIvPgo8ZmVPZmZzZXQgZHk9IjIuMzg4MDYiLz4KPGZlR2F1c3NpYW5CbHVyIHN0ZERldmlhdGlvbj0iMy41ODIwOSIvPgo8ZmVDb21wb3NpdGUgaW4yPSJoYXJkQWxwaGEiIG9wZXJhdG9yPSJvdXQiLz4KPGZlQ29sb3JNYXRyaXggdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAuMDYgMCIvPgo8ZmVCbGVuZCBtb2RlPSJub3JtYWwiIGluMj0iQmFja2dyb3VuZEltYWdlRml4IiByZXN1bHQ9ImVmZmVjdDFfZHJvcFNoYWRvd180NTM4XzM1MzA2Ii8+CjxmZUJsZW5kIG1vZGU9Im5vcm1hbCIgaW49IlNvdXJjZUdyYXBoaWMiIGluMj0iZWZmZWN0MV9kcm9wU2hhZG93XzQ1MzhfMzUzMDYiIHJlc3VsdD0ic2hhcGUiLz4KPC9maWx0ZXI+CjxmaWx0ZXIgaWQ9ImZpbHRlcjFfZF80NTM4XzM1MzA2IiB4PSI4NyIgeT0iLTQuNTAwMjQiIHdpZHRoPSI5Ni41IiBoZWlnaHQ9IjExOS4xODciIGZpbHRlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzPSJzUkdCIj4KPGZlRmxvb2QgZmxvb2Qtb3BhY2l0eT0iMCIgcmVzdWx0PSJCYWNrZ3JvdW5kSW1hZ2VGaXgiLz4KPGZlQ29sb3JNYXRyaXggaW49IlNvdXJjZUFscGhhIiB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMTI3IDAiIHJlc3VsdD0iaGFyZEFscGhhIi8+CjxmZU9mZnNldC8+CjxmZUdhdXNzaWFuQmx1ciBzdGREZXZpYXRpb249IjYiLz4KPGZlQ29tcG9zaXRlIGluMj0iaGFyZEFscGhhIiBvcGVyYXRvcj0ib3V0Ii8+CjxmZUNvbG9yTWF0cml4IHR5cGU9Im1hdHJpeCIgdmFsdWVzPSIwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwLjI1IDAiLz4KPGZlQmxlbmQgbW9kZT0ibm9ybWFsIiBpbjI9IkJhY2tncm91bmRJbWFnZUZpeCIgcmVzdWx0PSJlZmZlY3QxX2Ryb3BTaGFkb3dfNDUzOF8zNTMwNiIvPgo8ZmVCbGVuZCBtb2RlPSJub3JtYWwiIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9ImVmZmVjdDFfZHJvcFNoYWRvd180NTM4XzM1MzA2IiByZXN1bHQ9InNoYXBlIi8+CjwvZmlsdGVyPgo8L2RlZnM+Cjwvc3ZnPgo=", - "description": "Displays the latest values of the attributes or time-series data in a pie chart. Supports numeric values only.", + "description": "Displays the latest values of the attributes or time series data in a pie chart. Supports numeric values only.", "descriptor": { "type": "latest", "sizeX": 5, diff --git a/application/src/main/data/json/system/widget_types/point_chart.json b/application/src/main/data/json/system/widget_types/point_chart.json index beb99116c9..fcd2e62859 100644 --- a/application/src/main/data/json/system/widget_types/point_chart.json +++ b/application/src/main/data/json/system/widget_types/point_chart.json @@ -3,7 +3,7 @@ "name": "Point chart", "deprecated": false, "image": "tb-image:Y2hhcnRfKDMpLnN2Zw==:IlBvaW50IGNoYXJ0IiBzeXN0ZW0gd2lkZ2V0IGltYWdl;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF80MTgyXzExMTkzKSI+CjxwYXRoIGQ9Ik0yLjg0NzY2IDEuMjgxMjVWN0gyLjEyNVYyLjE4MzU5TDAuNjY3OTY5IDIuNzE0ODRWMi4wNjI1TDIuNzM0MzggMS4yODEyNUgyLjg0NzY2Wk04LjY3NTE3IDMuNzAzMTJWNC41NzAzMUM4LjY3NTE3IDUuMDM2NDYgOC42MzM1MSA1LjQyOTY5IDguNTUwMTcgNS43NUM4LjQ2Njg0IDYuMDcwMzEgOC4zNDcwNSA2LjMyODEyIDguMTkwOCA2LjUyMzQ0QzguMDM0NTUgNi43MTg3NSA3Ljg0NTc1IDYuODYwNjggNy42MjQzOSA2Ljk0OTIyQzcuNDA1NjQgNy4wMzUxNiA3LjE1ODI1IDcuMDc4MTIgNi44ODIyIDcuMDc4MTJDNi42NjM0NSA3LjA3ODEyIDYuNDYxNjMgNy4wNTA3OCA2LjI3NjczIDYuOTk2MDlDNi4wOTE4NCA2Ljk0MTQxIDUuOTI1MTcgNi44NTQxNyA1Ljc3NjczIDYuNzM0MzhDNS42MzA5IDYuNjExOTggNS41MDU5IDYuNDUzMTIgNS40MDE3MyA2LjI1NzgxQzUuMjk3NTcgNi4wNjI1IDUuMjE4MTQgNS44MjU1MiA1LjE2MzQ1IDUuNTQ2ODhDNS4xMDg3NyA1LjI2ODIzIDUuMDgxNDIgNC45NDI3MSA1LjA4MTQyIDQuNTcwMzFWMy43MDMxMkM1LjA4MTQyIDMuMjM2OTggNS4xMjMwOSAyLjg0NjM1IDUuMjA2NDIgMi41MzEyNUM1LjI5MjM2IDIuMjE2MTUgNS40MTM0NSAxLjk2MzU0IDUuNTY5NyAxLjc3MzQ0QzUuNzI1OTUgMS41ODA3MyA1LjkxMzQ1IDEuNDQyNzEgNi4xMzIyIDEuMzU5MzhDNi4zNTM1NiAxLjI3NjA0IDYuNjAwOTUgMS4yMzQzOCA2Ljg3NDM5IDEuMjM0MzhDNy4wOTU3NSAxLjIzNDM4IDcuMjk4ODcgMS4yNjE3MiA3LjQ4Mzc3IDEuMzE2NDFDNy42NzEyNyAxLjM2ODQ5IDcuODM3OTMgMS40NTMxMiA3Ljk4Mzc3IDEuNTcwMzFDOC4xMjk2IDEuNjg0OSA4LjI1MzMgMS44Mzg1NCA4LjM1NDg2IDIuMDMxMjVDOC40NTkwMyAyLjIyMTM1IDguNTM4NDUgMi40NTQ0MyA4LjU5MzE0IDIuNzMwNDdDOC42NDc4MyAzLjAwNjUxIDguNjc1MTcgMy4zMzA3MyA4LjY3NTE3IDMuNzAzMTJaTTcuOTQ4NjEgNC42ODc1VjMuNTgyMDNDNy45NDg2MSAzLjMyNjgyIDcuOTMyOTggMy4xMDI4NiA3LjkwMTczIDIuOTEwMTZDNy44NzMwOSAyLjcxNDg0IDcuODMwMTIgMi41NDgxOCA3Ljc3MjgzIDIuNDEwMTZDNy43MTU1NCAyLjI3MjE0IDcuNjQyNjIgMi4xNjAxNiA3LjU1NDA4IDIuMDc0MjJDNy40NjgxNCAxLjk4ODI4IDcuMzY3ODggMS45MjU3OCA3LjI1MzMgMS44ODY3MkM3LjE0MTMyIDEuODQ1MDUgNy4wMTUwMiAxLjgyNDIyIDYuODc0MzkgMS44MjQyMkM2LjcwMjUyIDEuODI0MjIgNi41NTAxNyAxLjg1Njc3IDYuNDE3MzYgMS45MjE4OEM2LjI4NDU1IDEuOTg0MzggNi4xNzI1NyAyLjA4NDY0IDYuMDgxNDIgMi4yMjI2NkM1Ljk5Mjg4IDIuMzYwNjggNS45MjUxNyAyLjU0MTY3IDUuODc4MyAyLjc2NTYyQzUuODMxNDIgMi45ODk1OCA1LjgwNzk4IDMuMjYxNzIgNS44MDc5OCAzLjU4MjAzVjQuNjg3NUM1LjgwNzk4IDQuOTQyNzEgNS44MjIzMSA1LjE2Nzk3IDUuODUwOTUgNS4zNjMyOEM1Ljg4MjIgNS41NTg1OSA1LjkyNzc4IDUuNzI3ODYgNS45ODc2NyA1Ljg3MTA5QzYuMDQ3NTcgNi4wMTE3MiA2LjEyMDQ4IDYuMTI3NiA2LjIwNjQyIDYuMjE4NzVDNi4yOTIzNiA2LjMwOTkgNi4zOTEzMiA2LjM3NzYgNi41MDMzIDYuNDIxODhDNi42MTc4OCA2LjQ2MzU0IDYuNzQ0MTggNi40ODQzOCA2Ljg4MjIgNi40ODQzOEM3LjA1OTI5IDYuNDg0MzggNy4yMTQyMyA2LjQ1MDUyIDcuMzQ3MDUgNi4zODI4MUM3LjQ3OTg2IDYuMzE1MSA3LjU5MDU0IDYuMjA5NjQgNy42NzkwOCA2LjA2NjQxQzcuNzcwMjIgNS45MjA1NyA3LjgzNzkzIDUuNzM0MzggNy44ODIyIDUuNTA3ODFDNy45MjY0NyA1LjI3ODY1IDcuOTQ4NjEgNS4wMDUyMSA3Ljk0ODYxIDQuNjg3NVpNMTMuMzA3NCAzLjcwMzEyVjQuNTcwMzFDMTMuMzA3NCA1LjAzNjQ2IDEzLjI2NTcgNS40Mjk2OSAxMy4xODI0IDUuNzVDMTMuMDk5IDYuMDcwMzEgMTIuOTc5MyA2LjMyODEyIDEyLjgyMyA2LjUyMzQ0QzEyLjY2NjggNi43MTg3NSAxMi40Nzc5IDYuODYwNjggMTIuMjU2NiA2Ljk0OTIyQzEyLjAzNzggNy4wMzUxNiAxMS43OTA0IDcuMDc4MTIgMTEuNTE0NCA3LjA3ODEyQzExLjI5NTcgNy4wNzgxMiAxMS4wOTM4IDcuMDUwNzggMTAuOTA4OSA2Ljk5NjA5QzEwLjcyNCA2Ljk0MTQxIDEwLjU1NzQgNi44NTQxNyAxMC40MDg5IDYuNzM0MzhDMTAuMjYzMSA2LjYxMTk4IDEwLjEzODEgNi40NTMxMiAxMC4wMzM5IDYuMjU3ODFDOS45Mjk3NyA2LjA2MjUgOS44NTAzNCA1LjgyNTUyIDkuNzk1NjYgNS41NDY4OEM5Ljc0MDk3IDUuMjY4MjMgOS43MTM2MyA0Ljk0MjcxIDkuNzEzNjMgNC41NzAzMVYzLjcwMzEyQzkuNzEzNjMgMy4yMzY5OCA5Ljc1NTI5IDIuODQ2MzUgOS44Mzg2MyAyLjUzMTI1QzkuOTI0NTYgMi4yMTYxNSAxMC4wNDU3IDEuOTYzNTQgMTAuMjAxOSAxLjc3MzQ0QzEwLjM1ODIgMS41ODA3MyAxMC41NDU3IDEuNDQyNzEgMTAuNzY0NCAxLjM1OTM4QzEwLjk4NTggMS4yNzYwNCAxMS4yMzMyIDEuMjM0MzggMTEuNTA2NiAxLjIzNDM4QzExLjcyNzkgMS4yMzQzOCAxMS45MzExIDEuMjYxNzIgMTIuMTE2IDEuMzE2NDFDMTIuMzAzNSAxLjM2ODQ5IDEyLjQ3MDEgMS40NTMxMiAxMi42MTYgMS41NzAzMUMxMi43NjE4IDEuNjg0OSAxMi44ODU1IDEuODM4NTQgMTIuOTg3MSAyLjAzMTI1QzEzLjA5MTIgMi4yMjEzNSAxMy4xNzA3IDIuNDU0NDMgMTMuMjI1MyAyLjczMDQ3QzEzLjI4IDMuMDA2NTEgMTMuMzA3NCAzLjMzMDczIDEzLjMwNzQgMy43MDMxMlpNMTIuNTgwOCA0LjY4NzVWMy41ODIwM0MxMi41ODA4IDMuMzI2ODIgMTIuNTY1MiAzLjEwMjg2IDEyLjUzMzkgMi45MTAxNkMxMi41MDUzIDIuNzE0ODQgMTIuNDYyMyAyLjU0ODE4IDEyLjQwNSAyLjQxMDE2QzEyLjM0NzcgMi4yNzIxNCAxMi4yNzQ4IDIuMTYwMTYgMTIuMTg2MyAyLjA3NDIyQzEyLjEwMDMgMS45ODgyOCAxMi4wMDAxIDEuOTI1NzggMTEuODg1NSAxLjg4NjcyQzExLjc3MzUgMS44NDUwNSAxMS42NDcyIDEuODI0MjIgMTEuNTA2NiAxLjgyNDIyQzExLjMzNDcgMS44MjQyMiAxMS4xODI0IDEuODU2NzcgMTEuMDQ5NiAxLjkyMTg4QzEwLjkxNjggMS45ODQzOCAxMC44MDQ4IDIuMDg0NjQgMTAuNzEzNiAyLjIyMjY2QzEwLjYyNTEgMi4zNjA2OCAxMC41NTc0IDIuNTQxNjcgMTAuNTEwNSAyLjc2NTYyQzEwLjQ2MzYgMi45ODk1OCAxMC40NDAyIDMuMjYxNzIgMTAuNDQwMiAzLjU4MjAzVjQuNjg3NUMxMC40NDAyIDQuOTQyNzEgMTAuNDU0NSA1LjE2Nzk3IDEwLjQ4MzIgNS4zNjMyOEMxMC41MTQ0IDUuNTU4NTkgMTAuNTYgNS43Mjc4NiAxMC42MTk5IDUuODcxMDlDMTAuNjc5OCA2LjAxMTcyIDEwLjc1MjcgNi4xMjc2IDEwLjgzODYgNi4yMTg3NUMxMC45MjQ2IDYuMzA5OSAxMS4wMjM1IDYuMzc3NiAxMS4xMzU1IDYuNDIxODhDMTEuMjUwMSA2LjQ2MzU0IDExLjM3NjQgNi40ODQzOCAxMS41MTQ0IDYuNDg0MzhDMTEuNjkxNSA2LjQ4NDM4IDExLjg0NjQgNi40NTA1MiAxMS45NzkzIDYuMzgyODFDMTIuMTEyMSA2LjMxNTEgMTIuMjIyNyA2LjIwOTY0IDEyLjMxMTMgNi4wNjY0MUMxMi40MDI0IDUuOTIwNTcgMTIuNDcwMSA1LjczNDM4IDEyLjUxNDQgNS41MDc4MUMxMi41NTg3IDUuMjc4NjUgMTIuNTgwOCA1LjAwNTIxIDEyLjU4MDggNC42ODc1Wk0xNC4zMDY4IDIuNzA3MDNWMi40MDYyNUMxNC4zMDY4IDIuMTkwMSAxNC4zNTM2IDEuOTkzNDkgMTQuNDQ3NCAxLjgxNjQxQzE0LjU0MTEgMS42MzkzMiAxNC42NzUzIDEuNDk3NCAxNC44NDk3IDEuMzkwNjJDMTUuMDI0MiAxLjI4Mzg1IDE1LjIzMTIgMS4yMzA0NyAxNS40NzA4IDEuMjMwNDdDMTUuNzE1NiAxLjIzMDQ3IDE1LjkyNCAxLjI4Mzg1IDE2LjA5NTggMS4zOTA2MkMxNi4yNzAzIDEuNDk3NCAxNi40MDQ0IDEuNjM5MzIgMTYuNDk4MiAxLjgxNjQxQzE2LjU5MTkgMS45OTM0OSAxNi42Mzg4IDIuMTkwMSAxNi42Mzg4IDIuNDA2MjVWMi43MDcwM0MxNi42Mzg4IDIuOTE3OTcgMTYuNTkxOSAzLjExMTk4IDE2LjQ5ODIgMy4yODkwNkMxNi40MDcgMy40NjYxNSAxNi4yNzQyIDMuNjA4MDcgMTYuMDk5NyAzLjcxNDg0QzE1LjkyNzkgMy44MjE2MSAxNS43MjA4IDMuODc1IDE1LjQ3ODYgMy44NzVDMTUuMjM2NSAzLjg3NSAxNS4wMjY4IDMuODIxNjEgMTQuODQ5NyAzLjcxNDg0QzE0LjY3NTMgMy42MDgwNyAxNC41NDExIDMuNDY2MTUgMTQuNDQ3NCAzLjI4OTA2QzE0LjM1MzYgMy4xMTE5OCAxNC4zMDY4IDIuOTE3OTcgMTQuMzA2OCAyLjcwNzAzWk0xNC44NDk3IDIuNDA2MjVWMi43MDcwM0MxNC44NDk3IDIuODI2ODIgMTQuODcxOSAyLjk0MDEgMTQuOTE2MSAzLjA0Njg4QzE0Ljk2MyAzLjE1MzY1IDE1LjAzMzMgMy4yNDA4OSAxNS4xMjcxIDMuMzA4NTlDMTUuMjIwOCAzLjM3MzcgMTUuMzM4IDMuNDA2MjUgMTUuNDc4NiAzLjQwNjI1QzE1LjYxOTMgMy40MDYyNSAxNS43MzUyIDMuMzczNyAxNS44MjYzIDMuMzA4NTlDMTUuOTE3NCAzLjI0MDg5IDE1Ljk4NTIgMy4xNTM2NSAxNi4wMjk0IDMuMDQ2ODhDMTYuMDczNyAyLjk0MDEgMTYuMDk1OCAyLjgyNjgyIDE2LjA5NTggMi43MDcwM1YyLjQwNjI1QzE2LjA5NTggMi4yODM4NSAxNi4wNzI0IDIuMTY5MjcgMTYuMDI1NSAyLjA2MjVDMTUuOTgxMiAxLjk1MzEyIDE1LjkxMjIgMS44NjU4OSAxNS44MTg1IDEuODAwNzhDMTUuNzI3MyAxLjczMzA3IDE1LjYxMTUgMS42OTkyMiAxNS40NzA4IDEuNjk5MjJDMTUuMzMyOCAxLjY5OTIyIDE1LjIxNjkgMS43MzMwNyAxNS4xMjMyIDEuODAwNzhDMTUuMDMyIDEuODY1ODkgMTQuOTYzIDEuOTUzMTIgMTQuOTE2MSAyLjA2MjVDMTQuODcxOSAyLjE2OTI3IDE0Ljg0OTcgMi4yODM4NSAxNC44NDk3IDIuNDA2MjVaTTE3LjA3NjMgNS45MTAxNlY1LjYwNTQ3QzE3LjA3NjMgNS4zOTE5MyAxNy4xMjMyIDUuMTk2NjEgMTcuMjE2OSA1LjAxOTUzQzE3LjMxMDcgNC44NDI0NSAxNy40NDQ4IDQuNzAwNTIgMTcuNjE5MyA0LjU5Mzc1QzE3Ljc5MzcgNC40ODY5OCAxOC4wMDA4IDQuNDMzNTkgMTguMjQwNCA0LjQzMzU5QzE4LjQ4NTIgNC40MzM1OSAxOC42OTM1IDQuNDg2OTggMTguODY1NCA0LjU5Mzc1QzE5LjAzOTggNC43MDA1MiAxOS4xNzQgNC44NDI0NSAxOS4yNjc3IDUuMDE5NTNDMTkuMzYxNSA1LjE5NjYxIDE5LjQwODMgNS4zOTE5MyAxOS40MDgzIDUuNjA1NDdWNS45MTAxNkMxOS40MDgzIDYuMTIzNyAxOS4zNjE1IDYuMzE5MDEgMTkuMjY3NyA2LjQ5NjA5QzE5LjE3NjYgNi42NzMxOCAxOS4wNDM3IDYuODE1MSAxOC44NjkzIDYuOTIxODhDMTguNjk3NCA3LjAyODY1IDE4LjQ5MDQgNy4wODIwMyAxOC4yNDgyIDcuMDgyMDNDMTguMDA2IDcuMDgyMDMgMTcuNzk3NyA3LjAyODY1IDE3LjYyMzIgNi45MjE4OEMxNy40NDg3IDYuODE1MSAxNy4zMTMzIDYuNjczMTggMTcuMjE2OSA2LjQ5NjA5QzE3LjEyMzIgNi4zMTkwMSAxNy4wNzYzIDYuMTIzNyAxNy4wNzYzIDUuOTEwMTZaTTE3LjYxOTMgNS42MDU0N1Y1LjkxMDE2QzE3LjYxOTMgNi4wMjk5NSAxNy42NDE0IDYuMTQ0NTMgMTcuNjg1NyA2LjI1MzkxQzE3LjczMjUgNi4zNjA2OCAxNy44MDI5IDYuNDQ3OTIgMTcuODk2NiA2LjUxNTYyQzE3Ljk5MDQgNi41ODA3MyAxOC4xMDc1IDYuNjEzMjggMTguMjQ4MiA2LjYxMzI4QzE4LjM4ODggNi42MTMyOCAxOC41MDQ3IDYuNTgwNzMgMTguNTk1OCA2LjUxNTYyQzE4LjY4OTYgNi40NDc5MiAxOC43NTg2IDYuMzYwNjggMTguODAyOSA2LjI1MzkxQzE4Ljg0NzEgNi4xNDcxNCAxOC44NjkzIDYuMDMyNTUgMTguODY5MyA1LjkxMDE2VjUuNjA1NDdDMTguODY5MyA1LjQ4MzA3IDE4Ljg0NTggNS4zNjg0OSAxOC43OTkgNS4yNjE3MkMxOC43NTQ3IDUuMTU0OTUgMTguNjg1NyA1LjA2OTAxIDE4LjU5MTkgNS4wMDM5MUMxOC41MDA4IDQuOTM2MiAxOC4zODM2IDQuOTAyMzQgMTguMjQwNCA0LjkwMjM0QzE4LjEwMjMgNC45MDIzNCAxNy45ODY1IDQuOTM2MiAxNy44OTI3IDUuMDAzOTFDMTcuODAxNiA1LjA2OTAxIDE3LjczMjUgNS4xNTQ5NSAxNy42ODU3IDUuMjYxNzJDMTcuNjQxNCA1LjM2ODQ5IDE3LjYxOTMgNS40ODMwNyAxNy42MTkzIDUuNjA1NDdaTTE4LjQyIDIuMTIxMDlMMTUuNjQyNyA2LjU2NjQxTDE1LjIzNjUgNi4zMDg1OUwxOC4wMTM4IDEuODYzMjhMMTguNDIgMi4xMjEwOVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPHBhdGggZD0iTTguMDU4NTkgMzMuNjY3N0M4LjA1ODU5IDM0LjAxNDEgNy45Nzc4NiAzNC4zMDgzIDcuODE2NDEgMzQuNTUwNUM3LjY1NzU1IDM0Ljc5MDEgNy40NDE0MSAzNC45NzI0IDcuMTY3OTcgMzUuMDk3NEM2Ljg5NzE0IDM1LjIyMjQgNi41OTExNSAzNS4yODQ5IDYuMjUgMzUuMjg0OUM1LjkwODg1IDM1LjI4NDkgNS42MDE1NiAzNS4yMjI0IDUuMzI4MTIgMzUuMDk3NEM1LjA1NDY5IDM0Ljk3MjQgNC44Mzg1NCAzNC43OTAxIDQuNjc5NjkgMzQuNTUwNUM0LjUyMDgzIDM0LjMwODMgNC40NDE0MSAzNC4wMTQxIDQuNDQxNDEgMzMuNjY3N0M0LjQ0MTQxIDMzLjQ0MTIgNC40ODQzOCAzMy4yMzQxIDQuNTcwMzEgMzMuMDQ2NkM0LjY1ODg1IDMyLjg1NjUgNC43ODI1NSAzMi42OTEyIDQuOTQxNDEgMzIuNTUwNUM1LjEwMjg2IDMyLjQwOTkgNS4yOTI5NyAzMi4zMDE4IDUuNTExNzIgMzIuMjI2M0M1LjczMzA3IDMyLjE0ODIgNS45NzY1NiAzMi4xMDkxIDYuMjQyMTkgMzIuMTA5MUM2LjU5MTE1IDMyLjEwOTEgNi45MDIzNCAzMi4xNzY4IDcuMTc1NzggMzIuMzEyM0M3LjQ0OTIyIDMyLjQ0NTEgNy42NjQwNiAzMi42Mjg3IDcuODIwMzEgMzIuODYzQzcuOTc5MTcgMzMuMDk3NCA4LjA1ODU5IDMzLjM2NTYgOC4wNTg1OSAzMy42Njc3Wk03LjMzMjAzIDMzLjY1MjFDNy4zMzIwMyAzMy40NDEyIDcuMjg2NDYgMzMuMjU1IDcuMTk1MzEgMzMuMDkzNUM3LjEwNDE3IDMyLjkyOTQgNi45NzY1NiAzMi44MDE4IDYuODEyNSAzMi43MTA3QzYuNjQ4NDQgMzIuNjE5NSA2LjQ1ODMzIDMyLjU3NCA2LjI0MjE5IDMyLjU3NEM2LjAyMDgzIDMyLjU3NCA1LjgyOTQzIDMyLjYxOTUgNS42Njc5NyAzMi43MTA3QzUuNTA5MTEgMzIuODAxOCA1LjM4NTQyIDMyLjkyOTQgNS4yOTY4OCAzMy4wOTM1QzUuMjA4MzMgMzMuMjU1IDUuMTY0MDYgMzMuNDQxMiA1LjE2NDA2IDMzLjY1MjFDNS4xNjQwNiAzMy44NzA4IDUuMjA3MDMgMzQuMDU4MyA1LjI5Mjk3IDM0LjIxNDZDNS4zODE1MSAzNC4zNjgyIDUuNTA2NTEgMzQuNDg2NyA1LjY2Nzk3IDM0LjU3MDFDNS44MzIwMyAzNC42NTA4IDYuMDI2MDQgMzQuNjkxMiA2LjI1IDM0LjY5MTJDNi40NzM5NiAzNC42OTEyIDYuNjY2NjcgMzQuNjUwOCA2LjgyODEyIDM0LjU3MDFDNi45ODk1OCAzNC40ODY3IDcuMTEzMjggMzQuMzY4MiA3LjE5OTIyIDM0LjIxNDZDNy4yODc3NiAzNC4wNTgzIDcuMzMyMDMgMzMuODcwOCA3LjMzMjAzIDMzLjY1MjFaTTcuOTI1NzggMzAuOTk5OEM3LjkyNTc4IDMxLjI3NTggNy44NTI4NiAzMS41MjQ1IDcuNzA3MDMgMzEuNzQ1OEM3LjU2MTIgMzEuOTY3MiA3LjM2MTk4IDMyLjE0MTcgNy4xMDkzOCAzMi4yNjkzQzYuODU2NzcgMzIuMzk2OSA2LjU3MDMxIDMyLjQ2MDcgNi4yNSAzMi40NjA3QzUuOTI0NDggMzIuNDYwNyA1LjYzNDExIDMyLjM5NjkgNS4zNzg5MSAzMi4yNjkzQzUuMTI2MyAzMi4xNDE3IDQuOTI4MzkgMzEuOTY3MiA0Ljc4NTE2IDMxLjc0NThDNC42NDE5MyAzMS41MjQ1IDQuNTcwMzEgMzEuMjc1OCA0LjU3MDMxIDMwLjk5OThDNC41NzAzMSAzMC42NjkgNC42NDE5MyAzMC4zODc4IDQuNzg1MTYgMzAuMTU2QzQuOTMwOTkgMjkuOTI0MiA1LjEzMDIxIDI5Ljc0NzIgNS4zODI4MSAyOS42MjQ4QzUuNjM1NDIgMjkuNTAyNCA1LjkyMzE4IDI5LjQ0MTIgNi4yNDYwOSAyOS40NDEyQzYuNTcxNjEgMjkuNDQxMiA2Ljg2MDY4IDI5LjUwMjQgNy4xMTMyOCAyOS42MjQ4QzcuMzY1ODkgMjkuNzQ3MiA3LjU2MzggMjkuOTI0MiA3LjcwNzAzIDMwLjE1NkM3Ljg1Mjg2IDMwLjM4NzggNy45MjU3OCAzMC42NjkgNy45MjU3OCAzMC45OTk4Wk03LjIwMzEyIDMxLjAxMTVDNy4yMDMxMiAzMC44MjE0IDcuMTYyNzYgMzAuNjUzNCA3LjA4MjAzIDMwLjUwNzZDNy4wMDEzIDMwLjM2MTcgNi44ODkzMiAzMC4yNDcyIDYuNzQ2MDkgMzAuMTYzOEM2LjYwMjg2IDMwLjA3NzkgNi40MzYyIDMwLjAzNDkgNi4yNDYwOSAzMC4wMzQ5QzYuMDU1OTkgMzAuMDM0OSA1Ljg4OTMyIDMwLjA3NTMgNS43NDYwOSAzMC4xNTZDNS42MDU0NyAzMC4yMzQxIDUuNDk0NzkgMzAuMzQ2MSA1LjQxNDA2IDMwLjQ5MTlDNS4zMzU5NCAzMC42Mzc4IDUuMjk2ODggMzAuODExIDUuMjk2ODggMzEuMDExNUM1LjI5Njg4IDMxLjIwNjggNS4zMzU5NCAzMS4zNzc0IDUuNDE0MDYgMzEuNTIzMkM1LjQ5NDc5IDMxLjY2OSA1LjYwNjc3IDMxLjc4MjMgNS43NSAzMS44NjNDNS44OTMyMyAzMS45NDM4IDYuMDU5OSAzMS45ODQxIDYuMjUgMzEuOTg0MUM2LjQ0MDEgMzEuOTg0MSA2LjYwNTQ3IDMxLjk0MzggNi43NDYwOSAzMS44NjNDNi44ODkzMiAzMS43ODIzIDcuMDAxMyAzMS42NjkgNy4wODIwMyAzMS41MjMyQzcuMTYyNzYgMzEuMzc3NCA3LjIwMzEyIDMxLjIwNjggNy4yMDMxMiAzMS4wMTE1Wk0xMi42NzUyIDMxLjkwOTlWMzIuNzc3MUMxMi42NzUyIDMzLjI0MzIgMTIuNjMzNSAzMy42MzY1IDEyLjU1MDIgMzMuOTU2OEMxMi40NjY4IDM0LjI3NzEgMTIuMzQ3IDM0LjUzNDkgMTIuMTkwOCAzNC43MzAyQzEyLjAzNDUgMzQuOTI1NSAxMS44NDU3IDM1LjA2NzUgMTEuNjI0NCAzNS4xNTZDMTEuNDA1NiAzNS4yNDE5IDExLjE1ODIgMzUuMjg0OSAxMC44ODIyIDM1LjI4NDlDMTAuNjYzNSAzNS4yODQ5IDEwLjQ2MTYgMzUuMjU3NiAxMC4yNzY3IDM1LjIwMjlDMTAuMDkxOCAzNS4xNDgyIDkuOTI1MTcgMzUuMDYxIDkuNzc2NzMgMzQuOTQxMkM5LjYzMDkgMzQuODE4OCA5LjUwNTkgMzQuNjU5OSA5LjQwMTczIDM0LjQ2NDZDOS4yOTc1NyAzNC4yNjkzIDkuMjE4MTQgMzQuMDMyMyA5LjE2MzQ1IDMzLjc1MzdDOS4xMDg3NyAzMy40NzUgOS4wODE0MiAzMy4xNDk1IDkuMDgxNDIgMzIuNzc3MVYzMS45MDk5QzkuMDgxNDIgMzEuNDQzOCA5LjEyMzA5IDMxLjA1MzEgOS4yMDY0MiAzMC43MzhDOS4yOTIzNiAzMC40MjI5IDkuNDEzNDUgMzAuMTcwMyA5LjU2OTcgMjkuOTgwMkM5LjcyNTk1IDI5Ljc4NzUgOS45MTM0NSAyOS42NDk1IDEwLjEzMjIgMjkuNTY2MkMxMC4zNTM2IDI5LjQ4MjggMTAuNjAxIDI5LjQ0MTIgMTAuODc0NCAyOS40NDEyQzExLjA5NTcgMjkuNDQxMiAxMS4yOTg5IDI5LjQ2ODUgMTEuNDgzOCAyOS41MjMyQzExLjY3MTMgMjkuNTc1MyAxMS44Mzc5IDI5LjY1OTkgMTEuOTgzOCAyOS43NzcxQzEyLjEyOTYgMjkuODkxNyAxMi4yNTMzIDMwLjA0NTMgMTIuMzU0OSAzMC4yMzhDMTIuNDU5IDMwLjQyODEgMTIuNTM4NSAzMC42NjEyIDEyLjU5MzEgMzAuOTM3M0MxMi42NDc4IDMxLjIxMzMgMTIuNjc1MiAzMS41Mzc1IDEyLjY3NTIgMzEuOTA5OVpNMTEuOTQ4NiAzMi44OTQzVjMxLjc4ODhDMTEuOTQ4NiAzMS41MzM2IDExLjkzMyAzMS4zMDk3IDExLjkwMTcgMzEuMTE2OUMxMS44NzMxIDMwLjkyMTYgMTEuODMwMSAzMC43NTUgMTEuNzcyOCAzMC42MTY5QzExLjcxNTUgMzAuNDc4OSAxMS42NDI2IDMwLjM2NjkgMTEuNTU0MSAzMC4yODFDMTEuNDY4MSAzMC4xOTUxIDExLjM2NzkgMzAuMTMyNiAxMS4yNTMzIDMwLjA5MzVDMTEuMTQxMyAzMC4wNTE4IDExLjAxNSAzMC4wMzEgMTAuODc0NCAzMC4wMzFDMTAuNzAyNSAzMC4wMzEgMTAuNTUwMiAzMC4wNjM2IDEwLjQxNzQgMzAuMTI4N0MxMC4yODQ1IDMwLjE5MTIgMTAuMTcyNiAzMC4yOTE0IDEwLjA4MTQgMzAuNDI5NEM5Ljk5Mjg4IDMwLjU2NzUgOS45MjUxNyAzMC43NDg1IDkuODc4MyAzMC45NzI0QzkuODMxNDIgMzEuMTk2NCA5LjgwNzk4IDMxLjQ2ODUgOS44MDc5OCAzMS43ODg4VjMyLjg5NDNDOS44MDc5OCAzMy4xNDk1IDkuODIyMzEgMzMuMzc0OCA5Ljg1MDk1IDMzLjU3MDFDOS44ODIyIDMzLjc2NTQgOS45Mjc3OCAzMy45MzQ3IDkuOTg3NjcgMzQuMDc3OUMxMC4wNDc2IDM0LjIxODUgMTAuMTIwNSAzNC4zMzQ0IDEwLjIwNjQgMzQuNDI1NUMxMC4yOTI0IDM0LjUxNjcgMTAuMzkxMyAzNC41ODQ0IDEwLjUwMzMgMzQuNjI4N0MxMC42MTc5IDM0LjY3MDMgMTAuNzQ0MiAzNC42OTEyIDEwLjg4MjIgMzQuNjkxMkMxMS4wNTkzIDM0LjY5MTIgMTEuMjE0MiAzNC42NTczIDExLjM0NyAzNC41ODk2QzExLjQ3OTkgMzQuNTIxOSAxMS41OTA1IDM0LjQxNjQgMTEuNjc5MSAzNC4yNzMyQzExLjc3MDIgMzQuMTI3NCAxMS44Mzc5IDMzLjk0MTIgMTEuODgyMiAzMy43MTQ2QzExLjkyNjUgMzMuNDg1NCAxMS45NDg2IDMzLjIxMiAxMS45NDg2IDMyLjg5NDNaTTEzLjY3NDYgMzAuOTEzOFYzMC42MTNDMTMuNjc0NiAzMC4zOTY5IDEzLjcyMTQgMzAuMjAwMyAxMy44MTUyIDMwLjAyMzJDMTMuOTA4OSAyOS44NDYxIDE0LjA0MzEgMjkuNzA0MiAxNC4yMTc1IDI5LjU5NzRDMTQuMzkyIDI5LjQ5MDYgMTQuNTk5IDI5LjQzNzMgMTQuODM4NiAyOS40MzczQzE1LjA4MzQgMjkuNDM3MyAxNS4yOTE4IDI5LjQ5MDYgMTUuNDYzNiAyOS41OTc0QzE1LjYzODEgMjkuNzA0MiAxNS43NzIyIDI5Ljg0NjEgMTUuODY2IDMwLjAyMzJDMTUuOTU5NyAzMC4yMDAzIDE2LjAwNjYgMzAuMzk2OSAxNi4wMDY2IDMwLjYxM1YzMC45MTM4QzE2LjAwNjYgMzEuMTI0OCAxNS45NTk3IDMxLjMxODggMTUuODY2IDMxLjQ5NThDMTUuNzc0OCAzMS42NzI5IDE1LjY0MiAzMS44MTQ5IDE1LjQ2NzUgMzEuOTIxNkMxNS4yOTU3IDMyLjAyODQgMTUuMDg4NiAzMi4wODE4IDE0Ljg0NjQgMzIuMDgxOEMxNC42MDQzIDMyLjA4MTggMTQuMzk0NiAzMi4wMjg0IDE0LjIxNzUgMzEuOTIxNkMxNC4wNDMxIDMxLjgxNDkgMTMuOTA4OSAzMS42NzI5IDEzLjgxNTIgMzEuNDk1OEMxMy43MjE0IDMxLjMxODggMTMuNjc0NiAzMS4xMjQ4IDEzLjY3NDYgMzAuOTEzOFpNMTQuMjE3NSAzMC42MTNWMzAuOTEzOEMxNC4yMTc1IDMxLjAzMzYgMTQuMjM5NyAzMS4xNDY5IDE0LjI4MzkgMzEuMjUzN0MxNC4zMzA4IDMxLjM2MDQgMTQuNDAxMSAzMS40NDc3IDE0LjQ5NDkgMzEuNTE1NEMxNC41ODg2IDMxLjU4MDUgMTQuNzA1OCAzMS42MTMgMTQuODQ2NCAzMS42MTNDMTQuOTg3MSAzMS42MTMgMTUuMTAyOSAzMS41ODA1IDE1LjE5NDEgMzEuNTE1NEMxNS4yODUyIDMxLjQ0NzcgMTUuMzUyOSAzMS4zNjA0IDE1LjM5NzIgMzEuMjUzN0MxNS40NDE1IDMxLjE0NjkgMTUuNDYzNiAzMS4wMzM2IDE1LjQ2MzYgMzAuOTEzOFYzMC42MTNDMTUuNDYzNiAzMC40OTA2IDE1LjQ0MDIgMzAuMzc2MSAxNS4zOTMzIDMwLjI2OTNDMTUuMzQ5IDMwLjE1OTkgMTUuMjggMzAuMDcyNyAxNS4xODYzIDMwLjAwNzZDMTUuMDk1MSAyOS45Mzk5IDE0Ljk3OTMgMjkuOTA2IDE0LjgzODYgMjkuOTA2QzE0LjcwMDYgMjkuOTA2IDE0LjU4NDcgMjkuOTM5OSAxNC40OTEgMzAuMDA3NkMxNC4zOTk4IDMwLjA3MjcgMTQuMzMwOCAzMC4xNTk5IDE0LjI4MzkgMzAuMjY5M0MxNC4yMzk3IDMwLjM3NjEgMTQuMjE3NSAzMC40OTA2IDE0LjIxNzUgMzAuNjEzWk0xNi40NDQxIDM0LjExNjlWMzMuODEyM0MxNi40NDQxIDMzLjU5ODcgMTYuNDkxIDMzLjQwMzQgMTYuNTg0NyAzMy4yMjYzQzE2LjY3ODUgMzMuMDQ5MiAxNi44MTI2IDMyLjkwNzMgMTYuOTg3MSAzMi44MDA1QzE3LjE2MTUgMzIuNjkzOCAxNy4zNjg2IDMyLjY0MDQgMTcuNjA4MiAzMi42NDA0QzE3Ljg1MjkgMzIuNjQwNCAxOC4wNjEzIDMyLjY5MzggMTguMjMzMiAzMi44MDA1QzE4LjQwNzYgMzIuOTA3MyAxOC41NDE4IDMzLjA0OTIgMTguNjM1NSAzMy4yMjYzQzE4LjcyOTMgMzMuNDAzNCAxOC43NzYxIDMzLjU5ODcgMTguNzc2MSAzMy44MTIzVjM0LjExNjlDMTguNzc2MSAzNC4zMzA1IDE4LjcyOTMgMzQuNTI1OCAxOC42MzU1IDM0LjcwMjlDMTguNTQ0NCAzNC44OCAxOC40MTE1IDM1LjAyMTkgMTguMjM3MSAzNS4xMjg3QzE4LjA2NTIgMzUuMjM1NCAxNy44NTgyIDM1LjI4ODggMTcuNjE2IDM1LjI4ODhDMTcuMzczOCAzNS4yODg4IDE3LjE2NTQgMzUuMjM1NCAxNi45OTEgMzUuMTI4N0MxNi44MTY1IDM1LjAyMTkgMTYuNjgxMSAzNC44OCAxNi41ODQ3IDM0LjcwMjlDMTYuNDkxIDM0LjUyNTggMTYuNDQ0MSAzNC4zMzA1IDE2LjQ0NDEgMzQuMTE2OVpNMTYuOTg3MSAzMy44MTIzVjM0LjExNjlDMTYuOTg3MSAzNC4yMzY3IDE3LjAwOTIgMzQuMzUxMyAxNy4wNTM1IDM0LjQ2MDdDMTcuMTAwMyAzNC41Njc1IDE3LjE3MDcgMzQuNjU0NyAxNy4yNjQ0IDM0LjcyMjRDMTcuMzU4MiAzNC43ODc1IDE3LjQ3NTMgMzQuODIwMSAxNy42MTYgMzQuODIwMUMxNy43NTY2IDM0LjgyMDEgMTcuODcyNSAzNC43ODc1IDE3Ljk2MzYgMzQuNzIyNEMxOC4wNTc0IDM0LjY1NDcgMTguMTI2NCAzNC41Njc1IDE4LjE3MDcgMzQuNDYwN0MxOC4yMTQ5IDM0LjM1MzkgMTguMjM3MSAzNC4yMzkzIDE4LjIzNzEgMzQuMTE2OVYzMy44MTIzQzE4LjIzNzEgMzMuNjg5OSAxOC4yMTM2IDMzLjU3NTMgMTguMTY2OCAzMy40Njg1QzE4LjEyMjUgMzMuMzYxNyAxOC4wNTM1IDMzLjI3NTggMTcuOTU5NyAzMy4yMTA3QzE3Ljg2ODYgMzMuMTQzIDE3Ljc1MTQgMzMuMTA5MSAxNy42MDgyIDMzLjEwOTFDMTcuNDcwMSAzMy4xMDkxIDE3LjM1NDMgMzMuMTQzIDE3LjI2MDUgMzMuMjEwN0MxNy4xNjk0IDMzLjI3NTggMTcuMTAwMyAzMy4zNjE3IDE3LjA1MzUgMzMuNDY4NUMxNy4wMDkyIDMzLjU3NTMgMTYuOTg3MSAzMy42ODk5IDE2Ljk4NzEgMzMuODEyM1pNMTcuNzg3OCAzMC4zMjc5TDE1LjAxMDUgMzQuNzczMkwxNC42MDQzIDM0LjUxNTRMMTcuMzgxNiAzMC4wNzAxTDE3Ljc4NzggMzAuMzI3OVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPHBhdGggZD0iTTcuMjQ2MDkgNTcuNzE4M0g3LjMwODU5VjU4LjMzMTVINy4yNDYwOUM2Ljg2MzI4IDU4LjMzMTUgNi41NDI5NyA1OC4zOTQgNi4yODUxNiA1OC41MTlDNi4wMjczNCA1OC42NDE0IDUuODIyOTIgNTguODA2OCA1LjY3MTg4IDU5LjAxNTFDNS41MjA4MyA1OS4yMjA5IDUuNDExNDYgNTkuNDUyNiA1LjM0Mzc1IDU5LjcxMDRDNS4yNzg2NSA1OS45NjgzIDUuMjQ2MDkgNjAuMjMgNS4yNDYwOSA2MC40OTU2VjYxLjMzMTVDNS4yNDYwOSA2MS41ODQxIDUuMjc2MDQgNjEuODA4MSA1LjMzNTk0IDYyLjAwMzRDNS4zOTU4MyA2Mi4xOTYxIDUuNDc3ODYgNjIuMzU4OSA1LjU4MjAzIDYyLjQ5MTdDNS42ODYyIDYyLjYyNDUgNS44MDMzOSA2Mi43MjQ4IDUuOTMzNTkgNjIuNzkyNUM2LjA2NjQxIDYyLjg2MDIgNi4yMDQ0MyA2Mi44OTQgNi4zNDc2NiA2Mi44OTRDNi41MTQzMiA2Mi44OTQgNi42NjI3NiA2Mi44NjI4IDYuNzkyOTcgNjIuODAwM0M2LjkyMzE4IDYyLjczNTIgNy4wMzI1NSA2Mi42NDUzIDcuMTIxMDkgNjIuNTMwOEM3LjIxMjI0IDYyLjQxMzYgNy4yODEyNSA2Mi4yNzU2IDcuMzI4MTIgNjIuMTE2N0M3LjM3NSA2MS45NTc4IDcuMzk4NDQgNjEuNzgzNCA3LjM5ODQ0IDYxLjU5MzNDNy4zOTg0NCA2MS40MjQgNy4zNzc2IDYxLjI2MTIgNy4zMzU5NCA2MS4xMDVDNy4yOTQyNyA2MC45NDYxIDcuMjMwNDcgNjAuODA1NSA3LjE0NDUzIDYwLjY4MzFDNy4wNTg1OSA2MC41NTgxIDYuOTUwNTIgNjAuNDYwNCA2LjgyMDMxIDYwLjM5MDFDNi42OTI3MSA2MC4zMTcyIDYuNTQwMzYgNjAuMjgwOCA2LjM2MzI4IDYwLjI4MDhDNi4xNjI3NiA2MC4yODA4IDUuOTc1MjYgNjAuMzMwMiA1LjgwMDc4IDYwLjQyOTJDNS42Mjg5MSA2MC41MjU2IDUuNDg2OTggNjAuNjUzMiA1LjM3NSA2MC44MTJDNS4yNjU2MiA2MC45NjgzIDUuMjAzMTIgNjEuMTM4OCA1LjE4NzUgNjEuMzIzN0w0LjgwNDY5IDYxLjMxOThDNC44NDExNSA2MS4wMjgyIDQuOTA4ODUgNjAuNzc5NSA1LjAwNzgxIDYwLjU3MzdDNS4xMDkzOCA2MC4zNjU0IDUuMjM0MzggNjAuMTk2MSA1LjM4MjgxIDYwLjA2NTlDNS41MzM4NSA1OS45MzMxIDUuNzAxODIgNTkuODM2OCA1Ljg4NjcyIDU5Ljc3NjlDNi4wNzQyMiA1OS43MTQ0IDYuMjcyMTQgNTkuNjgzMSA2LjQ4MDQ3IDU5LjY4MzFDNi43NjQzMiA1OS42ODMxIDcuMDA5MTEgNTkuNzM2NSA3LjIxNDg0IDU5Ljg0MzNDNy40MjA1NyA1OS45NSA3LjU4OTg0IDYwLjA5MzMgNy43MjI2NiA2MC4yNzI5QzcuODU1NDcgNjAuNDUgNy45NTMxMiA2MC42NTA2IDguMDE1NjIgNjAuODc0NUM4LjA4MDczIDYxLjA5NTkgOC4xMTMyOCA2MS4zMjM3IDguMTEzMjggNjEuNTU4MUM4LjExMzI4IDYxLjgyNjMgOC4wNzU1MiA2Mi4wNzc2IDggNjIuMzEyQzcuOTI0NDggNjIuNTQ2NCA3LjgxMTIgNjIuNzUyMSA3LjY2MDE2IDYyLjkyOTJDNy41MTE3MiA2My4xMDYzIDcuMzI4MTIgNjMuMjQ0MyA3LjEwOTM4IDYzLjM0MzNDNi44OTA2MiA2My40NDIyIDYuNjM2NzIgNjMuNDkxNyA2LjM0NzY2IDYzLjQ5MTdDNi4wNDAzNiA2My40OTE3IDUuNzcyMTQgNjMuNDI5MiA1LjU0Mjk3IDYzLjMwNDJDNS4zMTM4IDYzLjE3NjYgNS4xMjM3IDYzLjAwNzMgNC45NzI2NiA2Mi43OTY0QzQuODIxNjEgNjIuNTg1NCA0LjcwODMzIDYyLjM1MTEgNC42MzI4MSA2Mi4wOTMzQzQuNTU3MjkgNjEuODM1NCA0LjUxOTUzIDYxLjU3MzcgNC41MTk1MyA2MS4zMDgxVjYwLjk2ODNDNC41MTk1MyA2MC41NjcyIDQuNTU5OSA2MC4xNzQgNC42NDA2MiA1OS43ODg2QzQuNzIxMzUgNTkuNDAzMiA0Ljg2MDY4IDU5LjA1NDIgNS4wNTg1OSA1OC43NDE3QzUuMjU5MTEgNTguNDI5MiA1LjUzNjQ2IDU4LjE4MDUgNS44OTA2MiA1Ny45OTU2QzYuMjQ0NzkgNTcuODEwNyA2LjY5NjYxIDU3LjcxODMgNy4yNDYwOSA1Ny43MTgzWk0xMi42NzUyIDYwLjExNjdWNjAuOTgzOUMxMi42NzUyIDYxLjQ1IDEyLjYzMzUgNjEuODQzMyAxMi41NTAyIDYyLjE2MzZDMTIuNDY2OCA2Mi40ODM5IDEyLjM0NyA2Mi43NDE3IDEyLjE5MDggNjIuOTM3QzEyLjAzNDUgNjMuMTMyMyAxMS44NDU3IDYzLjI3NDMgMTEuNjI0NCA2My4zNjI4QzExLjQwNTYgNjMuNDQ4NyAxMS4xNTgyIDYzLjQ5MTcgMTAuODgyMiA2My40OTE3QzEwLjY2MzUgNjMuNDkxNyAxMC40NjE2IDYzLjQ2NDQgMTAuMjc2NyA2My40MDk3QzEwLjA5MTggNjMuMzU1IDkuOTI1MTcgNjMuMjY3NyA5Ljc3NjczIDYzLjE0NzlDOS42MzA5IDYzLjAyNTYgOS41MDU5IDYyLjg2NjcgOS40MDE3MyA2Mi42NzE0QzkuMjk3NTcgNjIuNDc2MSA5LjIxODE0IDYyLjIzOTEgOS4xNjM0NSA2MS45NjA0QzkuMTA4NzcgNjEuNjgxOCA5LjA4MTQyIDYxLjM1NjMgOS4wODE0MiA2MC45ODM5VjYwLjExNjdDOS4wODE0MiA1OS42NTA2IDkuMTIzMDkgNTkuMjU5OSA5LjIwNjQyIDU4Ljk0NDhDOS4yOTIzNiA1OC42Mjk3IDkuNDEzNDUgNTguMzc3MSA5LjU2OTcgNTguMTg3QzkuNzI1OTUgNTcuOTk0MyA5LjkxMzQ1IDU3Ljg1NjMgMTAuMTMyMiA1Ny43NzI5QzEwLjM1MzYgNTcuNjg5NiAxMC42MDEgNTcuNjQ3OSAxMC44NzQ0IDU3LjY0NzlDMTEuMDk1NyA1Ny42NDc5IDExLjI5ODkgNTcuNjc1MyAxMS40ODM4IDU3LjczQzExLjY3MTMgNTcuNzgyMSAxMS44Mzc5IDU3Ljg2NjcgMTEuOTgzOCA1Ny45ODM5QzEyLjEyOTYgNTguMDk4NSAxMi4yNTMzIDU4LjI1MjEgMTIuMzU0OSA1OC40NDQ4QzEyLjQ1OSA1OC42MzQ5IDEyLjUzODUgNTguODY4IDEyLjU5MzEgNTkuMTQ0QzEyLjY0NzggNTkuNDIwMSAxMi42NzUyIDU5Ljc0NDMgMTIuNjc1MiA2MC4xMTY3Wk0xMS45NDg2IDYxLjEwMTFWNTkuOTk1NkMxMS45NDg2IDU5Ljc0MDQgMTEuOTMzIDU5LjUxNjQgMTEuOTAxNyA1OS4zMjM3QzExLjg3MzEgNTkuMTI4NCAxMS44MzAxIDU4Ljk2MTggMTEuNzcyOCA1OC44MjM3QzExLjcxNTUgNTguNjg1NyAxMS42NDI2IDU4LjU3MzcgMTEuNTU0MSA1OC40ODc4QzExLjQ2ODEgNTguNDAxOSAxMS4zNjc5IDU4LjMzOTQgMTEuMjUzMyA1OC4zMDAzQzExLjE0MTMgNTguMjU4NiAxMS4wMTUgNTguMjM3OCAxMC44NzQ0IDU4LjIzNzhDMTAuNzAyNSA1OC4yMzc4IDEwLjU1MDIgNTguMjcwMyAxMC40MTc0IDU4LjMzNTRDMTAuMjg0NSA1OC4zOTc5IDEwLjE3MjYgNTguNDk4MiAxMC4wODE0IDU4LjYzNjJDOS45OTI4OCA1OC43NzQzIDkuOTI1MTcgNTguOTU1MiA5Ljg3ODMgNTkuMTc5MkM5LjgzMTQyIDU5LjQwMzIgOS44MDc5OCA1OS42NzUzIDkuODA3OTggNTkuOTk1NlY2MS4xMDExQzkuODA3OTggNjEuMzU2MyA5LjgyMjMxIDYxLjU4MTUgOS44NTA5NSA2MS43NzY5QzkuODgyMiA2MS45NzIyIDkuOTI3NzggNjIuMTQxNCA5Ljk4NzY3IDYyLjI4NDdDMTAuMDQ3NiA2Mi40MjUzIDEwLjEyMDUgNjIuNTQxMiAxMC4yMDY0IDYyLjYzMjNDMTAuMjkyNCA2Mi43MjM1IDEwLjM5MTMgNjIuNzkxMiAxMC41MDMzIDYyLjgzNTRDMTAuNjE3OSA2Mi44NzcxIDEwLjc0NDIgNjIuODk3OSAxMC44ODIyIDYyLjg5NzlDMTEuMDU5MyA2Mi44OTc5IDExLjIxNDIgNjIuODY0MSAxMS4zNDcgNjIuNzk2NEMxMS40Nzk5IDYyLjcyODcgMTEuNTkwNSA2Mi42MjMyIDExLjY3OTEgNjIuNDhDMTEuNzcwMiA2Mi4zMzQxIDExLjgzNzkgNjIuMTQ3OSAxMS44ODIyIDYxLjkyMTRDMTEuOTI2NSA2MS42OTIyIDExLjk0ODYgNjEuNDE4OCAxMS45NDg2IDYxLjEwMTFaTTEzLjY3NDYgNTkuMTIwNlY1OC44MTk4QzEzLjY3NDYgNTguNjAzNyAxMy43MjE0IDU4LjQwNzEgMTMuODE1MiA1OC4yM0MxMy45MDg5IDU4LjA1MjkgMTQuMDQzMSA1Ny45MTEgMTQuMjE3NSA1Ny44MDQyQzE0LjM5MiA1Ny42OTc0IDE0LjU5OSA1Ny42NDQgMTQuODM4NiA1Ny42NDRDMTUuMDgzNCA1Ny42NDQgMTUuMjkxOCA1Ny42OTc0IDE1LjQ2MzYgNTcuODA0MkMxNS42MzgxIDU3LjkxMSAxNS43NzIyIDU4LjA1MjkgMTUuODY2IDU4LjIzQzE1Ljk1OTcgNTguNDA3MSAxNi4wMDY2IDU4LjYwMzcgMTYuMDA2NiA1OC44MTk4VjU5LjEyMDZDMTYuMDA2NiA1OS4zMzE1IDE1Ljk1OTcgNTkuNTI1NiAxNS44NjYgNTkuNzAyNkMxNS43NzQ4IDU5Ljg3OTcgMTUuNjQyIDYwLjAyMTYgMTUuNDY3NSA2MC4xMjg0QzE1LjI5NTcgNjAuMjM1MiAxNS4wODg2IDYwLjI4ODYgMTQuODQ2NCA2MC4yODg2QzE0LjYwNDMgNjAuMjg4NiAxNC4zOTQ2IDYwLjIzNTIgMTQuMjE3NSA2MC4xMjg0QzE0LjA0MzEgNjAuMDIxNiAxMy45MDg5IDU5Ljg3OTcgMTMuODE1MiA1OS43MDI2QzEzLjcyMTQgNTkuNTI1NiAxMy42NzQ2IDU5LjMzMTUgMTMuNjc0NiA1OS4xMjA2Wk0xNC4yMTc1IDU4LjgxOThWNTkuMTIwNkMxNC4yMTc1IDU5LjI0MDQgMTQuMjM5NyA1OS4zNTM3IDE0LjI4MzkgNTkuNDYwNEMxNC4zMzA4IDU5LjU2NzIgMTQuNDAxMSA1OS42NTQ1IDE0LjQ5NDkgNTkuNzIyMkMxNC41ODg2IDU5Ljc4NzMgMTQuNzA1OCA1OS44MTk4IDE0Ljg0NjQgNTkuODE5OEMxNC45ODcxIDU5LjgxOTggMTUuMTAyOSA1OS43ODczIDE1LjE5NDEgNTkuNzIyMkMxNS4yODUyIDU5LjY1NDUgMTUuMzUyOSA1OS41NjcyIDE1LjM5NzIgNTkuNDYwNEMxNS40NDE1IDU5LjM1MzcgMTUuNDYzNiA1OS4yNDA0IDE1LjQ2MzYgNTkuMTIwNlY1OC44MTk4QzE1LjQ2MzYgNTguNjk3NCAxNS40NDAyIDU4LjU4MjggMTUuMzkzMyA1OC40NzYxQzE1LjM0OSA1OC4zNjY3IDE1LjI4IDU4LjI3OTUgMTUuMTg2MyA1OC4yMTQ0QzE1LjA5NTEgNTguMTQ2NiAxNC45NzkzIDU4LjExMjggMTQuODM4NiA1OC4xMTI4QzE0LjcwMDYgNTguMTEyOCAxNC41ODQ3IDU4LjE0NjYgMTQuNDkxIDU4LjIxNDRDMTQuMzk5OCA1OC4yNzk1IDE0LjMzMDggNTguMzY2NyAxNC4yODM5IDU4LjQ3NjFDMTQuMjM5NyA1OC41ODI4IDE0LjIxNzUgNTguNjk3NCAxNC4yMTc1IDU4LjgxOThaTTE2LjQ0NDEgNjIuMzIzN1Y2Mi4wMTlDMTYuNDQ0MSA2MS44MDU1IDE2LjQ5MSA2MS42MTAyIDE2LjU4NDcgNjEuNDMzMUMxNi42Nzg1IDYxLjI1NiAxNi44MTI2IDYxLjExNDEgMTYuOTg3MSA2MS4wMDczQzE3LjE2MTUgNjAuOTAwNiAxNy4zNjg2IDYwLjg0NzIgMTcuNjA4MiA2MC44NDcyQzE3Ljg1MjkgNjAuODQ3MiAxOC4wNjEzIDYwLjkwMDYgMTguMjMzMiA2MS4wMDczQzE4LjQwNzYgNjEuMTE0MSAxOC41NDE4IDYxLjI1NiAxOC42MzU1IDYxLjQzMzFDMTguNzI5MyA2MS42MTAyIDE4Ljc3NjEgNjEuODA1NSAxOC43NzYxIDYyLjAxOVY2Mi4zMjM3QzE4Ljc3NjEgNjIuNTM3MyAxOC43MjkzIDYyLjczMjYgMTguNjM1NSA2Mi45MDk3QzE4LjU0NDQgNjMuMDg2OCAxOC40MTE1IDYzLjIyODcgMTguMjM3MSA2My4zMzU0QzE4LjA2NTIgNjMuNDQyMiAxNy44NTgyIDYzLjQ5NTYgMTcuNjE2IDYzLjQ5NTZDMTcuMzczOCA2My40OTU2IDE3LjE2NTQgNjMuNDQyMiAxNi45OTEgNjMuMzM1NEMxNi44MTY1IDYzLjIyODcgMTYuNjgxMSA2My4wODY4IDE2LjU4NDcgNjIuOTA5N0MxNi40OTEgNjIuNzMyNiAxNi40NDQxIDYyLjUzNzMgMTYuNDQ0MSA2Mi4zMjM3Wk0xNi45ODcxIDYyLjAxOVY2Mi4zMjM3QzE2Ljk4NzEgNjIuNDQzNSAxNy4wMDkyIDYyLjU1ODEgMTcuMDUzNSA2Mi42Njc1QzE3LjEwMDMgNjIuNzc0MyAxNy4xNzA3IDYyLjg2MTUgMTcuMjY0NCA2Mi45MjkyQzE3LjM1ODIgNjIuOTk0MyAxNy40NzUzIDYzLjAyNjkgMTcuNjE2IDYzLjAyNjlDMTcuNzU2NiA2My4wMjY5IDE3Ljg3MjUgNjIuOTk0MyAxNy45NjM2IDYyLjkyOTJDMTguMDU3NCA2Mi44NjE1IDE4LjEyNjQgNjIuNzc0MyAxOC4xNzA3IDYyLjY2NzVDMTguMjE0OSA2Mi41NjA3IDE4LjIzNzEgNjIuNDQ2MSAxOC4yMzcxIDYyLjMyMzdWNjIuMDE5QzE4LjIzNzEgNjEuODk2NiAxOC4yMTM2IDYxLjc4MjEgMTguMTY2OCA2MS42NzUzQzE4LjEyMjUgNjEuNTY4NSAxOC4wNTM1IDYxLjQ4MjYgMTcuOTU5NyA2MS40MTc1QzE3Ljg2ODYgNjEuMzQ5OCAxNy43NTE0IDYxLjMxNTkgMTcuNjA4MiA2MS4zMTU5QzE3LjQ3MDEgNjEuMzE1OSAxNy4zNTQzIDYxLjM0OTggMTcuMjYwNSA2MS40MTc1QzE3LjE2OTQgNjEuNDgyNiAxNy4xMDAzIDYxLjU2ODUgMTcuMDUzNSA2MS42NzUzQzE3LjAwOTIgNjEuNzgyMSAxNi45ODcxIDYxLjg5NjYgMTYuOTg3MSA2Mi4wMTlaTTE3Ljc4NzggNTguNTM0N0wxNS4wMTA1IDYyLjk4TDE0LjYwNDMgNjIuNzIyMkwxNy4zODE2IDU4LjI3NjlMMTcuNzg3OCA1OC41MzQ3WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8cGF0aCBkPSJNOC4zMTY0MSA4OS43MDYzVjkwLjNINC4yMDcwM1Y4OS44NzQzTDYuNzUzOTEgODUuOTMyOUg3LjM0Mzc1TDYuNzEwOTQgODcuMDczNUw1LjAyNzM0IDg5LjcwNjNIOC4zMTY0MVpNNy41MjM0NCA4NS45MzI5VjkxLjYyMDRINi44MDA3OFY4NS45MzI5SDcuNTIzNDRaTTEyLjY3NTIgODguMzIzNVY4OS4xOTA3QzEyLjY3NTIgODkuNjU2OCAxMi42MzM1IDkwLjA1IDEyLjU1MDIgOTAuMzcwNEMxMi40NjY4IDkwLjY5MDcgMTIuMzQ3IDkwLjk0ODUgMTIuMTkwOCA5MS4xNDM4QzEyLjAzNDUgOTEuMzM5MSAxMS44NDU3IDkxLjQ4MSAxMS42MjQ0IDkxLjU2OTZDMTEuNDA1NiA5MS42NTU1IDExLjE1ODIgOTEuNjk4NSAxMC44ODIyIDkxLjY5ODVDMTAuNjYzNSA5MS42OTg1IDEwLjQ2MTYgOTEuNjcxMSAxMC4yNzY3IDkxLjYxNjVDMTAuMDkxOCA5MS41NjE4IDkuOTI1MTcgOTEuNDc0NSA5Ljc3NjczIDkxLjM1NDdDOS42MzA5IDkxLjIzMjMgOS41MDU5IDkxLjA3MzUgOS40MDE3MyA5MC44NzgyQzkuMjk3NTcgOTAuNjgyOSA5LjIxODE0IDkwLjQ0NTkgOS4xNjM0NSA5MC4xNjcyQzkuMTA4NzcgODkuODg4NiA5LjA4MTQyIDg5LjU2MzEgOS4wODE0MiA4OS4xOTA3Vjg4LjMyMzVDOS4wODE0MiA4Ny44NTczIDkuMTIzMDkgODcuNDY2NyA5LjIwNjQyIDg3LjE1MTZDOS4yOTIzNiA4Ni44MzY1IDkuNDEzNDUgODYuNTgzOSA5LjU2OTcgODYuMzkzOEM5LjcyNTk1IDg2LjIwMTEgOS45MTM0NSA4Ni4wNjMxIDEwLjEzMjIgODUuOTc5N0MxMC4zNTM2IDg1Ljg5NjQgMTAuNjAxIDg1Ljg1NDcgMTAuODc0NCA4NS44NTQ3QzExLjA5NTcgODUuODU0NyAxMS4yOTg5IDg1Ljg4MjEgMTEuNDgzOCA4NS45MzY4QzExLjY3MTMgODUuOTg4OSAxMS44Mzc5IDg2LjA3MzUgMTEuOTgzOCA4Ni4xOTA3QzEyLjEyOTYgODYuMzA1MyAxMi4yNTMzIDg2LjQ1ODkgMTIuMzU0OSA4Ni42NTE2QzEyLjQ1OSA4Ni44NDE3IDEyLjUzODUgODcuMDc0OCAxMi41OTMxIDg3LjM1MDhDMTIuNjQ3OCA4Ny42MjY5IDEyLjY3NTIgODcuOTUxMSAxMi42NzUyIDg4LjMyMzVaTTExLjk0ODYgODkuMzA3OVY4OC4yMDI0QzExLjk0ODYgODcuOTQ3MiAxMS45MzMgODcuNzIzMiAxMS45MDE3IDg3LjUzMDVDMTEuODczMSA4Ny4zMzUyIDExLjgzMDEgODcuMTY4NSAxMS43NzI4IDg3LjAzMDVDMTEuNzE1NSA4Ni44OTI1IDExLjY0MjYgODYuNzgwNSAxMS41NTQxIDg2LjY5NDZDMTEuNDY4MSA4Ni42MDg2IDExLjM2NzkgODYuNTQ2MSAxMS4yNTMzIDg2LjUwNzFDMTEuMTQxMyA4Ni40NjU0IDExLjAxNSA4Ni40NDQ2IDEwLjg3NDQgODYuNDQ0NkMxMC43MDI1IDg2LjQ0NDYgMTAuNTUwMiA4Ni40NzcxIDEwLjQxNzQgODYuNTQyMkMxMC4yODQ1IDg2LjYwNDcgMTAuMTcyNiA4Ni43MDUgMTAuMDgxNCA4Ni44NDNDOS45OTI4OCA4Ni45ODEgOS45MjUxNyA4Ny4xNjIgOS44NzgzIDg3LjM4NkM5LjgzMTQyIDg3LjYwOTkgOS44MDc5OCA4Ny44ODIxIDkuODA3OTggODguMjAyNFY4OS4zMDc5QzkuODA3OTggODkuNTYzMSA5LjgyMjMxIDg5Ljc4ODMgOS44NTA5NSA4OS45ODM2QzkuODgyMiA5MC4xNzkgOS45Mjc3OCA5MC4zNDgyIDkuOTg3NjcgOTAuNDkxNUMxMC4wNDc2IDkwLjYzMjEgMTAuMTIwNSA5MC43NDggMTAuMjA2NCA5MC44MzkxQzEwLjI5MjQgOTAuOTMwMyAxMC4zOTEzIDkwLjk5OCAxMC41MDMzIDkxLjA0MjJDMTAuNjE3OSA5MS4wODM5IDEwLjc0NDIgOTEuMTA0NyAxMC44ODIyIDkxLjEwNDdDMTEuMDU5MyA5MS4xMDQ3IDExLjIxNDIgOTEuMDcwOSAxMS4zNDcgOTEuMDAzMkMxMS40Nzk5IDkwLjkzNTUgMTEuNTkwNSA5MC44MyAxMS42NzkxIDkwLjY4NjhDMTEuNzcwMiA5MC41NDA5IDExLjgzNzkgOTAuMzU0NyAxMS44ODIyIDkwLjEyODJDMTEuOTI2NSA4OS44OTkgMTEuOTQ4NiA4OS42MjU2IDExLjk0ODYgODkuMzA3OVpNMTMuNjc0NiA4Ny4zMjc0Vjg3LjAyNjZDMTMuNjc0NiA4Ni44MTA1IDEzLjcyMTQgODYuNjEzOSAxMy44MTUyIDg2LjQzNjhDMTMuOTA4OSA4Ni4yNTk3IDE0LjA0MzEgODYuMTE3OCAxNC4yMTc1IDg2LjAxMUMxNC4zOTIgODUuOTA0MiAxNC41OTkgODUuODUwOCAxNC44Mzg2IDg1Ljg1MDhDMTUuMDgzNCA4NS44NTA4IDE1LjI5MTggODUuOTA0MiAxNS40NjM2IDg2LjAxMUMxNS42MzgxIDg2LjExNzggMTUuNzcyMiA4Ni4yNTk3IDE1Ljg2NiA4Ni40MzY4QzE1Ljk1OTcgODYuNjEzOSAxNi4wMDY2IDg2LjgxMDUgMTYuMDA2NiA4Ny4wMjY2Vjg3LjMyNzRDMTYuMDA2NiA4Ny41MzgzIDE1Ljk1OTcgODcuNzMyMyAxNS44NjYgODcuOTA5NEMxNS43NzQ4IDg4LjA4NjUgMTUuNjQyIDg4LjIyODQgMTUuNDY3NSA4OC4zMzUyQzE1LjI5NTcgODguNDQyIDE1LjA4ODYgODguNDk1NCAxNC44NDY0IDg4LjQ5NTRDMTQuNjA0MyA4OC40OTU0IDE0LjM5NDYgODguNDQyIDE0LjIxNzUgODguMzM1MkMxNC4wNDMxIDg4LjIyODQgMTMuOTA4OSA4OC4wODY1IDEzLjgxNTIgODcuOTA5NEMxMy43MjE0IDg3LjczMjMgMTMuNjc0NiA4Ny41MzgzIDEzLjY3NDYgODcuMzI3NFpNMTQuMjE3NSA4Ny4wMjY2Vjg3LjMyNzRDMTQuMjE3NSA4Ny40NDcyIDE0LjIzOTcgODcuNTYwNSAxNC4yODM5IDg3LjY2NzJDMTQuMzMwOCA4Ny43NzQgMTQuNDAxMSA4Ny44NjEyIDE0LjQ5NDkgODcuOTI5QzE0LjU4ODYgODcuOTk0MSAxNC43MDU4IDg4LjAyNjYgMTQuODQ2NCA4OC4wMjY2QzE0Ljk4NzEgODguMDI2NiAxNS4xMDI5IDg3Ljk5NDEgMTUuMTk0MSA4Ny45MjlDMTUuMjg1MiA4Ny44NjEyIDE1LjM1MjkgODcuNzc0IDE1LjM5NzIgODcuNjY3MkMxNS40NDE1IDg3LjU2MDUgMTUuNDYzNiA4Ny40NDcyIDE1LjQ2MzYgODcuMzI3NFY4Ny4wMjY2QzE1LjQ2MzYgODYuOTA0MiAxNS40NDAyIDg2Ljc4OTYgMTUuMzkzMyA4Ni42ODI5QzE1LjM0OSA4Ni41NzM1IDE1LjI4IDg2LjQ4NjIgMTUuMTg2MyA4Ni40MjExQzE1LjA5NTEgODYuMzUzNCAxNC45NzkzIDg2LjMxOTYgMTQuODM4NiA4Ni4zMTk2QzE0LjcwMDYgODYuMzE5NiAxNC41ODQ3IDg2LjM1MzQgMTQuNDkxIDg2LjQyMTFDMTQuMzk5OCA4Ni40ODYyIDE0LjMzMDggODYuNTczNSAxNC4yODM5IDg2LjY4MjlDMTQuMjM5NyA4Ni43ODk2IDE0LjIxNzUgODYuOTA0MiAxNC4yMTc1IDg3LjAyNjZaTTE2LjQ0NDEgOTAuNTMwNVY5MC4yMjU4QzE2LjQ0NDEgOTAuMDEyMyAxNi40OTEgODkuODE3IDE2LjU4NDcgODkuNjM5OUMxNi42Nzg1IDg5LjQ2MjggMTYuODEyNiA4OS4zMjA5IDE2Ljk4NzEgODkuMjE0MUMxNy4xNjE1IDg5LjEwNzMgMTcuMzY4NiA4OS4wNTQgMTcuNjA4MiA4OS4wNTRDMTcuODUyOSA4OS4wNTQgMTguMDYxMyA4OS4xMDczIDE4LjIzMzIgODkuMjE0MUMxOC40MDc2IDg5LjMyMDkgMTguNTQxOCA4OS40NjI4IDE4LjYzNTUgODkuNjM5OUMxOC43MjkzIDg5LjgxNyAxOC43NzYxIDkwLjAxMjMgMTguNzc2MSA5MC4yMjU4VjkwLjUzMDVDMTguNzc2MSA5MC43NDQxIDE4LjcyOTMgOTAuOTM5NCAxOC42MzU1IDkxLjExNjVDMTguNTQ0NCA5MS4yOTM1IDE4LjQxMTUgOTEuNDM1NSAxOC4yMzcxIDkxLjU0MjJDMTguMDY1MiA5MS42NDkgMTcuODU4MiA5MS43MDI0IDE3LjYxNiA5MS43MDI0QzE3LjM3MzggOTEuNzAyNCAxNy4xNjU0IDkxLjY0OSAxNi45OTEgOTEuNTQyMkMxNi44MTY1IDkxLjQzNTUgMTYuNjgxMSA5MS4yOTM1IDE2LjU4NDcgOTEuMTE2NUMxNi40OTEgOTAuOTM5NCAxNi40NDQxIDkwLjc0NDEgMTYuNDQ0MSA5MC41MzA1Wk0xNi45ODcxIDkwLjIyNThWOTAuNTMwNUMxNi45ODcxIDkwLjY1MDMgMTcuMDA5MiA5MC43NjQ5IDE3LjA1MzUgOTAuODc0M0MxNy4xMDAzIDkwLjk4MSAxNy4xNzA3IDkxLjA2ODMgMTcuMjY0NCA5MS4xMzZDMTcuMzU4MiA5MS4yMDExIDE3LjQ3NTMgOTEuMjMzNiAxNy42MTYgOTEuMjMzNkMxNy43NTY2IDkxLjIzMzYgMTcuODcyNSA5MS4yMDExIDE3Ljk2MzYgOTEuMTM2QzE4LjA1NzQgOTEuMDY4MyAxOC4xMjY0IDkwLjk4MSAxOC4xNzA3IDkwLjg3NDNDMTguMjE0OSA5MC43Njc1IDE4LjIzNzEgOTAuNjUyOSAxOC4yMzcxIDkwLjUzMDVWOTAuMjI1OEMxOC4yMzcxIDkwLjEwMzQgMTguMjEzNiA4OS45ODg5IDE4LjE2NjggODkuODgyMUMxOC4xMjI1IDg5Ljc3NTMgMTguMDUzNSA4OS42ODk0IDE3Ljk1OTcgODkuNjI0M0MxNy44Njg2IDg5LjU1NjYgMTcuNzUxNCA4OS41MjI3IDE3LjYwODIgODkuNTIyN0MxNy40NzAxIDg5LjUyMjcgMTcuMzU0MyA4OS41NTY2IDE3LjI2MDUgODkuNjI0M0MxNy4xNjk0IDg5LjY4OTQgMTcuMTAwMyA4OS43NzUzIDE3LjA1MzUgODkuODgyMUMxNy4wMDkyIDg5Ljk4ODkgMTYuOTg3MSA5MC4xMDM0IDE2Ljk4NzEgOTAuMjI1OFpNMTcuNzg3OCA4Ni43NDE1TDE1LjAxMDUgOTEuMTg2OEwxNC42MDQzIDkwLjkyOUwxNy4zODE2IDg2LjQ4MzZMMTcuNzg3OCA4Ni43NDE1WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8cGF0aCBkPSJNOC4xOTkyMiAxMTkuMjMzVjExOS44MjdINC40NzY1NlYxMTkuMzA4TDYuMzM5ODQgMTE3LjIzM0M2LjU2OTAxIDExNi45NzggNi43NDYwOSAxMTYuNzYyIDYuODcxMDkgMTE2LjU4NUM2Ljk5ODcgMTE2LjQwNSA3LjA4NzI0IDExNi4yNDUgNy4xMzY3MiAxMTYuMTA0QzcuMTg4OCAxMTUuOTYxIDcuMjE0ODQgMTE1LjgxNSA3LjIxNDg0IDExNS42NjdDNy4yMTQ4NCAxMTUuNDc5IDcuMTc1NzggMTE1LjMxIDcuMDk3NjYgMTE1LjE1OUM3LjAyMjE0IDExNS4wMDYgNi45MTAxNiAxMTQuODgzIDYuNzYxNzIgMTE0Ljc5MkM2LjYxMzI4IDExNC43MDEgNi40MzM1OSAxMTQuNjU1IDYuMjIyNjYgMTE0LjY1NUM1Ljk3MDA1IDExNC42NTUgNS43NTkxMSAxMTQuNzA1IDUuNTg5ODQgMTE0LjgwNEM1LjQyMzE4IDExNC45IDUuMjk4MTggMTE1LjAzNSA1LjIxNDg0IDExNS4yMUM1LjEzMTUxIDExNS4zODQgNS4wODk4NCAxMTUuNTg1IDUuMDg5ODQgMTE1LjgxMkg0LjM2NzE5QzQuMzY3MTkgMTE1LjQ5MSA0LjQzNzUgMTE1LjE5OCA0LjU3ODEyIDExNC45MzNDNC43MTg3NSAxMTQuNjY3IDQuOTI3MDggMTE0LjQ1NiA1LjIwMzEyIDExNC4zQzUuNDc5MTcgMTE0LjE0MSA1LjgxOTAxIDExNC4wNjIgNi4yMjI2NiAxMTQuMDYyQzYuNTgyMDMgMTE0LjA2MiA2Ljg4OTMyIDExNC4xMjUgNy4xNDQ1MyAxMTQuMjUzQzcuMzk5NzQgMTE0LjM3OCA3LjU5NTA1IDExNC41NTUgNy43MzA0NyAxMTQuNzg0QzcuODY4NDkgMTE1LjAxMSA3LjkzNzUgMTE1LjI3NiA3LjkzNzUgMTE1LjU4MUM3LjkzNzUgMTE1Ljc0OCA3LjkwODg1IDExNS45MTcgNy44NTE1NiAxMTYuMDg5QzcuNzk2ODggMTE2LjI1OCA3LjcyMDA1IDExNi40MjcgNy42MjEwOSAxMTYuNTk3QzcuNTI0NzQgMTE2Ljc2NiA3LjQxMTQ2IDExNi45MzMgNy4yODEyNSAxMTcuMDk3QzcuMTUzNjUgMTE3LjI2MSA3LjAxNjkzIDExNy40MjIgNi44NzEwOSAxMTcuNTgxTDUuMzQ3NjYgMTE5LjIzM0g4LjE5OTIyWk0xMi42NzUyIDExNi41M1YxMTcuMzk3QzEyLjY3NTIgMTE3Ljg2NCAxMi42MzM1IDExOC4yNTcgMTIuNTUwMiAxMTguNTc3QzEyLjQ2NjggMTE4Ljg5NyAxMi4zNDcgMTE5LjE1NSAxMi4xOTA4IDExOS4zNTFDMTIuMDM0NSAxMTkuNTQ2IDExLjg0NTcgMTE5LjY4OCAxMS42MjQ0IDExOS43NzZDMTEuNDA1NiAxMTkuODYyIDExLjE1ODIgMTE5LjkwNSAxMC44ODIyIDExOS45MDVDMTAuNjYzNSAxMTkuOTA1IDEwLjQ2MTYgMTE5Ljg3OCAxMC4yNzY3IDExOS44MjNDMTAuMDkxOCAxMTkuNzY5IDkuOTI1MTcgMTE5LjY4MSA5Ljc3NjczIDExOS41NjJDOS42MzA5IDExOS40MzkgOS41MDU5IDExOS4yOCA5LjQwMTczIDExOS4wODVDOS4yOTc1NyAxMTguODkgOS4yMTgxNCAxMTguNjUzIDkuMTYzNDUgMTE4LjM3NEM5LjEwODc3IDExOC4wOTUgOS4wODE0MiAxMTcuNzcgOS4wODE0MiAxMTcuMzk3VjExNi41M0M5LjA4MTQyIDExNi4wNjQgOS4xMjMwOSAxMTUuNjc0IDkuMjA2NDIgMTE1LjM1OEM5LjI5MjM2IDExNS4wNDMgOS40MTM0NSAxMTQuNzkxIDkuNTY5NyAxMTQuNjAxQzkuNzI1OTUgMTE0LjQwOCA5LjkxMzQ1IDExNC4yNyAxMC4xMzIyIDExNC4xODdDMTAuMzUzNiAxMTQuMTAzIDEwLjYwMSAxMTQuMDYyIDEwLjg3NDQgMTE0LjA2MkMxMS4wOTU3IDExNC4wNjIgMTEuMjk4OSAxMTQuMDg5IDExLjQ4MzggMTE0LjE0NEMxMS42NzEzIDExNC4xOTYgMTEuODM3OSAxMTQuMjggMTEuOTgzOCAxMTQuMzk3QzEyLjEyOTYgMTE0LjUxMiAxMi4yNTMzIDExNC42NjYgMTIuMzU0OSAxMTQuODU4QzEyLjQ1OSAxMTUuMDQ5IDEyLjUzODUgMTE1LjI4MiAxMi41OTMxIDExNS41NThDMTIuNjQ3OCAxMTUuODM0IDEyLjY3NTIgMTE2LjE1OCAxMi42NzUyIDExNi41M1pNMTEuOTQ4NiAxMTcuNTE1VjExNi40MDlDMTEuOTQ4NiAxMTYuMTU0IDExLjkzMyAxMTUuOTMgMTEuOTAxNyAxMTUuNzM3QzExLjg3MzEgMTE1LjU0MiAxMS44MzAxIDExNS4zNzUgMTEuNzcyOCAxMTUuMjM3QzExLjcxNTUgMTE1LjA5OSAxMS42NDI2IDExNC45ODcgMTEuNTU0MSAxMTQuOTAxQzExLjQ2ODEgMTE0LjgxNSAxMS4zNjc5IDExNC43NTMgMTEuMjUzMyAxMTQuNzE0QzExLjE0MTMgMTE0LjY3MiAxMS4wMTUgMTE0LjY1MSAxMC44NzQ0IDExNC42NTFDMTAuNzAyNSAxMTQuNjUxIDEwLjU1MDIgMTE0LjY4NCAxMC40MTc0IDExNC43NDlDMTAuMjg0NSAxMTQuODEyIDEwLjE3MjYgMTE0LjkxMiAxMC4wODE0IDExNS4wNUM5Ljk5Mjg4IDExNS4xODggOS45MjUxNyAxMTUuMzY5IDkuODc4MyAxMTUuNTkzQzkuODMxNDIgMTE1LjgxNyA5LjgwNzk4IDExNi4wODkgOS44MDc5OCAxMTYuNDA5VjExNy41MTVDOS44MDc5OCAxMTcuNzcgOS44MjIzMSAxMTcuOTk1IDkuODUwOTUgMTE4LjE5QzkuODgyMiAxMTguMzg2IDkuOTI3NzggMTE4LjU1NSA5Ljk4NzY3IDExOC42OThDMTAuMDQ3NiAxMTguODM5IDEwLjEyMDUgMTE4Ljk1NSAxMC4yMDY0IDExOS4wNDZDMTAuMjkyNCAxMTkuMTM3IDEwLjM5MTMgMTE5LjIwNSAxMC41MDMzIDExOS4yNDlDMTAuNjE3OSAxMTkuMjkxIDEwLjc0NDIgMTE5LjMxMiAxMC44ODIyIDExOS4zMTJDMTEuMDU5MyAxMTkuMzEyIDExLjIxNDIgMTE5LjI3OCAxMS4zNDcgMTE5LjIxQzExLjQ3OTkgMTE5LjE0MiAxMS41OTA1IDExOS4wMzcgMTEuNjc5MSAxMTguODk0QzExLjc3MDIgMTE4Ljc0OCAxMS44Mzc5IDExOC41NjIgMTEuODgyMiAxMTguMzM1QzExLjkyNjUgMTE4LjEwNiAxMS45NDg2IDExNy44MzIgMTEuOTQ4NiAxMTcuNTE1Wk0xMy42NzQ2IDExNS41MzRWMTE1LjIzM0MxMy42NzQ2IDExNS4wMTcgMTMuNzIxNCAxMTQuODIxIDEzLjgxNTIgMTE0LjY0NEMxMy45MDg5IDExNC40NjYgMTQuMDQzMSAxMTQuMzI1IDE0LjIxNzUgMTE0LjIxOEMxNC4zOTIgMTE0LjExMSAxNC41OTkgMTE0LjA1OCAxNC44Mzg2IDExNC4wNThDMTUuMDgzNCAxMTQuMDU4IDE1LjI5MTggMTE0LjExMSAxNS40NjM2IDExNC4yMThDMTUuNjM4MSAxMTQuMzI1IDE1Ljc3MjIgMTE0LjQ2NiAxNS44NjYgMTE0LjY0NEMxNS45NTk3IDExNC44MjEgMTYuMDA2NiAxMTUuMDE3IDE2LjAwNjYgMTE1LjIzM1YxMTUuNTM0QzE2LjAwNjYgMTE1Ljc0NSAxNS45NTk3IDExNS45MzkgMTUuODY2IDExNi4xMTZDMTUuNzc0OCAxMTYuMjkzIDE1LjY0MiAxMTYuNDM1IDE1LjQ2NzUgMTE2LjU0MkMxNS4yOTU3IDExNi42NDkgMTUuMDg4NiAxMTYuNzAyIDE0Ljg0NjQgMTE2LjcwMkMxNC42MDQzIDExNi43MDIgMTQuMzk0NiAxMTYuNjQ5IDE0LjIxNzUgMTE2LjU0MkMxNC4wNDMxIDExNi40MzUgMTMuOTA4OSAxMTYuMjkzIDEzLjgxNTIgMTE2LjExNkMxMy43MjE0IDExNS45MzkgMTMuNjc0NiAxMTUuNzQ1IDEzLjY3NDYgMTE1LjUzNFpNMTQuMjE3NSAxMTUuMjMzVjExNS41MzRDMTQuMjE3NSAxMTUuNjU0IDE0LjIzOTcgMTE1Ljc2NyAxNC4yODM5IDExNS44NzRDMTQuMzMwOCAxMTUuOTgxIDE0LjQwMTEgMTE2LjA2OCAxNC40OTQ5IDExNi4xMzZDMTQuNTg4NiAxMTYuMjAxIDE0LjcwNTggMTE2LjIzMyAxNC44NDY0IDExNi4yMzNDMTQuOTg3MSAxMTYuMjMzIDE1LjEwMjkgMTE2LjIwMSAxNS4xOTQxIDExNi4xMzZDMTUuMjg1MiAxMTYuMDY4IDE1LjM1MjkgMTE1Ljk4MSAxNS4zOTcyIDExNS44NzRDMTUuNDQxNSAxMTUuNzY3IDE1LjQ2MzYgMTE1LjY1NCAxNS40NjM2IDExNS41MzRWMTE1LjIzM0MxNS40NjM2IDExNS4xMTEgMTUuNDQwMiAxMTQuOTk2IDE1LjM5MzMgMTE0Ljg5QzE1LjM0OSAxMTQuNzggMTUuMjggMTE0LjY5MyAxNS4xODYzIDExNC42MjhDMTUuMDk1MSAxMTQuNTYgMTQuOTc5MyAxMTQuNTI2IDE0LjgzODYgMTE0LjUyNkMxNC43MDA2IDExNC41MjYgMTQuNTg0NyAxMTQuNTYgMTQuNDkxIDExNC42MjhDMTQuMzk5OCAxMTQuNjkzIDE0LjMzMDggMTE0Ljc4IDE0LjI4MzkgMTE0Ljg5QzE0LjIzOTcgMTE0Ljk5NiAxNC4yMTc1IDExNS4xMTEgMTQuMjE3NSAxMTUuMjMzWk0xNi40NDQxIDExOC43MzdWMTE4LjQzM0MxNi40NDQxIDExOC4yMTkgMTYuNDkxIDExOC4wMjQgMTYuNTg0NyAxMTcuODQ3QzE2LjY3ODUgMTE3LjY3IDE2LjgxMjYgMTE3LjUyOCAxNi45ODcxIDExNy40MjFDMTcuMTYxNSAxMTcuMzE0IDE3LjM2ODYgMTE3LjI2MSAxNy42MDgyIDExNy4yNjFDMTcuODUyOSAxMTcuMjYxIDE4LjA2MTMgMTE3LjMxNCAxOC4yMzMyIDExNy40MjFDMTguNDA3NiAxMTcuNTI4IDE4LjU0MTggMTE3LjY3IDE4LjYzNTUgMTE3Ljg0N0MxOC43MjkzIDExOC4wMjQgMTguNzc2MSAxMTguMjE5IDE4Ljc3NjEgMTE4LjQzM1YxMTguNzM3QzE4Ljc3NjEgMTE4Ljk1MSAxOC43MjkzIDExOS4xNDYgMTguNjM1NSAxMTkuMzIzQzE4LjU0NDQgMTE5LjUgMTguNDExNSAxMTkuNjQyIDE4LjIzNzEgMTE5Ljc0OUMxOC4wNjUyIDExOS44NTYgMTcuODU4MiAxMTkuOTA5IDE3LjYxNiAxMTkuOTA5QzE3LjM3MzggMTE5LjkwOSAxNy4xNjU0IDExOS44NTYgMTYuOTkxIDExOS43NDlDMTYuODE2NSAxMTkuNjQyIDE2LjY4MTEgMTE5LjUgMTYuNTg0NyAxMTkuMzIzQzE2LjQ5MSAxMTkuMTQ2IDE2LjQ0NDEgMTE4Ljk1MSAxNi40NDQxIDExOC43MzdaTTE2Ljk4NzEgMTE4LjQzM1YxMTguNzM3QzE2Ljk4NzEgMTE4Ljg1NyAxNy4wMDkyIDExOC45NzIgMTcuMDUzNSAxMTkuMDgxQzE3LjEwMDMgMTE5LjE4OCAxNy4xNzA3IDExOS4yNzUgMTcuMjY0NCAxMTkuMzQzQzE3LjM1ODIgMTE5LjQwOCAxNy40NzUzIDExOS40NCAxNy42MTYgMTE5LjQ0QzE3Ljc1NjYgMTE5LjQ0IDE3Ljg3MjUgMTE5LjQwOCAxNy45NjM2IDExOS4zNDNDMTguMDU3NCAxMTkuMjc1IDE4LjEyNjQgMTE5LjE4OCAxOC4xNzA3IDExOS4wODFDMTguMjE0OSAxMTguOTc0IDE4LjIzNzEgMTE4Ljg2IDE4LjIzNzEgMTE4LjczN1YxMTguNDMzQzE4LjIzNzEgMTE4LjMxIDE4LjIxMzYgMTE4LjE5NiAxOC4xNjY4IDExOC4wODlDMTguMTIyNSAxMTcuOTgyIDE4LjA1MzUgMTE3Ljg5NiAxNy45NTk3IDExNy44MzFDMTcuODY4NiAxMTcuNzYzIDE3Ljc1MTQgMTE3LjcyOSAxNy42MDgyIDExNy43MjlDMTcuNDcwMSAxMTcuNzI5IDE3LjM1NDMgMTE3Ljc2MyAxNy4yNjA1IDExNy44MzFDMTcuMTY5NCAxMTcuODk2IDE3LjEwMDMgMTE3Ljk4MiAxNy4wNTM1IDExOC4wODlDMTcuMDA5MiAxMTguMTk2IDE2Ljk4NzEgMTE4LjMxIDE2Ljk4NzEgMTE4LjQzM1pNMTcuNzg3OCAxMTQuOTQ4TDE1LjAxMDUgMTE5LjM5NEwxNC42MDQzIDExOS4xMzZMMTcuMzgxNiAxMTQuNjlMMTcuNzg3OCAxMTQuOTQ4WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8cGF0aCBkPSJNMTMuMDQzIDE0NC43MzdWMTQ1LjYwNEMxMy4wNDMgMTQ2LjA3IDEzLjAwMTMgMTQ2LjQ2NCAxMi45MTggMTQ2Ljc4NEMxMi44MzQ2IDE0Ny4xMDQgMTIuNzE0OCAxNDcuMzYyIDEyLjU1ODYgMTQ3LjU1N0MxMi40MDIzIDE0Ny43NTMgMTIuMjEzNSAxNDcuODk1IDExLjk5MjIgMTQ3Ljk4M0MxMS43NzM0IDE0OC4wNjkgMTEuNTI2IDE0OC4xMTIgMTEuMjUgMTQ4LjExMkMxMS4wMzEyIDE0OC4xMTIgMTAuODI5NCAxNDguMDg1IDEwLjY0NDUgMTQ4LjAzQzEwLjQ1OTYgMTQ3Ljk3NSAxMC4yOTMgMTQ3Ljg4OCAxMC4xNDQ1IDE0Ny43NjhDOS45OTg3IDE0Ny42NDYgOS44NzM3IDE0Ny40ODcgOS43Njk1MyAxNDcuMjkyQzkuNjY1MzYgMTQ3LjA5NiA5LjU4NTk0IDE0Ni44NTkgOS41MzEyNSAxNDYuNTgxQzkuNDc2NTYgMTQ2LjMwMiA5LjQ0OTIyIDE0NS45NzcgOS40NDkyMiAxNDUuNjA0VjE0NC43MzdDOS40NDkyMiAxNDQuMjcxIDkuNDkwODkgMTQzLjg4IDkuNTc0MjIgMTQzLjU2NUM5LjY2MDE2IDE0My4yNSA5Ljc4MTI1IDE0Mi45OTcgOS45Mzc1IDE0Mi44MDdDMTAuMDkzOCAxNDIuNjE1IDEwLjI4MTIgMTQyLjQ3NyAxMC41IDE0Mi4zOTNDMTAuNzIxNCAxNDIuMzEgMTAuOTY4OCAxNDIuMjY4IDExLjI0MjIgMTQyLjI2OEMxMS40NjM1IDE0Mi4yNjggMTEuNjY2NyAxNDIuMjk2IDExLjg1MTYgMTQyLjM1QzEyLjAzOTEgMTQyLjQwMiAxMi4yMDU3IDE0Mi40ODcgMTIuMzUxNiAxNDIuNjA0QzEyLjQ5NzQgMTQyLjcxOSAxMi42MjExIDE0Mi44NzIgMTIuNzIyNyAxNDMuMDY1QzEyLjgyNjggMTQzLjI1NSAxMi45MDYyIDE0My40ODggMTIuOTYwOSAxNDMuNzY0QzEzLjAxNTYgMTQ0LjA0IDEzLjA0MyAxNDQuMzY1IDEzLjA0MyAxNDQuNzM3Wk0xMi4zMTY0IDE0NS43MjFWMTQ0LjYxNkMxMi4zMTY0IDE0NC4zNjEgMTIuMzAwOCAxNDQuMTM3IDEyLjI2OTUgMTQzLjk0NEMxMi4yNDA5IDE0My43NDkgMTIuMTk3OSAxNDMuNTgyIDEyLjE0MDYgMTQzLjQ0NEMxMi4wODMzIDE0My4zMDYgMTIuMDEwNCAxNDMuMTk0IDExLjkyMTkgMTQzLjEwOEMxMS44MzU5IDE0My4wMjIgMTEuNzM1NyAxNDIuOTYgMTEuNjIxMSAxNDIuOTIxQzExLjUwOTEgMTQyLjg3OSAxMS4zODI4IDE0Mi44NTggMTEuMjQyMiAxNDIuODU4QzExLjA3MDMgMTQyLjg1OCAxMC45MTggMTQyLjg5MSAxMC43ODUyIDE0Mi45NTZDMTAuNjUyMyAxNDMuMDE4IDEwLjU0MDQgMTQzLjExOSAxMC40NDkyIDE0My4yNTdDMTAuMzYwNyAxNDMuMzk1IDEwLjI5MyAxNDMuNTc2IDEwLjI0NjEgMTQzLjhDMTAuMTk5MiAxNDQuMDI0IDEwLjE3NTggMTQ0LjI5NiAxMC4xNzU4IDE0NC42MTZWMTQ1LjcyMUMxMC4xNzU4IDE0NS45NzcgMTAuMTkwMSAxNDYuMjAyIDEwLjIxODggMTQ2LjM5N0MxMC4yNSAxNDYuNTkzIDEwLjI5NTYgMTQ2Ljc2MiAxMC4zNTU1IDE0Ni45MDVDMTAuNDE1NCAxNDcuMDQ2IDEwLjQ4ODMgMTQ3LjE2MiAxMC41NzQyIDE0Ny4yNTNDMTAuNjYwMiAxNDcuMzQ0IDEwLjc1OTEgMTQ3LjQxMiAxMC44NzExIDE0Ny40NTZDMTAuOTg1NyAxNDcuNDk3IDExLjExMiAxNDcuNTE4IDExLjI1IDE0Ny41MThDMTEuNDI3MSAxNDcuNTE4IDExLjU4MiAxNDcuNDg0IDExLjcxNDggMTQ3LjQxN0MxMS44NDc3IDE0Ny4zNDkgMTEuOTU4MyAxNDcuMjQ0IDEyLjA0NjkgMTQ3LjFDMTIuMTM4IDE0Ni45NTUgMTIuMjA1NyAxNDYuNzY4IDEyLjI1IDE0Ni41NDJDMTIuMjk0MyAxNDYuMzEzIDEyLjMxNjQgMTQ2LjAzOSAxMi4zMTY0IDE0NS43MjFaTTE0LjA0MjQgMTQzLjc0MVYxNDMuNDRDMTQuMDQyNCAxNDMuMjI0IDE0LjA4OTIgMTQzLjAyNyAxNC4xODMgMTQyLjg1QzE0LjI3NjcgMTQyLjY3MyAxNC40MTA4IDE0Mi41MzEgMTQuNTg1MyAxNDIuNDI1QzE0Ljc1OTggMTQyLjMxOCAxNC45NjY4IDE0Mi4yNjQgMTUuMjA2NCAxNDIuMjY0QzE1LjQ1MTIgMTQyLjI2NCAxNS42NTk1IDE0Mi4zMTggMTUuODMxNCAxNDIuNDI1QzE2LjAwNTkgMTQyLjUzMSAxNi4xNCAxNDIuNjczIDE2LjIzMzggMTQyLjg1QzE2LjMyNzUgMTQzLjAyNyAxNi4zNzQ0IDE0My4yMjQgMTYuMzc0NCAxNDMuNDRWMTQzLjc0MUMxNi4zNzQ0IDE0My45NTIgMTYuMzI3NSAxNDQuMTQ2IDE2LjIzMzggMTQ0LjMyM0MxNi4xNDI2IDE0NC41IDE2LjAwOTggMTQ0LjY0MiAxNS44MzUzIDE0NC43NDlDMTUuNjYzNSAxNDQuODU2IDE1LjQ1NjQgMTQ0LjkwOSAxNS4yMTQyIDE0NC45MDlDMTQuOTcyIDE0NC45MDkgMTQuNzYyNCAxNDQuODU2IDE0LjU4NTMgMTQ0Ljc0OUMxNC40MTA4IDE0NC42NDIgMTQuMjc2NyAxNDQuNSAxNC4xODMgMTQ0LjMyM0MxNC4wODkyIDE0NC4xNDYgMTQuMDQyNCAxNDMuOTUyIDE0LjA0MjQgMTQzLjc0MVpNMTQuNTg1MyAxNDMuNDRWMTQzLjc0MUMxNC41ODUzIDE0My44NjEgMTQuNjA3NSAxNDMuOTc0IDE0LjY1MTcgMTQ0LjA4MUMxNC42OTg2IDE0NC4xODggMTQuNzY4OSAxNDQuMjc1IDE0Ljg2MjcgMTQ0LjM0M0MxNC45NTY0IDE0NC40MDggMTUuMDczNiAxNDQuNDQgMTUuMjE0MiAxNDQuNDRDMTUuMzU0OSAxNDQuNDQgMTUuNDcwNyAxNDQuNDA4IDE1LjU2MTkgMTQ0LjM0M0MxNS42NTMgMTQ0LjI3NSAxNS43MjA3IDE0NC4xODggMTUuNzY1IDE0NC4wODFDMTUuODA5MyAxNDMuOTc0IDE1LjgzMTQgMTQzLjg2MSAxNS44MzE0IDE0My43NDFWMTQzLjQ0QzE1LjgzMTQgMTQzLjMxOCAxNS44MDggMTQzLjIwMyAxNS43NjExIDE0My4wOTZDMTUuNzE2OCAxNDIuOTg3IDE1LjY0NzggMTQyLjkgMTUuNTU0MSAxNDIuODM1QzE1LjQ2MjkgMTQyLjc2NyAxNS4zNDcgMTQyLjczMyAxNS4yMDY0IDE0Mi43MzNDMTUuMDY4NCAxNDIuNzMzIDE0Ljk1MjUgMTQyLjc2NyAxNC44NTg4IDE0Mi44MzVDMTQuNzY3NiAxNDIuOSAxNC42OTg2IDE0Mi45ODcgMTQuNjUxNyAxNDMuMDk2QzE0LjYwNzUgMTQzLjIwMyAxNC41ODUzIDE0My4zMTggMTQuNTg1MyAxNDMuNDRaTTE2LjgxMTkgMTQ2Ljk0NFYxNDYuNjM5QzE2LjgxMTkgMTQ2LjQyNiAxNi44NTg4IDE0Ni4yMzEgMTYuOTUyNSAxNDYuMDUzQzE3LjA0NjMgMTQ1Ljg3NiAxNy4xODA0IDE0NS43MzQgMTcuMzU0OSAxNDUuNjI4QzE3LjUyOTMgMTQ1LjUyMSAxNy43MzY0IDE0NS40NjggMTcuOTc2IDE0NS40NjhDMTguMjIwNyAxNDUuNDY4IDE4LjQyOTEgMTQ1LjUyMSAxOC42MDEgMTQ1LjYyOEMxOC43NzU0IDE0NS43MzQgMTguOTA5NSAxNDUuODc2IDE5LjAwMzMgMTQ2LjA1M0MxOS4wOTcgMTQ2LjIzMSAxOS4xNDM5IDE0Ni40MjYgMTkuMTQzOSAxNDYuNjM5VjE0Ni45NDRDMTkuMTQzOSAxNDcuMTU4IDE5LjA5NyAxNDcuMzUzIDE5LjAwMzMgMTQ3LjUzQzE4LjkxMjIgMTQ3LjcwNyAxOC43NzkzIDE0Ny44NDkgMTguNjA0OSAxNDcuOTU2QzE4LjQzMyAxNDguMDYzIDE4LjIyNiAxNDguMTE2IDE3Ljk4MzggMTQ4LjExNkMxNy43NDE2IDE0OC4xMTYgMTcuNTMzMiAxNDguMDYzIDE3LjM1ODggMTQ3Ljk1NkMxNy4xODQzIDE0Ny44NDkgMTcuMDQ4OSAxNDcuNzA3IDE2Ljk1MjUgMTQ3LjUzQzE2Ljg1ODggMTQ3LjM1MyAxNi44MTE5IDE0Ny4xNTggMTYuODExOSAxNDYuOTQ0Wk0xNy4zNTQ5IDE0Ni42MzlWMTQ2Ljk0NEMxNy4zNTQ5IDE0Ny4wNjQgMTcuMzc3IDE0Ny4xNzggMTcuNDIxMyAxNDcuMjg4QzE3LjQ2ODEgMTQ3LjM5NSAxNy41Mzg1IDE0Ny40ODIgMTcuNjMyMiAxNDcuNTVDMTcuNzI2IDE0Ny42MTUgMTcuODQzMSAxNDcuNjQ3IDE3Ljk4MzggMTQ3LjY0N0MxOC4xMjQ0IDE0Ny42NDcgMTguMjQwMyAxNDcuNjE1IDE4LjMzMTQgMTQ3LjU1QzE4LjQyNTIgMTQ3LjQ4MiAxOC40OTQyIDE0Ny4zOTUgMTguNTM4NSAxNDcuMjg4QzE4LjU4MjcgMTQ3LjE4MSAxOC42MDQ5IDE0Ny4wNjYgMTguNjA0OSAxNDYuOTQ0VjE0Ni42MzlDMTguNjA0OSAxNDYuNTE3IDE4LjU4MTQgMTQ2LjQwMiAxOC41MzQ1IDE0Ni4yOTZDMTguNDkwMyAxNDYuMTg5IDE4LjQyMTMgMTQ2LjEwMyAxOC4zMjc1IDE0Ni4wMzhDMTguMjM2NCAxNDUuOTcgMTguMTE5MiAxNDUuOTM2IDE3Ljk3NiAxNDUuOTM2QzE3LjgzNzkgMTQ1LjkzNiAxNy43MjIgMTQ1Ljk3IDE3LjYyODMgMTQ2LjAzOEMxNy41MzcyIDE0Ni4xMDMgMTcuNDY4MSAxNDYuMTg5IDE3LjQyMTMgMTQ2LjI5NkMxNy4zNzcgMTQ2LjQwMiAxNy4zNTQ5IDE0Ni41MTcgMTcuMzU0OSAxNDYuNjM5Wk0xOC4xNTU2IDE0My4xNTVMMTUuMzc4MyAxNDcuNkwxNC45NzIgMTQ3LjM0M0wxNy43NDk0IDE0Mi44OTdMMTguMTU1NiAxNDMuMTU1WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8cGF0aCBkPSJNMjUgNC4xNjExM0wyMDAgNC4xNjExNiIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiLz4KPHBhdGggZD0iTTI1IDMzLjE2MTFMMjAwIDMzLjE2MTIiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjEyIi8+CjxwYXRoIGQ9Ik0yNSA2MS4xNjExTDIwMCA2MS4xNjEyIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC4xMiIvPgo8cGF0aCBkPSJNMjUgODkuMTYxMUwyMDAgODkuMTYxMiIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiLz4KPHBhdGggZD0iTTI1IDExOC4xNjFMMjAwIDExOC4xNjEiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjEyIi8+CjxsaW5lIHgxPSIyMy4yIiB5MT0iMTQ1Ljk2MSIgeDI9IjIwMi44IiB5Mj0iMTQ1Ljk2MSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuNyIgc3Ryb2tlLXdpZHRoPSIwLjQiIHN0cm9rZS1saW5lY2FwPSJzcXVhcmUiLz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAxXzQxODJfMTExOTMpIj4KPGxpbmUgeDE9IjQwLjQ1IiB5MT0iMTQ3LjA3MiIgeDI9IjQwLjQ1IiB5Mj0iMTQ2LjI1IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8cGF0aCBkPSJNMzIuNTA5MSAxNTIuMDI1VjE1Mi44OTNDMzIuNTA5MSAxNTMuMzU5IDMyLjQ2NzQgMTUzLjc1MiAzMi4zODQxIDE1NC4wNzJDMzIuMzAwNyAxNTQuMzkzIDMyLjE4MDkgMTU0LjY1IDMyLjAyNDcgMTU0Ljg0NkMzMS44Njg0IDE1NS4wNDEgMzEuNjc5NiAxNTUuMTgzIDMxLjQ1ODMgMTU1LjI3MUMzMS4yMzk1IDE1NS4zNTcgMzAuOTkyMSAxNTUuNCAzMC43MTYxIDE1NS40QzMwLjQ5NzMgMTU1LjQgMzAuMjk1NSAxNTUuMzczIDMwLjExMDYgMTU1LjMxOEMyOS45MjU3IDE1NS4yNjQgMjkuNzU5MSAxNTUuMTc2IDI5LjYxMDYgMTU1LjA1N0MyOS40NjQ4IDE1NC45MzQgMjkuMzM5OCAxNTQuNzc1IDI5LjIzNTYgMTU0LjU4QzI5LjEzMTUgMTU0LjM4NSAyOS4wNTIgMTU0LjE0OCAyOC45OTczIDE1My44NjlDMjguOTQyNyAxNTMuNTkgMjguOTE1MyAxNTMuMjY1IDI4LjkxNTMgMTUyLjg5M1YxNTIuMDI1QzI4LjkxNTMgMTUxLjU1OSAyOC45NTcgMTUxLjE2OSAyOS4wNDAzIDE1MC44NTRDMjkuMTI2MiAxNTAuNTM4IDI5LjI0NzMgMTUwLjI4NiAyOS40MDM2IDE1MC4wOTZDMjkuNTU5OCAxNDkuOTAzIDI5Ljc0NzMgMTQ5Ljc2NSAyOS45NjYxIDE0OS42ODJDMzAuMTg3NCAxNDkuNTk4IDMwLjQzNDggMTQ5LjU1NyAzMC43MDgzIDE0OS41NTdDMzAuOTI5NiAxNDkuNTU3IDMxLjEzMjggMTQ5LjU4NCAzMS4zMTc3IDE0OS42MzlDMzEuNTA1MiAxNDkuNjkxIDMxLjY3MTggMTQ5Ljc3NSAzMS44MTc3IDE0OS44OTNDMzEuOTYzNSAxNTAuMDA3IDMyLjA4NzIgMTUwLjE2MSAzMi4xODg3IDE1MC4zNTRDMzIuMjkyOSAxNTAuNTQ0IDMyLjM3MjMgMTUwLjc3NyAzMi40MjcgMTUxLjA1M0MzMi40ODE3IDE1MS4zMjkgMzIuNTA5MSAxNTEuNjUzIDMyLjUwOTEgMTUyLjAyNVpNMzEuNzgyNSAxNTMuMDFWMTUxLjkwNEMzMS43ODI1IDE1MS42NDkgMzEuNzY2OSAxNTEuNDI1IDMxLjczNTYgMTUxLjIzMkMzMS43MDcgMTUxLjAzNyAzMS42NjQgMTUwLjg3IDMxLjYwNjcgMTUwLjczMkMzMS41NDk0IDE1MC41OTQgMzEuNDc2NSAxNTAuNDgyIDMxLjM4OCAxNTAuMzk2QzMxLjMwMiAxNTAuMzExIDMxLjIwMTggMTUwLjI0OCAzMS4wODcyIDE1MC4yMDlDMzAuOTc1MiAxNTAuMTY3IDMwLjg0ODkgMTUwLjE0NiAzMC43MDgzIDE1MC4xNDZDMzAuNTM2NCAxNTAuMTQ2IDMwLjM4NDEgMTUwLjE3OSAzMC4yNTEyIDE1MC4yNDRDMzAuMTE4NCAxNTAuMzA3IDMwLjAwNjUgMTUwLjQwNyAyOS45MTUzIDE1MC41NDVDMjkuODI2OCAxNTAuNjgzIDI5Ljc1OTEgMTUwLjg2NCAyOS43MTIyIDE1MS4wODhDMjkuNjY1MyAxNTEuMzEyIDI5LjY0MTkgMTUxLjU4NCAyOS42NDE5IDE1MS45MDRWMTUzLjAxQzI5LjY0MTkgMTUzLjI2NSAyOS42NTYyIDE1My40OSAyOS42ODQ4IDE1My42ODZDMjkuNzE2MSAxNTMuODgxIDI5Ljc2MTcgMTU0LjA1IDI5LjgyMTYgMTU0LjE5M0MyOS44ODE1IDE1NC4zMzQgMjkuOTU0NCAxNTQuNDUgMzAuMDQwMyAxNTQuNTQxQzMwLjEyNjIgMTU0LjYzMiAzMC4yMjUyIDE1NC43IDMwLjMzNzIgMTU0Ljc0NEMzMC40NTE4IDE1NC43ODYgMzAuNTc4MSAxNTQuODA3IDMwLjcxNjEgMTU0LjgwN0MzMC44OTMyIDE1NC44MDcgMzEuMDQ4MSAxNTQuNzczIDMxLjE4MDkgMTU0LjcwNUMzMS4zMTM3IDE1NC42MzcgMzEuNDI0NCAxNTQuNTMyIDMxLjUxMyAxNTQuMzg5QzMxLjYwNDEgMTU0LjI0MyAzMS42NzE4IDE1NC4wNTcgMzEuNzE2MSAxNTMuODNDMzEuNzYwNCAxNTMuNjAxIDMxLjc4MjUgMTUzLjMyNyAzMS43ODI1IDE1My4wMVpNMzUuODk2NCAxNDkuNjA0VjE1NS4zMjJIMzUuMTczN1YxNTAuNTA2TDMzLjcxNjcgMTUxLjAzN1YxNTAuMzg1TDM1Ljc4MzEgMTQ5LjYwNEgzNS44OTY0Wk00MS4xMTI0IDE0OS42MzVWMTU1LjMyMkg0MC4zNTg1VjE0OS42MzVINDEuMTEyNFpNNDMuNDk1MiAxNTIuMTkzVjE1Mi44MTFINDAuOTQ4M1YxNTIuMTkzSDQzLjQ5NTJaTTQzLjg4MTkgMTQ5LjYzNVYxNTAuMjUySDQwLjk0ODNWMTQ5LjYzNUg0My44ODE5Wk00Ni40MjE2IDE1NS40QzQ2LjEyNzMgMTU1LjQgNDUuODYwNCAxNTUuMzUxIDQ1LjYyMDggMTU1LjI1MkM0NS4zODM4IDE1NS4xNSA0NS4xNzk0IDE1NS4wMDggNDUuMDA3NSAxNTQuODI2QzQ0LjgzODMgMTU0LjY0NCA0NC43MDgxIDE1NC40MjggNDQuNjE2OSAxNTQuMTc4QzQ0LjUyNTggMTUzLjkyOCA0NC40ODAyIDE1My42NTQgNDQuNDgwMiAxNTMuMzU3VjE1My4xOTNDNDQuNDgwMiAxNTIuODUgNDQuNTMxIDE1Mi41NDQgNDQuNjMyNSAxNTIuMjc1QzQ0LjczNDEgMTUyLjAwNSA0NC44NzIxIDE1MS43NzUgNDUuMDQ2NiAxNTEuNTg4QzQ1LjIyMTEgMTUxLjQgNDUuNDE5IDE1MS4yNTggNDUuNjQwMyAxNTEuMTYyQzQ1Ljg2MTcgMTUxLjA2NiA0Ni4wOTA5IDE1MS4wMTggNDYuMzI3OCAxNTEuMDE4QzQ2LjYyOTkgMTUxLjAxOCA0Ni44OTAzIDE1MS4wNyA0Ny4xMDkxIDE1MS4xNzRDNDcuMzMwNSAxNTEuMjc4IDQ3LjUxMTQgMTUxLjQyNCA0Ny42NTIxIDE1MS42MTFDNDcuNzkyNyAxNTEuNzk2IDQ3Ljg5NjkgMTUyLjAxNSA0Ny45NjQ2IDE1Mi4yNjhDNDguMDMyMyAxNTIuNTE4IDQ4LjA2NjEgMTUyLjc5MSA0OC4wNjYxIDE1My4wODhWMTUzLjQxMkg0NC45MDk5VjE1Mi44MjJINDcuMzQzNVYxNTIuNzY4QzQ3LjMzMzEgMTUyLjU4IDQ3LjI5NCAxNTIuMzk4IDQ3LjIyNjMgMTUyLjIyMUM0Ny4xNjEyIDE1Mi4wNDQgNDcuMDU3IDE1MS44OTggNDYuOTEzOCAxNTEuNzgzQzQ2Ljc3MDYgMTUxLjY2OSA0Ni41NzUyIDE1MS42MTEgNDYuMzI3OCAxNTEuNjExQzQ2LjE2MzggMTUxLjYxMSA0Ni4wMTI3IDE1MS42NDYgNDUuODc0NyAxNTEuNzE3QzQ1LjczNjcgMTUxLjc4NSA0NS42MTgyIDE1MS44ODYgNDUuNTE5MyAxNTIuMDIxQzQ1LjQyMDMgMTUyLjE1NyA0NS4zNDM1IDE1Mi4zMjIgNDUuMjg4OCAxNTIuNTE4QzQ1LjIzNDEgMTUyLjcxMyA0NS4yMDY4IDE1Mi45MzggNDUuMjA2OCAxNTMuMTkzVjE1My4zNTdDNDUuMjA2OCAxNTMuNTU4IDQ1LjIzNDEgMTUzLjc0NyA0NS4yODg4IDE1My45MjRDNDUuMzQ2MSAxNTQuMDk4IDQ1LjQyODEgMTU0LjI1MiA0NS41MzQ5IDE1NC4zODVDNDUuNjQ0MyAxNTQuNTE4IDQ1Ljc3NTggMTU0LjYyMiA0NS45Mjk0IDE1NC42OTdDNDYuMDg1NyAxNTQuNzczIDQ2LjI2MjcgMTU0LjgxMSA0Ni40NjA3IDE1NC44MTFDNDYuNzE1OSAxNTQuODExIDQ2LjkzMiAxNTQuNzU4IDQ3LjEwOTEgMTU0LjY1NEM0Ny4yODYyIDE1NC41NSA0Ny40NDExIDE1NC40MTEgNDcuNTczOSAxNTQuMjM2TDQ4LjAxMTQgMTU0LjU4NEM0Ny45MjAzIDE1NC43MjIgNDcuODA0NCAxNTQuODU0IDQ3LjY2MzggMTU0Ljk3OUM0Ny41MjMyIDE1NS4xMDQgNDcuMzUgMTU1LjIwNSA0Ny4xNDQzIDE1NS4yODNDNDYuOTQxMSAxNTUuMzYxIDQ2LjcwMDIgMTU1LjQgNDYuNDIxNiAxNTUuNFpNNDguOTg4NiAxNDkuMzIySDQ5LjcxNTJWMTU0LjUwMkw0OS42NTI3IDE1NS4zMjJINDguOTg4NlYxNDkuMzIyWk01Mi41NzA2IDE1My4xNzRWMTUzLjI1NkM1Mi41NzA2IDE1My41NjMgNTIuNTM0MiAxNTMuODQ4IDUyLjQ2MTMgMTU0LjExMUM1Mi4zODgzIDE1NC4zNzIgNTIuMjgxNiAxNTQuNTk4IDUyLjE0MDkgMTU0Ljc5MUM1Mi4wMDAzIDE1NC45ODQgNTEuODI4NCAxNTUuMTMzIDUxLjYyNTMgMTU1LjI0QzUxLjQyMjIgMTU1LjM0NyA1MS4xODkxIDE1NS40IDUwLjkyNjEgMTU1LjRDNTAuNjU3OSAxNTUuNCA1MC40MjIyIDE1NS4zNTUgNTAuMjE5MSAxNTUuMjY0QzUwLjAxODUgMTU1LjE3IDQ5Ljg0OTMgMTU1LjAzNiA0OS43MTEzIDE1NC44NjFDNDkuNTczMiAxNTQuNjg3IDQ5LjQ2MjYgMTU0LjQ3NiA0OS4zNzkyIDE1NC4yMjlDNDkuMjk4NSAxNTMuOTgxIDQ5LjI0MjUgMTUzLjcwMiA0OS4yMTEzIDE1My4zOTNWMTUzLjAzM0M0OS4yNDI1IDE1Mi43MjEgNDkuMjk4NSAxNTIuNDQxIDQ5LjM3OTIgMTUyLjE5M0M0OS40NjI2IDE1MS45NDYgNDkuNTczMiAxNTEuNzM1IDQ5LjcxMTMgMTUxLjU2MUM0OS44NDkzIDE1MS4zODMgNTAuMDE4NSAxNTEuMjQ5IDUwLjIxOTEgMTUxLjE1OEM1MC40MTk2IDE1MS4wNjQgNTAuNjUyNyAxNTEuMDE4IDUwLjkxODMgMTUxLjAxOEM1MS4xODM5IDE1MS4wMTggNTEuNDE5NiAxNTEuMDcgNTEuNjI1MyAxNTEuMTc0QzUxLjgzMSAxNTEuMjc1IDUyLjAwMjkgMTUxLjQyMSA1Mi4xNDA5IDE1MS42MTFDNTIuMjgxNiAxNTEuODAxIDUyLjM4ODMgMTUyLjAyOSA1Mi40NjEzIDE1Mi4yOTVDNTIuNTM0MiAxNTIuNTU4IDUyLjU3MDYgMTUyLjg1MSA1Mi41NzA2IDE1My4xNzRaTTUxLjg0NDEgMTUzLjI1NlYxNTMuMTc0QzUxLjg0NDEgMTUyLjk2MyA1MS44MjQ1IDE1Mi43NjUgNTEuNzg1NSAxNTIuNThDNTEuNzQ2NCAxNTIuMzkzIDUxLjY4MzkgMTUyLjIyOSA1MS41OTggMTUyLjA4OEM1MS41MTIgMTUxLjk0NSA1MS4zOTg4IDE1MS44MzMgNTEuMjU4MSAxNTEuNzUyQzUxLjExNzUgMTUxLjY2OSA1MC45NDQzIDE1MS42MjcgNTAuNzM4NiAxNTEuNjI3QzUwLjU1NjMgMTUxLjYyNyA1MC4zOTc1IDE1MS42NTggNTAuMjYyIDE1MS43MjFDNTAuMTI5MiAxNTEuNzgzIDUwLjAxNTkgMTUxLjg2OCA0OS45MjIyIDE1MS45NzVDNDkuODI4NCAxNTIuMDc5IDQ5Ljc1MTYgMTUyLjE5OSA0OS42OTE3IDE1Mi4zMzRDNDkuNjM0NCAxNTIuNDY3IDQ5LjU5MTUgMTUyLjYwNSA0OS41NjI4IDE1Mi43NDhWMTUzLjY4OUM0OS42MDQ1IDE1My44NzIgNDkuNjcyMiAxNTQuMDQ4IDQ5Ljc2NTkgMTU0LjIxN0M0OS44NjIzIDE1NC4zODMgNDkuOTg5OSAxNTQuNTIgNTAuMTQ4OCAxNTQuNjI3QzUwLjMxMDIgMTU0LjczNCA1MC41MDk0IDE1NC43ODcgNTAuNzQ2NCAxNTQuNzg3QzUwLjk0MTcgMTU0Ljc4NyA1MS4xMDg0IDE1NC43NDggNTEuMjQ2NCAxNTQuNjdDNTEuMzg3IDE1NC41ODkgNTEuNTAwMyAxNTQuNDc5IDUxLjU4NjMgMTU0LjMzOEM1MS42NzQ4IDE1NC4xOTcgNTEuNzM5OSAxNTQuMDM1IDUxLjc4MTYgMTUzLjg1QzUxLjgyMzIgMTUzLjY2NSA1MS44NDQxIDE1My40NjcgNTEuODQ0MSAxNTMuMjU2WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8L2c+CjxsaW5lIHgxPSI1OC4xNDk5IiB5MT0iMTQ3LjIzMyIgeDI9IjU4LjE0OTkiIHkyPSIxNDYuNDExIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8ZyBjbGlwLXBhdGg9InVybCgjY2xpcDJfNDE4Ml8xMTE5MykiPgo8bGluZSB4MT0iNzUuODQ5OSIgeTE9IjE0Ny4yMzMiIHgyPSI3NS44NDk5IiB5Mj0iMTQ2LjQxMSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuNSIgc3Ryb2tlLXdpZHRoPSIwLjUiIHN0cm9rZS1saW5lY2FwPSJzcXVhcmUiLz4KPHBhdGggZD0iTTY3LjkwOSAxNTIuMTg2VjE1My4wNTNDNjcuOTA5IDE1My41MiA2Ny44NjczIDE1My45MTMgNjcuNzg0IDE1NC4yMzNDNjcuNzAwNiAxNTQuNTUzIDY3LjU4MDggMTU0LjgxMSA2Ny40MjQ2IDE1NS4wMDdDNjcuMjY4MyAxNTUuMjAyIDY3LjA3OTUgMTU1LjM0NCA2Ni44NTgyIDE1NS40MzJDNjYuNjM5NCAxNTUuNTE4IDY2LjM5MiAxNTUuNTYxIDY2LjExNiAxNTUuNTYxQzY1Ljg5NzIgMTU1LjU2MSA2NS42OTU0IDE1NS41MzQgNjUuNTEwNSAxNTUuNDc5QzY1LjMyNTYgMTU1LjQyNSA2NS4xNTkgMTU1LjMzNyA2NS4wMTA1IDE1NS4yMThDNjQuODY0NyAxNTUuMDk1IDY0LjczOTcgMTU0LjkzNiA2NC42MzU1IDE1NC43NDFDNjQuNTMxNCAxNTQuNTQ2IDY0LjQ1MTkgMTU0LjMwOSA2NC4zOTcyIDE1NC4wM0M2NC4zNDI2IDE1My43NTEgNjQuMzE1MiAxNTMuNDI2IDY0LjMxNTIgMTUzLjA1M1YxNTIuMTg2QzY0LjMxNTIgMTUxLjcyIDY0LjM1NjkgMTUxLjMzIDY0LjQ0MDIgMTUxLjAxNEM2NC41MjYxIDE1MC42OTkgNjQuNjQ3MiAxNTAuNDQ3IDY0LjgwMzUgMTUwLjI1N0M2NC45NTk3IDE1MC4wNjQgNjUuMTQ3MiAxNDkuOTI2IDY1LjM2NiAxNDkuODQzQzY1LjU4NzMgMTQ5Ljc1OSA2NS44MzQ3IDE0OS43MTggNjYuMTA4MiAxNDkuNzE4QzY2LjMyOTUgMTQ5LjcxOCA2Ni41MzI3IDE0OS43NDUgNjYuNzE3NiAxNDkuOEM2Ni45MDUxIDE0OS44NTIgNjcuMDcxNyAxNDkuOTM2IDY3LjIxNzYgMTUwLjA1M0M2Ny4zNjM0IDE1MC4xNjggNjcuNDg3MSAxNTAuMzIyIDY3LjU4ODYgMTUwLjUxNEM2Ny42OTI4IDE1MC43MDUgNjcuNzcyMiAxNTAuOTM4IDY3LjgyNjkgMTUxLjIxNEM2Ny44ODE2IDE1MS40OSA2Ny45MDkgMTUxLjgxNCA2Ny45MDkgMTUyLjE4NlpNNjcuMTgyNCAxNTMuMTcxVjE1Mi4wNjVDNjcuMTgyNCAxNTEuODEgNjcuMTY2OCAxNTEuNTg2IDY3LjEzNTUgMTUxLjM5M0M2Ny4xMDY5IDE1MS4xOTggNjcuMDYzOSAxNTEuMDMxIDY3LjAwNjYgMTUwLjg5M0M2Ni45NDkzIDE1MC43NTUgNjYuODc2NCAxNTAuNjQzIDY2Ljc4NzkgMTUwLjU1N0M2Ni43MDE5IDE1MC40NzEgNjYuNjAxNyAxNTAuNDA5IDY2LjQ4NzEgMTUwLjM3QzY2LjM3NTEgMTUwLjMyOCA2Ni4yNDg4IDE1MC4zMDcgNjYuMTA4MiAxNTAuMzA3QzY1LjkzNjMgMTUwLjMwNyA2NS43ODQgMTUwLjM0IDY1LjY1MTEgMTUwLjQwNUM2NS41MTgzIDE1MC40NjggNjUuNDA2NCAxNTAuNTY4IDY1LjMxNTIgMTUwLjcwNkM2NS4yMjY3IDE1MC44NDQgNjUuMTU5IDE1MS4wMjUgNjUuMTEyMSAxNTEuMjQ5QzY1LjA2NTIgMTUxLjQ3MyA2NS4wNDE4IDE1MS43NDUgNjUuMDQxOCAxNTIuMDY1VjE1My4xNzFDNjUuMDQxOCAxNTMuNDI2IDY1LjA1NjEgMTUzLjY1MSA2NS4wODQ3IDE1My44NDZDNjUuMTE2IDE1NC4wNDIgNjUuMTYxNiAxNTQuMjExIDY1LjIyMTUgMTU0LjM1NEM2NS4yODE0IDE1NC40OTUgNjUuMzU0MyAxNTQuNjExIDY1LjQ0MDIgMTU0LjcwMkM2NS41MjYxIDE1NC43OTMgNjUuNjI1MSAxNTQuODYxIDY1LjczNzEgMTU0LjkwNUM2NS44NTE3IDE1NC45NDcgNjUuOTc4IDE1NC45NjggNjYuMTE2IDE1NC45NjhDNjYuMjkzMSAxNTQuOTY4IDY2LjQ0OCAxNTQuOTM0IDY2LjU4MDggMTU0Ljg2NkM2Ni43MTM2IDE1NC43OTggNjYuODI0MyAxNTQuNjkzIDY2LjkxMjkgMTU0LjU1QzY3LjAwNCAxNTQuNDA0IDY3LjA3MTcgMTU0LjIxOCA2Ny4xMTYgMTUzLjk5MUM2Ny4xNjAzIDE1My43NjIgNjcuMTgyNCAxNTMuNDg4IDY3LjE4MjQgMTUzLjE3MVpNNjkuOTc2IDE1Mi4yODRINzAuNDkxNkM3MC43NDQyIDE1Mi4yODQgNzAuOTUyNSAxNTIuMjQyIDcxLjExNjYgMTUyLjE1OUM3MS4yODMzIDE1Mi4wNzMgNzEuNDA3IDE1MS45NTcgNzEuNDg3NyAxNTEuODExQzcxLjU3MSAxNTEuNjYzIDcxLjYxMjcgMTUxLjQ5NiA3MS42MTI3IDE1MS4zMTFDNzEuNjEyNyAxNTEuMDkzIDcxLjU3NjIgMTUwLjkwOSA3MS41MDMzIDE1MC43NkM3MS40MzA0IDE1MC42MTIgNzEuMzIxIDE1MC41IDcxLjE3NTIgMTUwLjQyNUM3MS4wMjkzIDE1MC4zNDkgNzAuODQ0NSAxNTAuMzExIDcwLjYyMDUgMTUwLjMxMUM3MC40MTc0IDE1MC4zMTEgNzAuMjM3NyAxNTAuMzUyIDcwLjA4MTQgMTUwLjQzMkM2OS45Mjc4IDE1MC41MSA2OS44MDY3IDE1MC42MjIgNjkuNzE4MiAxNTAuNzY4QzY5LjYzMjIgMTUwLjkxNCA2OS41ODkyIDE1MS4wODYgNjkuNTg5MiAxNTEuMjg0SDY4Ljg2NjZDNjguODY2NiAxNTAuOTk1IDY4LjkzOTUgMTUwLjczMiA2OS4wODUzIDE1MC40OTVDNjkuMjMxMiAxNTAuMjU4IDY5LjQzNTYgMTUwLjA2OSA2OS42OTg2IDE0OS45MjhDNjkuOTY0MiAxNDkuNzg4IDcwLjI3MTUgMTQ5LjcxOCA3MC42MjA1IDE0OS43MThDNzAuOTY0MiAxNDkuNzE4IDcxLjI2NSAxNDkuNzc5IDcxLjUyMjggMTQ5LjkwMUM3MS43ODA3IDE1MC4wMjEgNzEuOTgxMiAxNTAuMjAxIDcyLjEyNDQgMTUwLjQ0QzcyLjI2NzYgMTUwLjY3NyA3Mi4zMzkyIDE1MC45NzMgNzIuMzM5MiAxNTEuMzI3QzcyLjMzOTIgMTUxLjQ3IDcyLjMwNTQgMTUxLjYyNCA3Mi4yMzc3IDE1MS43ODhDNzIuMTcyNiAxNTEuOTQ5IDcyLjA2OTcgMTUyLjEgNzEuOTI5MSAxNTIuMjQxQzcxLjc5MTEgMTUyLjM4MiA3MS42MTE0IDE1Mi40OTcgNzEuMzkgMTUyLjU4OUM3MS4xNjg3IDE1Mi42NzcgNzAuOTAzIDE1Mi43MjEgNzAuNTkzMiAxNTIuNzIxSDY5Ljk3NlYxNTIuMjg0Wk02OS45NzYgMTUyLjg3OFYxNTIuNDQ0SDcwLjU5MzJDNzAuOTU1MSAxNTIuNDQ0IDcxLjI1NDYgMTUyLjQ4NyA3MS40OTE2IDE1Mi41NzNDNzEuNzI4NiAxNTIuNjU5IDcxLjkxNDggMTUyLjc3NCA3Mi4wNTAyIDE1Mi45MTdDNzIuMTg4MiAxNTMuMDYgNzIuMjg0NiAxNTMuMjE4IDcyLjMzOTIgMTUzLjM4OUM3Mi4zOTY1IDE1My41NTkgNzIuNDI1MiAxNTMuNzI4IDcyLjQyNTIgMTUzLjg5N0M3Mi40MjUyIDE1NC4xNjMgNzIuMzc5NiAxNTQuMzk5IDcyLjI4ODUgMTU0LjYwNEM3Mi4xOTk5IDE1NC44MSA3Mi4wNzM2IDE1NC45ODQgNzEuOTA5NiAxNTUuMTI4QzcxLjc0ODEgMTU1LjI3MSA3MS41NTggMTU1LjM3OSA3MS4zMzkyIDE1NS40NTJDNzEuMTIwNSAxNTUuNTI1IDcwLjg4MjIgMTU1LjU2MSA3MC42MjQ0IDE1NS41NjFDNzAuMzc3IDE1NS41NjEgNzAuMTQzOSAxNTUuNTI2IDY5LjkyNTIgMTU1LjQ1NkM2OS43MDkgMTU1LjM4NSA2OS41MTc2IDE1NS4yODQgNjkuMzUxIDE1NS4xNTFDNjkuMTg0MyAxNTUuMDE2IDY5LjA1NDEgMTU0Ljg1IDY4Ljk2MDMgMTU0LjY1NUM2OC44NjY2IDE1NC40NTcgNjguODE5NyAxNTQuMjMyIDY4LjgxOTcgMTUzLjk3OUg2OS41NDI0QzY5LjU0MjQgMTU0LjE3NyA2OS41ODUzIDE1NC4zNSA2OS42NzEzIDE1NC40OTlDNjkuNzU5OCAxNTQuNjQ3IDY5Ljg4NDggMTU0Ljc2MyA3MC4wNDYzIDE1NC44NDZDNzAuMjEwMyAxNTQuOTI3IDcwLjQwMyAxNTQuOTY4IDcwLjYyNDQgMTU0Ljk2OEM3MC44NDU4IDE1NC45NjggNzEuMDM1OSAxNTQuOTMgNzEuMTk0NyAxNTQuODU0QzcxLjM1NjIgMTU0Ljc3NiA3MS40Nzk5IDE1NC42NTkgNzEuNTY1OCAxNTQuNTAzQzcxLjY1NDMgMTU0LjM0NiA3MS42OTg2IDE1NC4xNSA3MS42OTg2IDE1My45MTNDNzEuNjk4NiAxNTMuNjc2IDcxLjY0OTEgMTUzLjQ4MiA3MS41NTAyIDE1My4zMzFDNzEuNDUxMiAxNTMuMTc3IDcxLjMxMDYgMTUzLjA2NCA3MS4xMjgzIDE1Mi45OTFDNzAuOTQ4NiAxNTIuOTE1IDcwLjczNjQgMTUyLjg3OCA3MC40OTE2IDE1Mi44NzhINjkuOTc2Wk03Ni41MTIzIDE0OS43OTZWMTU1LjQ4M0g3NS43NTg0VjE0OS43OTZINzYuNTEyM1pNNzguODk1MSAxNTIuMzU0VjE1Mi45NzFINzYuMzQ4MlYxNTIuMzU0SDc4Ljg5NTFaTTc5LjI4MTggMTQ5Ljc5NlYxNTAuNDEzSDc2LjM0ODJWMTQ5Ljc5Nkg3OS4yODE4Wk04MS44MjE1IDE1NS41NjFDODEuNTI3MiAxNTUuNTYxIDgxLjI2MDMgMTU1LjUxMiA4MS4wMjA3IDE1NS40MTNDODAuNzgzNyAxNTUuMzExIDgwLjU3OTMgMTU1LjE2OSA4MC40MDc0IDE1NC45ODdDODAuMjM4MiAxNTQuODA1IDgwLjEwOCAxNTQuNTg5IDgwLjAxNjggMTU0LjMzOUM3OS45MjU3IDE1NC4wODkgNzkuODgwMSAxNTMuODE1IDc5Ljg4MDEgMTUzLjUxOFYxNTMuMzU0Qzc5Ljg4MDEgMTUzLjAxIDc5LjkzMDkgMTUyLjcwNSA4MC4wMzI0IDE1Mi40MzZDODAuMTM0IDE1Mi4xNjUgODAuMjcyIDE1MS45MzYgODAuNDQ2NSAxNTEuNzQ5QzgwLjYyMSAxNTEuNTYxIDgwLjgxODkgMTUxLjQxOSA4MS4wNDAyIDE1MS4zMjNDODEuMjYxNiAxNTEuMjI3IDgxLjQ5MDggMTUxLjE3OCA4MS43Mjc3IDE1MS4xNzhDODIuMDI5OCAxNTEuMTc4IDgyLjI5MDIgMTUxLjIzMSA4Mi41MDkgMTUxLjMzNUM4Mi43MzA0IDE1MS40MzkgODIuOTExMyAxNTEuNTg1IDgzLjA1MiAxNTEuNzcyQzgzLjE5MjYgMTUxLjk1NyA4My4yOTY4IDE1Mi4xNzYgODMuMzY0NSAxNTIuNDI4QzgzLjQzMjIgMTUyLjY3OCA4My40NjYgMTUyLjk1MiA4My40NjYgMTUzLjI0OVYxNTMuNTczSDgwLjMwOThWMTUyLjk4M0g4Mi43NDM0VjE1Mi45MjhDODIuNzMzIDE1Mi43NDEgODIuNjkzOSAxNTIuNTU5IDgyLjYyNjIgMTUyLjM4MkM4Mi41NjExIDE1Mi4yMDUgODIuNDU2OSAxNTIuMDU5IDgyLjMxMzcgMTUxLjk0NEM4Mi4xNzA1IDE1MS44MyA4MS45NzUxIDE1MS43NzIgODEuNzI3NyAxNTEuNzcyQzgxLjU2MzcgMTUxLjc3MiA4MS40MTI2IDE1MS44MDcgODEuMjc0NiAxNTEuODc4QzgxLjEzNjYgMTUxLjk0NSA4MS4wMTgxIDE1Mi4wNDcgODAuOTE5MiAxNTIuMTgyQzgwLjgyMDIgMTUyLjMxOCA4MC43NDM0IDE1Mi40ODMgODAuNjg4NyAxNTIuNjc4QzgwLjYzNCAxNTIuODc0IDgwLjYwNjcgMTUzLjA5OSA4MC42MDY3IDE1My4zNTRWMTUzLjUxOEM4MC42MDY3IDE1My43MTkgODAuNjM0IDE1My45MDggODAuNjg4NyAxNTQuMDg1QzgwLjc0NiAxNTQuMjU5IDgwLjgyOCAxNTQuNDEzIDgwLjkzNDggMTU0LjU0NkM4MS4wNDQyIDE1NC42NzggODEuMTc1NyAxNTQuNzgzIDgxLjMyOTMgMTU0Ljg1OEM4MS40ODU2IDE1NC45MzQgODEuNjYyNiAxNTQuOTcxIDgxLjg2MDYgMTU0Ljk3MUM4Mi4xMTU4IDE1NC45NzEgODIuMzMxOSAxNTQuOTE5IDgyLjUwOSAxNTQuODE1QzgyLjY4NjEgMTU0LjcxMSA4Mi44NDEgMTU0LjU3MiA4Mi45NzM4IDE1NC4zOTdMODMuNDExMyAxNTQuNzQ1QzgzLjMyMDIgMTU0Ljg4MyA4My4yMDQzIDE1NS4wMTQgODMuMDYzNyAxNTUuMTM5QzgyLjkyMzEgMTU1LjI2NCA4Mi43NDk5IDE1NS4zNjYgODIuNTQ0MiAxNTUuNDQ0QzgyLjM0MSAxNTUuNTIyIDgyLjEwMDEgMTU1LjU2MSA4MS44MjE1IDE1NS41NjFaTTg0LjM4ODUgMTQ5LjQ4M0g4NS4xMTUxVjE1NC42NjNMODUuMDUyNiAxNTUuNDgzSDg0LjM4ODVWMTQ5LjQ4M1pNODcuOTcwNSAxNTMuMzM1VjE1My40MTdDODcuOTcwNSAxNTMuNzI0IDg3LjkzNDEgMTU0LjAwOSA4Ny44NjEyIDE1NC4yNzJDODcuNzg4MiAxNTQuNTMzIDg3LjY4MTUgMTU0Ljc1OSA4Ny41NDA4IDE1NC45NTJDODcuNDAwMiAxNTUuMTQ1IDg3LjIyODMgMTU1LjI5NCA4Ny4wMjUyIDE1NS40MDFDODYuODIyMSAxNTUuNTA4IDg2LjU4OSAxNTUuNTYxIDg2LjMyNiAxNTUuNTYxQzg2LjA1NzggMTU1LjU2MSA4NS44MjIxIDE1NS41MTYgODUuNjE5IDE1NS40MjVDODUuNDE4NSAxNTUuMzMxIDg1LjI0OTIgMTU1LjE5NyA4NS4xMTEyIDE1NS4wMjJDODQuOTczMSAxNTQuODQ4IDg0Ljg2MjUgMTU0LjYzNyA4NC43NzkxIDE1NC4zODlDODQuNjk4NCAxNTQuMTQyIDg0LjY0MjQgMTUzLjg2MyA4NC42MTEyIDE1My41NTNWMTUzLjE5NEM4NC42NDI0IDE1Mi44ODIgODQuNjk4NCAxNTIuNjAyIDg0Ljc3OTEgMTUyLjM1NEM4NC44NjI1IDE1Mi4xMDcgODQuOTczMSAxNTEuODk2IDg1LjExMTIgMTUxLjcyMUM4NS4yNDkyIDE1MS41NDQgODUuNDE4NSAxNTEuNDEgODUuNjE5IDE1MS4zMTlDODUuODE5NSAxNTEuMjI1IDg2LjA1MjYgMTUxLjE3OCA4Ni4zMTgyIDE1MS4xNzhDODYuNTgzOCAxNTEuMTc4IDg2LjgxOTUgMTUxLjIzMSA4Ny4wMjUyIDE1MS4zMzVDODcuMjMxIDE1MS40MzYgODcuNDAyOCAxNTEuNTgyIDg3LjU0MDggMTUxLjc3MkM4Ny42ODE1IDE1MS45NjIgODcuNzg4MiAxNTIuMTkgODcuODYxMiAxNTIuNDU2Qzg3LjkzNDEgMTUyLjcxOSA4Ny45NzA1IDE1My4wMTIgODcuOTcwNSAxNTMuMzM1Wk04Ny4yNDQgMTUzLjQxN1YxNTMuMzM1Qzg3LjI0NCAxNTMuMTI0IDg3LjIyNDQgMTUyLjkyNiA4Ny4xODU0IDE1Mi43NDFDODcuMTQ2MyAxNTIuNTUzIDg3LjA4MzggMTUyLjM4OSA4Ni45OTc5IDE1Mi4yNDlDODYuOTExOSAxNTIuMTA2IDg2Ljc5ODcgMTUxLjk5NCA4Ni42NTggMTUxLjkxM0M4Ni41MTc0IDE1MS44MyA4Ni4zNDQyIDE1MS43ODggODYuMTM4NSAxNTEuNzg4Qzg1Ljk1NjIgMTUxLjc4OCA4NS43OTc0IDE1MS44MTkgODUuNjYxOSAxNTEuODgyQzg1LjUyOTEgMTUxLjk0NCA4NS40MTU4IDE1Mi4wMjkgODUuMzIyMSAxNTIuMTM1Qzg1LjIyODMgMTUyLjI0IDg1LjE1MTUgMTUyLjM1OSA4NS4wOTE2IDE1Mi40OTVDODUuMDM0MyAxNTIuNjI4IDg0Ljk5MTQgMTUyLjc2NiA4NC45NjI3IDE1Mi45MDlWMTUzLjg1Qzg1LjAwNDQgMTU0LjAzMyA4NS4wNzIxIDE1NC4yMDggODUuMTY1OCAxNTQuMzc4Qzg1LjI2MjIgMTU0LjU0NCA4NS4zODk4IDE1NC42ODEgODUuNTQ4NyAxNTQuNzg4Qzg1LjcxMDEgMTU0Ljg5NSA4NS45MDkzIDE1NC45NDggODYuMTQ2MyAxNTQuOTQ4Qzg2LjM0MTYgMTU0Ljk0OCA4Ni41MDgzIDE1NC45MDkgODYuNjQ2MyAxNTQuODMxQzg2Ljc4NjkgMTU0Ljc1IDg2LjkwMDIgMTU0LjYzOSA4Ni45ODYyIDE1NC40OTlDODcuMDc0NyAxNTQuMzU4IDg3LjEzOTggMTU0LjE5NSA4Ny4xODE1IDE1NC4wMUM4Ny4yMjMxIDE1My44MjYgODcuMjQ0IDE1My42MjggODcuMjQ0IDE1My40MTdaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjU0Ii8+CjwvZz4KPGxpbmUgeDE9IjkzLjU1IiB5MT0iMTQ3LjIzMyIgeDI9IjkzLjU1IiB5Mj0iMTQ2LjQxMSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuNSIgc3Ryb2tlLXdpZHRoPSIwLjUiIHN0cm9rZS1saW5lY2FwPSJzcXVhcmUiLz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAzXzQxODJfMTExOTMpIj4KPGxpbmUgeDE9IjExMS4yNSIgeTE9IjE0Ny4yMzMiIHgyPSIxMTEuMjUiIHkyPSIxNDYuNDExIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8cGF0aCBkPSJNMTAzLjMwOSAxNTIuMTg2VjE1My4wNTNDMTAzLjMwOSAxNTMuNTIgMTAzLjI2NyAxNTMuOTEzIDEwMy4xODQgMTU0LjIzM0MxMDMuMTAxIDE1NC41NTMgMTAyLjk4MSAxNTQuODExIDEwMi44MjUgMTU1LjAwN0MxMDIuNjY4IDE1NS4yMDIgMTAyLjQ4IDE1NS4zNDQgMTAyLjI1OCAxNTUuNDMyQzEwMi4wNCAxNTUuNTE4IDEwMS43OTIgMTU1LjU2MSAxMDEuNTE2IDE1NS41NjFDMTAxLjI5NyAxNTUuNTYxIDEwMS4wOTYgMTU1LjUzNCAxMDAuOTExIDE1NS40NzlDMTAwLjcyNiAxNTUuNDI1IDEwMC41NTkgMTU1LjMzNyAxMDAuNDExIDE1NS4yMThDMTAwLjI2NSAxNTUuMDk1IDEwMC4xNCAxNTQuOTM2IDEwMC4wMzYgMTU0Ljc0MUM5OS45MzE1IDE1NC41NDYgOTkuODUyMSAxNTQuMzA5IDk5Ljc5NzQgMTU0LjAzQzk5Ljc0MjcgMTUzLjc1MSA5OS43MTU0IDE1My40MjYgOTkuNzE1NCAxNTMuMDUzVjE1Mi4xODZDOTkuNzE1NCAxNTEuNzIgOTkuNzU3IDE1MS4zMyA5OS44NDA0IDE1MS4wMTRDOTkuOTI2MyAxNTAuNjk5IDEwMC4wNDcgMTUwLjQ0NyAxMDAuMjA0IDE1MC4yNTdDMTAwLjM2IDE1MC4wNjQgMTAwLjU0NyAxNDkuOTI2IDEwMC43NjYgMTQ5Ljg0M0MxMDAuOTg3IDE0OS43NTkgMTAxLjIzNSAxNDkuNzE4IDEwMS41MDggMTQ5LjcxOEMxMDEuNzMgMTQ5LjcxOCAxMDEuOTMzIDE0OS43NDUgMTAyLjExOCAxNDkuOEMxMDIuMzA1IDE0OS44NTIgMTAyLjQ3MiAxNDkuOTM2IDEwMi42MTggMTUwLjA1M0MxMDIuNzY0IDE1MC4xNjggMTAyLjg4NyAxNTAuMzIyIDEwMi45ODkgMTUwLjUxNEMxMDMuMDkzIDE1MC43MDUgMTAzLjE3MiAxNTAuOTM4IDEwMy4yMjcgMTUxLjIxNEMxMDMuMjgyIDE1MS40OSAxMDMuMzA5IDE1MS44MTQgMTAzLjMwOSAxNTIuMTg2Wk0xMDIuNTgzIDE1My4xNzFWMTUyLjA2NUMxMDIuNTgzIDE1MS44MSAxMDIuNTY3IDE1MS41ODYgMTAyLjUzNiAxNTEuMzkzQzEwMi41MDcgMTUxLjE5OCAxMDIuNDY0IDE1MS4wMzEgMTAyLjQwNyAxNTAuODkzQzEwMi4zNDkgMTUwLjc1NSAxMDIuMjc3IDE1MC42NDMgMTAyLjE4OCAxNTAuNTU3QzEwMi4xMDIgMTUwLjQ3MSAxMDIuMDAyIDE1MC40MDkgMTAxLjg4NyAxNTAuMzdDMTAxLjc3NSAxNTAuMzI4IDEwMS42NDkgMTUwLjMwNyAxMDEuNTA4IDE1MC4zMDdDMTAxLjMzNiAxNTAuMzA3IDEwMS4xODQgMTUwLjM0IDEwMS4wNTEgMTUwLjQwNUMxMDAuOTE4IDE1MC40NjggMTAwLjgwNyAxNTAuNTY4IDEwMC43MTUgMTUwLjcwNkMxMDAuNjI3IDE1MC44NDQgMTAwLjU1OSAxNTEuMDI1IDEwMC41MTIgMTUxLjI0OUMxMDAuNDY1IDE1MS40NzMgMTAwLjQ0MiAxNTEuNzQ1IDEwMC40NDIgMTUyLjA2NVYxNTMuMTcxQzEwMC40NDIgMTUzLjQyNiAxMDAuNDU2IDE1My42NTEgMTAwLjQ4NSAxNTMuODQ2QzEwMC41MTYgMTU0LjA0MiAxMDAuNTYyIDE1NC4yMTEgMTAwLjYyMiAxNTQuMzU0QzEwMC42ODIgMTU0LjQ5NSAxMDAuNzU0IDE1NC42MTEgMTAwLjg0IDE1NC43MDJDMTAwLjkyNiAxNTQuNzkzIDEwMS4wMjUgMTU0Ljg2MSAxMDEuMTM3IDE1NC45MDVDMTAxLjI1MiAxNTQuOTQ3IDEwMS4zNzggMTU0Ljk2OCAxMDEuNTE2IDE1NC45NjhDMTAxLjY5MyAxNTQuOTY4IDEwMS44NDggMTU0LjkzNCAxMDEuOTgxIDE1NC44NjZDMTAyLjExNCAxNTQuNzk4IDEwMi4yMjQgMTU0LjY5MyAxMDIuMzEzIDE1NC41NUMxMDIuNDA0IDE1NC40MDQgMTAyLjQ3MiAxNTQuMjE4IDEwMi41MTYgMTUzLjk5MUMxMDIuNTYgMTUzLjc2MiAxMDIuNTgzIDE1My40ODggMTAyLjU4MyAxNTMuMTcxWk0xMDUuMjM1IDE1Mi43NzZMMTA0LjY1NyAxNTIuNjI4TDEwNC45NDMgMTQ5Ljc5NkgxMDcuODZWMTUwLjQ2NEgxMDUuNTU2TDEwNS4zODQgMTUyLjAxQzEwNS40ODggMTUxLjk1MSAxMDUuNjIgMTUxLjg5NSAxMDUuNzc4IDE1MS44NDNDMTA1Ljk0IDE1MS43OSAxMDYuMTI1IDE1MS43NjQgMTA2LjMzMyAxNTEuNzY0QzEwNi41OTYgMTUxLjc2NCAxMDYuODMyIDE1MS44MSAxMDcuMDQgMTUxLjkwMUMxMDcuMjQ5IDE1MS45OSAxMDcuNDI2IDE1Mi4xMTcgMTA3LjU3MSAxNTIuMjg0QzEwNy43MiAxNTIuNDUxIDEwNy44MzMgMTUyLjY1MSAxMDcuOTExIDE1Mi44ODVDMTA3Ljk4OSAxNTMuMTIgMTA4LjAyOCAxNTMuMzgyIDEwOC4wMjggMTUzLjY3MUMxMDguMDI4IDE1My45NDQgMTA3Ljk5MSAxNTQuMTk1IDEwNy45MTUgMTU0LjQyNUMxMDcuODQyIDE1NC42NTQgMTA3LjczMiAxNTQuODU0IDEwNy41ODMgMTU1LjAyNkMxMDcuNDM1IDE1NS4xOTUgMTA3LjI0NyAxNTUuMzI3IDEwNy4wMjEgMTU1LjQyMUMxMDYuNzk3IDE1NS41MTQgMTA2LjUzMiAxNTUuNTYxIDEwNi4yMjggMTU1LjU2MUMxMDUuOTk5IDE1NS41NjEgMTA1Ljc4MSAxNTUuNTMgMTA1LjU3NSAxNTUuNDY4QzEwNS4zNzIgMTU1LjQwMiAxMDUuMTkgMTU1LjMwNSAxMDUuMDI4IDE1NS4xNzVDMTA0Ljg3IDE1NS4wNDIgMTA0LjczOSAxNTQuODc4IDEwNC42MzggMTU0LjY4MkMxMDQuNTM5IDE1NC40ODQgMTA0LjQ3NiAxNTQuMjUzIDEwNC40NSAxNTMuOTg3SDEwNS4xMzhDMTA1LjE2OSAxNTQuMjAxIDEwNS4yMzIgMTU0LjM4IDEwNS4zMjUgMTU0LjUyNkMxMDUuNDE5IDE1NC42NzIgMTA1LjU0MSAxNTQuNzgzIDEwNS42OTMgMTU0Ljg1OEMxMDUuODQ2IDE1NC45MzEgMTA2LjAyNSAxNTQuOTY4IDEwNi4yMjggMTU0Ljk2OEMxMDYuNCAxNTQuOTY4IDEwNi41NTIgMTU0LjkzOCAxMDYuNjg1IDE1NC44NzhDMTA2LjgxOCAxNTQuODE4IDEwNi45MjkgMTU0LjczMiAxMDcuMDIxIDE1NC42MkMxMDcuMTEyIDE1NC41MDggMTA3LjE4MSAxNTQuMzcyIDEwNy4yMjggMTU0LjIxNEMxMDcuMjc3IDE1NC4wNTUgMTA3LjMwMiAxNTMuODc2IDEwNy4zMDIgMTUzLjY3OEMxMDcuMzAyIDE1My40OTkgMTA3LjI3NyAxNTMuMzMyIDEwNy4yMjggMTUzLjE3OEMxMDcuMTc4IDE1My4wMjUgMTA3LjEwNCAxNTIuODkxIDEwNy4wMDUgMTUyLjc3NkMxMDYuOTA5IDE1Mi42NjIgMTA2Ljc5IDE1Mi41NzMgMTA2LjY1IDE1Mi41MUMxMDYuNTA5IDE1Mi40NDUgMTA2LjM0NyAxNTIuNDEzIDEwNi4xNjUgMTUyLjQxM0MxMDUuOTIzIDE1Mi40MTMgMTA1LjczOSAxNTIuNDQ1IDEwNS42MTQgMTUyLjUxQzEwNS40OTIgMTUyLjU3NiAxMDUuMzY2IDE1Mi42NjQgMTA1LjIzNSAxNTIuNzc2Wk0xMTEuOTEyIDE0OS43OTZWMTU1LjQ4M0gxMTEuMTU5VjE0OS43OTZIMTExLjkxMlpNMTE0LjI5NSAxNTIuMzU0VjE1Mi45NzFIMTExLjc0OFYxNTIuMzU0SDExNC4yOTVaTTExNC42ODIgMTQ5Ljc5NlYxNTAuNDEzSDExMS43NDhWMTQ5Ljc5NkgxMTQuNjgyWk0xMTcuMjIyIDE1NS41NjFDMTE2LjkyNyAxNTUuNTYxIDExNi42NiAxNTUuNTEyIDExNi40MjEgMTU1LjQxM0MxMTYuMTg0IDE1NS4zMTEgMTE1Ljk3OSAxNTUuMTY5IDExNS44MDggMTU0Ljk4N0MxMTUuNjM4IDE1NC44MDUgMTE1LjUwOCAxNTQuNTg5IDExNS40MTcgMTU0LjMzOUMxMTUuMzI2IDE1NC4wODkgMTE1LjI4IDE1My44MTUgMTE1LjI4IDE1My41MThWMTUzLjM1NEMxMTUuMjggMTUzLjAxIDExNS4zMzEgMTUyLjcwNSAxMTUuNDMzIDE1Mi40MzZDMTE1LjUzNCAxNTIuMTY1IDExNS42NzIgMTUxLjkzNiAxMTUuODQ3IDE1MS43NDlDMTE2LjAyMSAxNTEuNTYxIDExNi4yMTkgMTUxLjQxOSAxMTYuNDQgMTUxLjMyM0MxMTYuNjYyIDE1MS4yMjcgMTE2Ljg5MSAxNTEuMTc4IDExNy4xMjggMTUxLjE3OEMxMTcuNDMgMTUxLjE3OCAxMTcuNjkgMTUxLjIzMSAxMTcuOTA5IDE1MS4zMzVDMTE4LjEzIDE1MS40MzkgMTE4LjMxMSAxNTEuNTg1IDExOC40NTIgMTUxLjc3MkMxMTguNTkzIDE1MS45NTcgMTE4LjY5NyAxNTIuMTc2IDExOC43NjUgMTUyLjQyOEMxMTguODMyIDE1Mi42NzggMTE4Ljg2NiAxNTIuOTUyIDExOC44NjYgMTUzLjI0OVYxNTMuNTczSDExNS43MVYxNTIuOTgzSDExOC4xNDRWMTUyLjkyOEMxMTguMTMzIDE1Mi43NDEgMTE4LjA5NCAxNTIuNTU5IDExOC4wMjYgMTUyLjM4MkMxMTcuOTYxIDE1Mi4yMDUgMTE3Ljg1NyAxNTIuMDU5IDExNy43MTQgMTUxLjk0NEMxMTcuNTcxIDE1MS44MyAxMTcuMzc1IDE1MS43NzIgMTE3LjEyOCAxNTEuNzcyQzExNi45NjQgMTUxLjc3MiAxMTYuODEzIDE1MS44MDcgMTE2LjY3NSAxNTEuODc4QzExNi41MzcgMTUxLjk0NSAxMTYuNDE4IDE1Mi4wNDcgMTE2LjMxOSAxNTIuMTgyQzExNi4yMiAxNTIuMzE4IDExNi4xNDQgMTUyLjQ4MyAxMTYuMDg5IDE1Mi42NzhDMTE2LjAzNCAxNTIuODc0IDExNi4wMDcgMTUzLjA5OSAxMTYuMDA3IDE1My4zNTRWMTUzLjUxOEMxMTYuMDA3IDE1My43MTkgMTE2LjAzNCAxNTMuOTA4IDExNi4wODkgMTU0LjA4NUMxMTYuMTQ2IDE1NC4yNTkgMTE2LjIyOCAxNTQuNDEzIDExNi4zMzUgMTU0LjU0NkMxMTYuNDQ0IDE1NC42NzggMTE2LjU3NiAxNTQuNzgzIDExNi43MjkgMTU0Ljg1OEMxMTYuODg2IDE1NC45MzQgMTE3LjA2MyAxNTQuOTcxIDExNy4yNjEgMTU0Ljk3MUMxMTcuNTE2IDE1NC45NzEgMTE3LjczMiAxNTQuOTE5IDExNy45MDkgMTU0LjgxNUMxMTguMDg2IDE1NC43MTEgMTE4LjI0MSAxNTQuNTcyIDExOC4zNzQgMTU0LjM5N0wxMTguODExIDE1NC43NDVDMTE4LjcyIDE1NC44ODMgMTE4LjYwNCAxNTUuMDE0IDExOC40NjQgMTU1LjEzOUMxMTguMzIzIDE1NS4yNjQgMTE4LjE1IDE1NS4zNjYgMTE3Ljk0NCAxNTUuNDQ0QzExNy43NDEgMTU1LjUyMiAxMTcuNSAxNTUuNTYxIDExNy4yMjIgMTU1LjU2MVpNMTE5Ljc4OSAxNDkuNDgzSDEyMC41MTVWMTU0LjY2M0wxMjAuNDUzIDE1NS40ODNIMTE5Ljc4OVYxNDkuNDgzWk0xMjMuMzcxIDE1My4zMzVWMTUzLjQxN0MxMjMuMzcxIDE1My43MjQgMTIzLjMzNCAxNTQuMDA5IDEyMy4yNjEgMTU0LjI3MkMxMjMuMTg4IDE1NC41MzMgMTIzLjA4MiAxNTQuNzU5IDEyMi45NDEgMTU0Ljk1MkMxMjIuOCAxNTUuMTQ1IDEyMi42MjggMTU1LjI5NCAxMjIuNDI1IDE1NS40MDFDMTIyLjIyMiAxNTUuNTA4IDEyMS45ODkgMTU1LjU2MSAxMjEuNzI2IDE1NS41NjFDMTIxLjQ1OCAxNTUuNTYxIDEyMS4yMjIgMTU1LjUxNiAxMjEuMDE5IDE1NS40MjVDMTIwLjgxOSAxNTUuMzMxIDEyMC42NDkgMTU1LjE5NyAxMjAuNTExIDE1NS4wMjJDMTIwLjM3MyAxNTQuODQ4IDEyMC4yNjMgMTU0LjYzNyAxMjAuMTc5IDE1NC4zODlDMTIwLjA5OSAxNTQuMTQyIDEyMC4wNDMgMTUzLjg2MyAxMjAuMDExIDE1My41NTNWMTUzLjE5NEMxMjAuMDQzIDE1Mi44ODIgMTIwLjA5OSAxNTIuNjAyIDEyMC4xNzkgMTUyLjM1NEMxMjAuMjYzIDE1Mi4xMDcgMTIwLjM3MyAxNTEuODk2IDEyMC41MTEgMTUxLjcyMUMxMjAuNjQ5IDE1MS41NDQgMTIwLjgxOSAxNTEuNDEgMTIxLjAxOSAxNTEuMzE5QzEyMS4yMiAxNTEuMjI1IDEyMS40NTMgMTUxLjE3OCAxMjEuNzE4IDE1MS4xNzhDMTIxLjk4NCAxNTEuMTc4IDEyMi4yMiAxNTEuMjMxIDEyMi40MjUgMTUxLjMzNUMxMjIuNjMxIDE1MS40MzYgMTIyLjgwMyAxNTEuNTgyIDEyMi45NDEgMTUxLjc3MkMxMjMuMDgyIDE1MS45NjIgMTIzLjE4OCAxNTIuMTkgMTIzLjI2MSAxNTIuNDU2QzEyMy4zMzQgMTUyLjcxOSAxMjMuMzcxIDE1My4wMTIgMTIzLjM3MSAxNTMuMzM1Wk0xMjIuNjQ0IDE1My40MTdWMTUzLjMzNUMxMjIuNjQ0IDE1My4xMjQgMTIyLjYyNSAxNTIuOTI2IDEyMi41ODYgMTUyLjc0MUMxMjIuNTQ2IDE1Mi41NTMgMTIyLjQ4NCAxNTIuMzg5IDEyMi4zOTggMTUyLjI0OUMxMjIuMzEyIDE1Mi4xMDYgMTIyLjE5OSAxNTEuOTk0IDEyMi4wNTggMTUxLjkxM0MxMjEuOTE4IDE1MS44MyAxMjEuNzQ0IDE1MS43ODggMTIxLjUzOSAxNTEuNzg4QzEyMS4zNTYgMTUxLjc4OCAxMjEuMTk4IDE1MS44MTkgMTIxLjA2MiAxNTEuODgyQzEyMC45MjkgMTUxLjk0NCAxMjAuODE2IDE1Mi4wMjkgMTIwLjcyMiAxNTIuMTM1QzEyMC42MjggMTUyLjI0IDEyMC41NTIgMTUyLjM1OSAxMjAuNDkyIDE1Mi40OTVDMTIwLjQzNCAxNTIuNjI4IDEyMC4zOTIgMTUyLjc2NiAxMjAuMzYzIDE1Mi45MDlWMTUzLjg1QzEyMC40MDUgMTU0LjAzMyAxMjAuNDcyIDE1NC4yMDggMTIwLjU2NiAxNTQuMzc4QzEyMC42NjIgMTU0LjU0NCAxMjAuNzkgMTU0LjY4MSAxMjAuOTQ5IDE1NC43ODhDMTIxLjExIDE1NC44OTUgMTIxLjMwOSAxNTQuOTQ4IDEyMS41NDYgMTU0Ljk0OEMxMjEuNzQyIDE1NC45NDggMTIxLjkwOCAxNTQuOTA5IDEyMi4wNDYgMTU0LjgzMUMxMjIuMTg3IDE1NC43NSAxMjIuMyAxNTQuNjM5IDEyMi4zODYgMTU0LjQ5OUMxMjIuNDc1IDE1NC4zNTggMTIyLjU0IDE1NC4xOTUgMTIyLjU4MiAxNTQuMDFDMTIyLjYyMyAxNTMuODI2IDEyMi42NDQgMTUzLjYyOCAxMjIuNjQ0IDE1My40MTdaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjU0Ii8+CjwvZz4KPGxpbmUgeDE9IjEyOC45NSIgeTE9IjE0Ny4yMzMiIHgyPSIxMjguOTUiIHkyPSIxNDYuNDExIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8ZyBjbGlwLXBhdGg9InVybCgjY2xpcDRfNDE4Ml8xMTE5MykiPgo8bGluZSB4MT0iMTQ2LjY1IiB5MT0iMTQ3LjIzMyIgeDI9IjE0Ni42NSIgeTI9IjE0Ni40MTEiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjUiIHN0cm9rZS13aWR0aD0iMC41IiBzdHJva2UtbGluZWNhcD0ic3F1YXJlIi8+CjxwYXRoIGQ9Ik0xMzguNzA5IDE1Mi4xODZWMTUzLjA1M0MxMzguNzA5IDE1My41MiAxMzguNjY3IDE1My45MTMgMTM4LjU4NCAxNTQuMjMzQzEzOC41MDEgMTU0LjU1MyAxMzguMzgxIDE1NC44MTEgMTM4LjIyNSAxNTUuMDA3QzEzOC4wNjggMTU1LjIwMiAxMzcuODggMTU1LjM0NCAxMzcuNjU4IDE1NS40MzJDMTM3LjQzOSAxNTUuNTE4IDEzNy4xOTIgMTU1LjU2MSAxMzYuOTE2IDE1NS41NjFDMTM2LjY5NyAxNTUuNTYxIDEzNi40OTUgMTU1LjUzNCAxMzYuMzExIDE1NS40NzlDMTM2LjEyNiAxNTUuNDI1IDEzNS45NTkgMTU1LjMzNyAxMzUuODExIDE1NS4yMThDMTM1LjY2NSAxNTUuMDk1IDEzNS41NCAxNTQuOTM2IDEzNS40MzYgMTU0Ljc0MUMxMzUuMzMxIDE1NC41NDYgMTM1LjI1MiAxNTQuMzA5IDEzNS4xOTcgMTU0LjAzQzEzNS4xNDMgMTUzLjc1MSAxMzUuMTE1IDE1My40MjYgMTM1LjExNSAxNTMuMDUzVjE1Mi4xODZDMTM1LjExNSAxNTEuNzIgMTM1LjE1NyAxNTEuMzMgMTM1LjI0IDE1MS4wMTRDMTM1LjMyNiAxNTAuNjk5IDEzNS40NDcgMTUwLjQ0NyAxMzUuNjA0IDE1MC4yNTdDMTM1Ljc2IDE1MC4wNjQgMTM1Ljk0NyAxNDkuOTI2IDEzNi4xNjYgMTQ5Ljg0M0MxMzYuMzg3IDE0OS43NTkgMTM2LjYzNSAxNDkuNzE4IDEzNi45MDggMTQ5LjcxOEMxMzcuMTMgMTQ5LjcxOCAxMzcuMzMzIDE0OS43NDUgMTM3LjUxOCAxNDkuOEMxMzcuNzA1IDE0OS44NTIgMTM3Ljg3MiAxNDkuOTM2IDEzOC4wMTggMTUwLjA1M0MxMzguMTYzIDE1MC4xNjggMTM4LjI4NyAxNTAuMzIyIDEzOC4zODkgMTUwLjUxNEMxMzguNDkzIDE1MC43MDUgMTM4LjU3MiAxNTAuOTM4IDEzOC42MjcgMTUxLjIxNEMxMzguNjgyIDE1MS40OSAxMzguNzA5IDE1MS44MTQgMTM4LjcwOSAxNTIuMTg2Wk0xMzcuOTgyIDE1My4xNzFWMTUyLjA2NUMxMzcuOTgyIDE1MS44MSAxMzcuOTY3IDE1MS41ODYgMTM3LjkzNiAxNTEuMzkzQzEzNy45MDcgMTUxLjE5OCAxMzcuODY0IDE1MS4wMzEgMTM3LjgwNyAxNTAuODkzQzEzNy43NDkgMTUwLjc1NSAxMzcuNjc2IDE1MC42NDMgMTM3LjU4OCAxNTAuNTU3QzEzNy41MDIgMTUwLjQ3MSAxMzcuNDAyIDE1MC40MDkgMTM3LjI4NyAxNTAuMzdDMTM3LjE3NSAxNTAuMzI4IDEzNy4wNDkgMTUwLjMwNyAxMzYuOTA4IDE1MC4zMDdDMTM2LjczNiAxNTAuMzA3IDEzNi41ODQgMTUwLjM0IDEzNi40NTEgMTUwLjQwNUMxMzYuMzE4IDE1MC40NjggMTM2LjIwNiAxNTAuNTY4IDEzNi4xMTUgMTUwLjcwNkMxMzYuMDI3IDE1MC44NDQgMTM1Ljk1OSAxNTEuMDI1IDEzNS45MTIgMTUxLjI0OUMxMzUuODY1IDE1MS40NzMgMTM1Ljg0MiAxNTEuNzQ1IDEzNS44NDIgMTUyLjA2NVYxNTMuMTcxQzEzNS44NDIgMTUzLjQyNiAxMzUuODU2IDE1My42NTEgMTM1Ljg4NSAxNTMuODQ2QzEzNS45MTYgMTU0LjA0MiAxMzUuOTYyIDE1NC4yMTEgMTM2LjAyMiAxNTQuMzU0QzEzNi4wODEgMTU0LjQ5NSAxMzYuMTU0IDE1NC42MTEgMTM2LjI0IDE1NC43MDJDMTM2LjMyNiAxNTQuNzkzIDEzNi40MjUgMTU0Ljg2MSAxMzYuNTM3IDE1NC45MDVDMTM2LjY1MiAxNTQuOTQ3IDEzNi43NzggMTU0Ljk2OCAxMzYuOTE2IDE1NC45NjhDMTM3LjA5MyAxNTQuOTY4IDEzNy4yNDggMTU0LjkzNCAxMzcuMzgxIDE1NC44NjZDMTM3LjUxNCAxNTQuNzk4IDEzNy42MjQgMTU0LjY5MyAxMzcuNzEzIDE1NC41NUMxMzcuODA0IDE1NC40MDQgMTM3Ljg3MiAxNTQuMjE4IDEzNy45MTYgMTUzLjk5MUMxMzcuOTYgMTUzLjc2MiAxMzcuOTgyIDE1My40ODggMTM3Ljk4MiAxNTMuMTcxWk0xNDMuMzk3IDE0OS43OTZWMTUwLjIwMkwxNDEuMDQyIDE1NS40ODNIMTQwLjI4TDE0Mi42MzEgMTUwLjM4OUgxMzkuNTUzVjE0OS43OTZIMTQzLjM5N1pNMTQ3LjMxMiAxNDkuNzk2VjE1NS40ODNIMTQ2LjU1OFYxNDkuNzk2SDE0Ny4zMTJaTTE0OS42OTUgMTUyLjM1NFYxNTIuOTcxSDE0Ny4xNDhWMTUyLjM1NEgxNDkuNjk1Wk0xNTAuMDgyIDE0OS43OTZWMTUwLjQxM0gxNDcuMTQ4VjE0OS43OTZIMTUwLjA4MlpNMTUyLjYyMiAxNTUuNTYxQzE1Mi4zMjcgMTU1LjU2MSAxNTIuMDYgMTU1LjUxMiAxNTEuODIxIDE1NS40MTNDMTUxLjU4NCAxNTUuMzExIDE1MS4zNzkgMTU1LjE2OSAxNTEuMjA3IDE1NC45ODdDMTUxLjAzOCAxNTQuODA1IDE1MC45MDggMTU0LjU4OSAxNTAuODE3IDE1NC4zMzlDMTUwLjcyNiAxNTQuMDg5IDE1MC42OCAxNTMuODE1IDE1MC42OCAxNTMuNTE4VjE1My4zNTRDMTUwLjY4IDE1My4wMSAxNTAuNzMxIDE1Mi43MDUgMTUwLjgzMiAxNTIuNDM2QzE1MC45MzQgMTUyLjE2NSAxNTEuMDcyIDE1MS45MzYgMTUxLjI0NyAxNTEuNzQ5QzE1MS40MjEgMTUxLjU2MSAxNTEuNjE5IDE1MS40MTkgMTUxLjg0IDE1MS4zMjNDMTUyLjA2MiAxNTEuMjI3IDE1Mi4yOTEgMTUxLjE3OCAxNTIuNTI4IDE1MS4xNzhDMTUyLjgzIDE1MS4xNzggMTUzLjA5IDE1MS4yMzEgMTUzLjMwOSAxNTEuMzM1QzE1My41MyAxNTEuNDM5IDE1My43MTEgMTUxLjU4NSAxNTMuODUyIDE1MS43NzJDMTUzLjk5MyAxNTEuOTU3IDE1NC4wOTcgMTUyLjE3NiAxNTQuMTY1IDE1Mi40MjhDMTU0LjIzMiAxNTIuNjc4IDE1NC4yNjYgMTUyLjk1MiAxNTQuMjY2IDE1My4yNDlWMTUzLjU3M0gxNTEuMTFWMTUyLjk4M0gxNTMuNTQzVjE1Mi45MjhDMTUzLjUzMyAxNTIuNzQxIDE1My40OTQgMTUyLjU1OSAxNTMuNDI2IDE1Mi4zODJDMTUzLjM2MSAxNTIuMjA1IDE1My4yNTcgMTUyLjA1OSAxNTMuMTE0IDE1MS45NDRDMTUyLjk3MSAxNTEuODMgMTUyLjc3NSAxNTEuNzcyIDE1Mi41MjggMTUxLjc3MkMxNTIuMzY0IDE1MS43NzIgMTUyLjIxMyAxNTEuODA3IDE1Mi4wNzUgMTUxLjg3OEMxNTEuOTM3IDE1MS45NDUgMTUxLjgxOCAxNTIuMDQ3IDE1MS43MTkgMTUyLjE4MkMxNTEuNjIgMTUyLjMxOCAxNTEuNTQzIDE1Mi40ODMgMTUxLjQ4OSAxNTIuNjc4QzE1MS40MzQgMTUyLjg3NCAxNTEuNDA3IDE1My4wOTkgMTUxLjQwNyAxNTMuMzU0VjE1My41MThDMTUxLjQwNyAxNTMuNzE5IDE1MS40MzQgMTUzLjkwOCAxNTEuNDg5IDE1NC4wODVDMTUxLjU0NiAxNTQuMjU5IDE1MS42MjggMTU0LjQxMyAxNTEuNzM1IDE1NC41NDZDMTUxLjg0NCAxNTQuNjc4IDE1MS45NzYgMTU0Ljc4MyAxNTIuMTI5IDE1NC44NThDMTUyLjI4NiAxNTQuOTM0IDE1Mi40NjMgMTU0Ljk3MSAxNTIuNjYxIDE1NC45NzFDMTUyLjkxNiAxNTQuOTcxIDE1My4xMzIgMTU0LjkxOSAxNTMuMzA5IDE1NC44MTVDMTUzLjQ4NiAxNTQuNzExIDE1My42NDEgMTU0LjU3MiAxNTMuNzc0IDE1NC4zOTdMMTU0LjIxMSAxNTQuNzQ1QzE1NC4xMiAxNTQuODgzIDE1NC4wMDQgMTU1LjAxNCAxNTMuODY0IDE1NS4xMzlDMTUzLjcyMyAxNTUuMjY0IDE1My41NSAxNTUuMzY2IDE1My4zNDQgMTU1LjQ0NEMxNTMuMTQxIDE1NS41MjIgMTUyLjkgMTU1LjU2MSAxNTIuNjIyIDE1NS41NjFaTTE1NS4xODkgMTQ5LjQ4M0gxNTUuOTE1VjE1NC42NjNMMTU1Ljg1MyAxNTUuNDgzSDE1NS4xODlWMTQ5LjQ4M1pNMTU4Ljc3MSAxNTMuMzM1VjE1My40MTdDMTU4Ljc3MSAxNTMuNzI0IDE1OC43MzQgMTU0LjAwOSAxNTguNjYxIDE1NC4yNzJDMTU4LjU4OCAxNTQuNTMzIDE1OC40ODIgMTU0Ljc1OSAxNTguMzQxIDE1NC45NTJDMTU4LjIgMTU1LjE0NSAxNTguMDI4IDE1NS4yOTQgMTU3LjgyNSAxNTUuNDAxQzE1Ny42MjIgMTU1LjUwOCAxNTcuMzg5IDE1NS41NjEgMTU3LjEyNiAxNTUuNTYxQzE1Ni44NTggMTU1LjU2MSAxNTYuNjIyIDE1NS41MTYgMTU2LjQxOSAxNTUuNDI1QzE1Ni4yMTggMTU1LjMzMSAxNTYuMDQ5IDE1NS4xOTcgMTU1LjkxMSAxNTUuMDIyQzE1NS43NzMgMTU0Ljg0OCAxNTUuNjYzIDE1NC42MzcgMTU1LjU3OSAxNTQuMzg5QzE1NS40OTggMTU0LjE0MiAxNTUuNDQyIDE1My44NjMgMTU1LjQxMSAxNTMuNTUzVjE1My4xOTRDMTU1LjQ0MiAxNTIuODgyIDE1NS40OTggMTUyLjYwMiAxNTUuNTc5IDE1Mi4zNTRDMTU1LjY2MyAxNTIuMTA3IDE1NS43NzMgMTUxLjg5NiAxNTUuOTExIDE1MS43MjFDMTU2LjA0OSAxNTEuNTQ0IDE1Ni4yMTggMTUxLjQxIDE1Ni40MTkgMTUxLjMxOUMxNTYuNjIgMTUxLjIyNSAxNTYuODUzIDE1MS4xNzggMTU3LjExOCAxNTEuMTc4QzE1Ny4zODQgMTUxLjE3OCAxNTcuNjIgMTUxLjIzMSAxNTcuODI1IDE1MS4zMzVDMTU4LjAzMSAxNTEuNDM2IDE1OC4yMDMgMTUxLjU4MiAxNTguMzQxIDE1MS43NzJDMTU4LjQ4MiAxNTEuOTYyIDE1OC41ODggMTUyLjE5IDE1OC42NjEgMTUyLjQ1NkMxNTguNzM0IDE1Mi43MTkgMTU4Ljc3MSAxNTMuMDEyIDE1OC43NzEgMTUzLjMzNVpNMTU4LjA0NCAxNTMuNDE3VjE1My4zMzVDMTU4LjA0NCAxNTMuMTI0IDE1OC4wMjQgMTUyLjkyNiAxNTcuOTg1IDE1Mi43NDFDMTU3Ljk0NiAxNTIuNTUzIDE1Ny44ODQgMTUyLjM4OSAxNTcuNzk4IDE1Mi4yNDlDMTU3LjcxMiAxNTIuMTA2IDE1Ny41OTkgMTUxLjk5NCAxNTcuNDU4IDE1MS45MTNDMTU3LjMxNyAxNTEuODMgMTU3LjE0NCAxNTEuNzg4IDE1Ni45MzkgMTUxLjc4OEMxNTYuNzU2IDE1MS43ODggMTU2LjU5NyAxNTEuODE5IDE1Ni40NjIgMTUxLjg4MkMxNTYuMzI5IDE1MS45NDQgMTU2LjIxNiAxNTIuMDI5IDE1Ni4xMjIgMTUyLjEzNUMxNTYuMDI4IDE1Mi4yNCAxNTUuOTUyIDE1Mi4zNTkgMTU1Ljg5MiAxNTIuNDk1QzE1NS44MzQgMTUyLjYyOCAxNTUuNzkxIDE1Mi43NjYgMTU1Ljc2MyAxNTIuOTA5VjE1My44NUMxNTUuODA0IDE1NC4wMzMgMTU1Ljg3MiAxNTQuMjA4IDE1NS45NjYgMTU0LjM3OEMxNTYuMDYyIDE1NC41NDQgMTU2LjE5IDE1NC42ODEgMTU2LjM0OSAxNTQuNzg4QzE1Ni41MSAxNTQuODk1IDE1Ni43MDkgMTU0Ljk0OCAxNTYuOTQ2IDE1NC45NDhDMTU3LjE0MiAxNTQuOTQ4IDE1Ny4zMDggMTU0LjkwOSAxNTcuNDQ2IDE1NC44MzFDMTU3LjU4NyAxNTQuNzUgMTU3LjcgMTU0LjYzOSAxNTcuNzg2IDE1NC40OTlDMTU3Ljg3NSAxNTQuMzU4IDE1Ny45NCAxNTQuMTk1IDE1Ny45ODIgMTU0LjAxQzE1OC4wMjMgMTUzLjgyNiAxNTguMDQ0IDE1My42MjggMTU4LjA0NCAxNTMuNDE3WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8L2c+CjxsaW5lIHgxPSIxNjQuMzUiIHkxPSIxNDcuMjMzIiB4Mj0iMTY0LjM1IiB5Mj0iMTQ2LjQxMSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuNSIgc3Ryb2tlLXdpZHRoPSIwLjUiIHN0cm9rZS1saW5lY2FwPSJzcXVhcmUiLz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXA1XzQxODJfMTExOTMpIj4KPGxpbmUgeDE9IjE4Mi4wNSIgeTE9IjE0Ny4yMzMiIHgyPSIxODIuMDUiIHkyPSIxNDYuNDExIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8cGF0aCBkPSJNMTc0LjEwOSAxNTIuMTg2VjE1My4wNTNDMTc0LjEwOSAxNTMuNTIgMTc0LjA2NyAxNTMuOTEzIDE3My45ODQgMTU0LjIzM0MxNzMuOTAxIDE1NC41NTMgMTczLjc4MSAxNTQuODExIDE3My42MjUgMTU1LjAwN0MxNzMuNDY5IDE1NS4yMDIgMTczLjI4IDE1NS4zNDQgMTczLjA1OCAxNTUuNDMyQzE3Mi44NCAxNTUuNTE4IDE3Mi41OTIgMTU1LjU2MSAxNzIuMzE2IDE1NS41NjFDMTcyLjA5NyAxNTUuNTYxIDE3MS44OTYgMTU1LjUzNCAxNzEuNzExIDE1NS40NzlDMTcxLjUyNiAxNTUuNDI1IDE3MS4zNTkgMTU1LjMzNyAxNzEuMjExIDE1NS4yMThDMTcxLjA2NSAxNTUuMDk1IDE3MC45NCAxNTQuOTM2IDE3MC44MzYgMTU0Ljc0MUMxNzAuNzMyIDE1NC41NDYgMTcwLjY1MiAxNTQuMzA5IDE3MC41OTcgMTU0LjAzQzE3MC41NDMgMTUzLjc1MSAxNzAuNTE1IDE1My40MjYgMTcwLjUxNSAxNTMuMDUzVjE1Mi4xODZDMTcwLjUxNSAxNTEuNzIgMTcwLjU1NyAxNTEuMzMgMTcwLjY0IDE1MS4wMTRDMTcwLjcyNiAxNTAuNjk5IDE3MC44NDcgMTUwLjQ0NyAxNzEuMDA0IDE1MC4yNTdDMTcxLjE2IDE1MC4wNjQgMTcxLjM0NyAxNDkuOTI2IDE3MS41NjYgMTQ5Ljg0M0MxNzEuNzg4IDE0OS43NTkgMTcyLjAzNSAxNDkuNzE4IDE3Mi4zMDggMTQ5LjcxOEMxNzIuNTMgMTQ5LjcxOCAxNzIuNzMzIDE0OS43NDUgMTcyLjkxOCAxNDkuOEMxNzMuMTA1IDE0OS44NTIgMTczLjI3MiAxNDkuOTM2IDE3My40MTggMTUwLjA1M0MxNzMuNTY0IDE1MC4xNjggMTczLjY4NyAxNTAuMzIyIDE3My43ODkgMTUwLjUxNEMxNzMuODkzIDE1MC43MDUgMTczLjk3MiAxNTAuOTM4IDE3NC4wMjcgMTUxLjIxNEMxNzQuMDgyIDE1MS40OSAxNzQuMTA5IDE1MS44MTQgMTc0LjEwOSAxNTIuMTg2Wk0xNzMuMzgzIDE1My4xNzFWMTUyLjA2NUMxNzMuMzgzIDE1MS44MSAxNzMuMzY3IDE1MS41ODYgMTczLjMzNiAxNTEuMzkzQzE3My4zMDcgMTUxLjE5OCAxNzMuMjY0IDE1MS4wMzEgMTczLjIwNyAxNTAuODkzQzE3My4xNSAxNTAuNzU1IDE3My4wNzcgMTUwLjY0MyAxNzIuOTg4IDE1MC41NTdDMTcyLjkwMiAxNTAuNDcxIDE3Mi44MDIgMTUwLjQwOSAxNzIuNjg3IDE1MC4zN0MxNzIuNTc1IDE1MC4zMjggMTcyLjQ0OSAxNTAuMzA3IDE3Mi4zMDggMTUwLjMwN0MxNzIuMTM2IDE1MC4zMDcgMTcxLjk4NCAxNTAuMzQgMTcxLjg1MSAxNTAuNDA1QzE3MS43MTkgMTUwLjQ2OCAxNzEuNjA3IDE1MC41NjggMTcxLjUxNSAxNTAuNzA2QzE3MS40MjcgMTUwLjg0NCAxNzEuMzU5IDE1MS4wMjUgMTcxLjMxMiAxNTEuMjQ5QzE3MS4yNjUgMTUxLjQ3MyAxNzEuMjQyIDE1MS43NDUgMTcxLjI0MiAxNTIuMDY1VjE1My4xNzFDMTcxLjI0MiAxNTMuNDI2IDE3MS4yNTYgMTUzLjY1MSAxNzEuMjg1IDE1My44NDZDMTcxLjMxNiAxNTQuMDQyIDE3MS4zNjIgMTU0LjIxMSAxNzEuNDIyIDE1NC4zNTRDMTcxLjQ4MiAxNTQuNDk1IDE3MS41NTQgMTU0LjYxMSAxNzEuNjQgMTU0LjcwMkMxNzEuNzI2IDE1NC43OTMgMTcxLjgyNSAxNTQuODYxIDE3MS45MzcgMTU0LjkwNUMxNzIuMDUyIDE1NC45NDcgMTcyLjE3OCAxNTQuOTY4IDE3Mi4zMTYgMTU0Ljk2OEMxNzIuNDkzIDE1NC45NjggMTcyLjY0OCAxNTQuOTM0IDE3Mi43ODEgMTU0Ljg2NkMxNzIuOTE0IDE1NC43OTggMTczLjAyNSAxNTQuNjkzIDE3My4xMTMgMTU0LjU1QzE3My4yMDQgMTU0LjQwNCAxNzMuMjcyIDE1NC4yMTggMTczLjMxNiAxNTMuOTkxQzE3My4zNiAxNTMuNzYyIDE3My4zODMgMTUzLjQ4OCAxNzMuMzgzIDE1My4xNzFaTTE3NS44NCAxNTQuODc4SDE3NS45MTRDMTc2LjMzMSAxNTQuODc4IDE3Ni42NyAxNTQuODE5IDE3Ni45MyAxNTQuNzAyQzE3Ny4xOSAxNTQuNTg1IDE3Ny4zOTEgMTU0LjQyNyAxNzcuNTMyIDE1NC4yMjlDMTc3LjY3MiAxNTQuMDMxIDE3Ny43NjkgMTUzLjgwOSAxNzcuODIxIDE1My41NjFDMTc3Ljg3MyAxNTMuMzExIDE3Ny44OTkgMTUzLjA1NSAxNzcuODk5IDE1Mi43OTJWMTUxLjkyMUMxNzcuODk5IDE1MS42NjMgMTc3Ljg2OSAxNTEuNDM0IDE3Ny44MDkgMTUxLjIzM0MxNzcuNzUyIDE1MS4wMzMgMTc3LjY3MSAxNTAuODY1IDE3Ny41NjcgMTUwLjcyOUMxNzcuNDY1IDE1MC41OTQgMTc3LjM0OSAxNTAuNDkxIDE3Ny4yMTkgMTUwLjQyMUMxNzcuMDg5IDE1MC4zNSAxNzYuOTUxIDE1MC4zMTUgMTc2LjgwNSAxNTAuMzE1QzE3Ni42MzggMTUwLjMxNSAxNzYuNDg5IDE1MC4zNDkgMTc2LjM1NiAxNTAuNDE3QzE3Ni4yMjYgMTUwLjQ4MiAxNzYuMTE1IDE1MC41NzQgMTc2LjAyNCAxNTAuNjk0QzE3NS45MzUgMTUwLjgxNCAxNzUuODY4IDE1MC45NTUgMTc1LjgyMSAxNTEuMTE2QzE3NS43NzQgMTUxLjI3NyAxNzUuNzUgMTUxLjQ1MyAxNzUuNzUgMTUxLjY0M0MxNzUuNzUgMTUxLjgxMyAxNzUuNzcxIDE1MS45NzcgMTc1LjgxMyAxNTIuMTM1QzE3NS44NTUgMTUyLjI5NCAxNzUuOTE4IDE1Mi40MzggMTc2LjAwNCAxNTIuNTY1QzE3Ni4wOSAxNTIuNjkzIDE3Ni4xOTcgMTUyLjc5NCAxNzYuMzI1IDE1Mi44N0MxNzYuNDU1IDE1Mi45NDMgMTc2LjYwNyAxNTIuOTc5IDE3Ni43ODIgMTUyLjk3OUMxNzYuOTQzIDE1Mi45NzkgMTc3LjA5NCAxNTIuOTQ4IDE3Ny4yMzUgMTUyLjg4NUMxNzcuMzc4IDE1Mi44MiAxNzcuNTA0IDE1Mi43MzMgMTc3LjYxNCAxNTIuNjI0QzE3Ny43MjYgMTUyLjUxMiAxNzcuODE0IDE1Mi4zODUgMTc3Ljg3OSAxNTIuMjQ1QzE3Ny45NDcgMTUyLjEwNCAxNzcuOTg2IDE1MS45NTcgMTc3Ljk5NiAxNTEuODAzSDE3OC4zNEMxNzguMzQgMTUyLjAyIDE3OC4yOTcgMTUyLjIzMyAxNzguMjExIDE1Mi40NDRDMTc4LjEyOCAxNTIuNjUyIDE3OC4wMTEgMTUyLjg0MyAxNzcuODYgMTUzLjAxNEMxNzcuNzA5IDE1My4xODYgMTc3LjUzMiAxNTMuMzI0IDE3Ny4zMjkgMTUzLjQyOEMxNzcuMTI1IDE1My41MyAxNzYuOTA0IDE1My41ODEgMTc2LjY2NCAxNTMuNTgxQzE3Ni4zODMgMTUzLjU4MSAxNzYuMTQgMTUzLjUyNiAxNzUuOTM0IDE1My40MTdDMTc1LjcyOCAxNTMuMzA3IDE3NS41NTkgMTUzLjE2MiAxNzUuNDI2IDE1Mi45NzlDMTc1LjI5NiAxNTIuNzk3IDE3NS4xOTggMTUyLjU5NCAxNzUuMTMzIDE1Mi4zN0MxNzUuMDcxIDE1Mi4xNDMgMTc1LjAzOSAxNTEuOTE0IDE3NS4wMzkgMTUxLjY4MkMxNzUuMDM5IDE1MS40MTIgMTc1LjA3NyAxNTEuMTU4IDE3NS4xNTMgMTUwLjkyMUMxNzUuMjI4IDE1MC42ODQgMTc1LjM0IDE1MC40NzUgMTc1LjQ4OSAxNTAuMjk2QzE3NS42MzcgMTUwLjExMyAxNzUuODIxIDE0OS45NzEgMTc2LjAzOSAxNDkuODdDMTc2LjI2MSAxNDkuNzY4IDE3Ni41MTYgMTQ5LjcxOCAxNzYuODA1IDE0OS43MThDMTc3LjEzMSAxNDkuNzE4IDE3Ny40MDggMTQ5Ljc4MyAxNzcuNjM3IDE0OS45MTNDMTc3Ljg2NiAxNTAuMDQzIDE3OC4wNTIgMTUwLjIxOCAxNzguMTk2IDE1MC40MzZDMTc4LjM0MiAxNTAuNjU1IDE3OC40NDggMTUwLjkwMSAxNzguNTE2IDE1MS4xNzVDMTc4LjU4NCAxNTEuNDQ4IDE3OC42MTggMTUxLjcyOSAxNzguNjE4IDE1Mi4wMThWMTUyLjI4QzE3OC42MTggMTUyLjU3NCAxNzguNTk4IDE1Mi44NzQgMTc4LjU1OSAxNTMuMTc4QzE3OC41MjMgMTUzLjQ4MSAxNzguNDUxIDE1My43NyAxNzguMzQ0IDE1NC4wNDZDMTc4LjI0IDE1NC4zMjIgMTc4LjA4OCAxNTQuNTY5IDE3Ny44ODcgMTU0Ljc4OEMxNzcuNjg3IDE1NS4wMDQgMTc3LjQyNSAxNTUuMTc2IDE3Ny4xMDIgMTU1LjMwM0MxNzYuNzgyIDE1NS40MjggMTc2LjM4NiAxNTUuNDkxIDE3NS45MTQgMTU1LjQ5MUgxNzUuODRWMTU0Ljg3OFpNMTgyLjcxMyAxNDkuNzk2VjE1NS40ODNIMTgxLjk1OVYxNDkuNzk2SDE4Mi43MTNaTTE4NS4wOTUgMTUyLjM1NFYxNTIuOTcxSDE4Mi41NDhWMTUyLjM1NEgxODUuMDk1Wk0xODUuNDgyIDE0OS43OTZWMTUwLjQxM0gxODIuNTQ4VjE0OS43OTZIMTg1LjQ4MlpNMTg4LjAyMiAxNTUuNTYxQzE4Ny43MjcgMTU1LjU2MSAxODcuNDYgMTU1LjUxMiAxODcuMjIxIDE1NS40MTNDMTg2Ljk4NCAxNTUuMzExIDE4Ni43OCAxNTUuMTY5IDE4Ni42MDggMTU0Ljk4N0MxODYuNDM4IDE1NC44MDUgMTg2LjMwOCAxNTQuNTg5IDE4Ni4yMTcgMTU0LjMzOUMxODYuMTI2IDE1NC4wODkgMTg2LjA4IDE1My44MTUgMTg2LjA4IDE1My41MThWMTUzLjM1NEMxODYuMDggMTUzLjAxIDE4Ni4xMzEgMTUyLjcwNSAxODYuMjMzIDE1Mi40MzZDMTg2LjMzNCAxNTIuMTY1IDE4Ni40NzIgMTUxLjkzNiAxODYuNjQ3IDE1MS43NDlDMTg2LjgyMSAxNTEuNTYxIDE4Ny4wMTkgMTUxLjQxOSAxODcuMjQgMTUxLjMyM0MxODcuNDYyIDE1MS4yMjcgMTg3LjY5MSAxNTEuMTc4IDE4Ny45MjggMTUxLjE3OEMxODguMjMgMTUxLjE3OCAxODguNDkgMTUxLjIzMSAxODguNzA5IDE1MS4zMzVDMTg4LjkzMSAxNTEuNDM5IDE4OS4xMTIgMTUxLjU4NSAxODkuMjUyIDE1MS43NzJDMTg5LjM5MyAxNTEuOTU3IDE4OS40OTcgMTUyLjE3NiAxODkuNTY1IDE1Mi40MjhDMTg5LjYzMiAxNTIuNjc4IDE4OS42NjYgMTUyLjk1MiAxODkuNjY2IDE1My4yNDlWMTUzLjU3M0gxODYuNTFWMTUyLjk4M0gxODguOTQ0VjE1Mi45MjhDMTg4LjkzMyAxNTIuNzQxIDE4OC44OTQgMTUyLjU1OSAxODguODI2IDE1Mi4zODJDMTg4Ljc2MSAxNTIuMjA1IDE4OC42NTcgMTUyLjA1OSAxODguNTE0IDE1MS45NDRDMTg4LjM3MSAxNTEuODMgMTg4LjE3NSAxNTEuNzcyIDE4Ny45MjggMTUxLjc3MkMxODcuNzY0IDE1MS43NzIgMTg3LjYxMyAxNTEuODA3IDE4Ny40NzUgMTUxLjg3OEMxODcuMzM3IDE1MS45NDUgMTg3LjIxOCAxNTIuMDQ3IDE4Ny4xMTkgMTUyLjE4MkMxODcuMDIgMTUyLjMxOCAxODYuOTQ0IDE1Mi40ODMgMTg2Ljg4OSAxNTIuNjc4QzE4Ni44MzQgMTUyLjg3NCAxODYuODA3IDE1My4wOTkgMTg2LjgwNyAxNTMuMzU0VjE1My41MThDMTg2LjgwNyAxNTMuNzE5IDE4Ni44MzQgMTUzLjkwOCAxODYuODg5IDE1NC4wODVDMTg2Ljk0NiAxNTQuMjU5IDE4Ny4wMjggMTU0LjQxMyAxODcuMTM1IDE1NC41NDZDMTg3LjI0NCAxNTQuNjc4IDE4Ny4zNzYgMTU0Ljc4MyAxODcuNTMgMTU0Ljg1OEMxODcuNjg2IDE1NC45MzQgMTg3Ljg2MyAxNTQuOTcxIDE4OC4wNjEgMTU0Ljk3MUMxODguMzE2IDE1NC45NzEgMTg4LjUzMiAxNTQuOTE5IDE4OC43MDkgMTU0LjgxNUMxODguODg2IDE1NC43MTEgMTg5LjA0MSAxNTQuNTcyIDE4OS4xNzQgMTU0LjM5N0wxODkuNjEyIDE1NC43NDVDMTg5LjUyIDE1NC44ODMgMTg5LjQwNSAxNTUuMDE0IDE4OS4yNjQgMTU1LjEzOUMxODkuMTIzIDE1NS4yNjQgMTg4Ljk1IDE1NS4zNjYgMTg4Ljc0NCAxNTUuNDQ0QzE4OC41NDEgMTU1LjUyMiAxODguMyAxNTUuNTYxIDE4OC4wMjIgMTU1LjU2MVpNMTkwLjU4OSAxNDkuNDgzSDE5MS4zMTVWMTU0LjY2M0wxOTEuMjUzIDE1NS40ODNIMTkwLjU4OVYxNDkuNDgzWk0xOTQuMTcxIDE1My4zMzVWMTUzLjQxN0MxOTQuMTcxIDE1My43MjQgMTk0LjEzNCAxNTQuMDA5IDE5NC4wNjEgMTU0LjI3MkMxOTMuOTg4IDE1NC41MzMgMTkzLjg4MiAxNTQuNzU5IDE5My43NDEgMTU0Ljk1MkMxOTMuNiAxNTUuMTQ1IDE5My40MjkgMTU1LjI5NCAxOTMuMjI1IDE1NS40MDFDMTkzLjAyMiAxNTUuNTA4IDE5Mi43ODkgMTU1LjU2MSAxOTIuNTI2IDE1NS41NjFDMTkyLjI1OCAxNTUuNTYxIDE5Mi4wMjIgMTU1LjUxNiAxOTEuODE5IDE1NS40MjVDMTkxLjYxOSAxNTUuMzMxIDE5MS40NDkgMTU1LjE5NyAxOTEuMzExIDE1NS4wMjJDMTkxLjE3MyAxNTQuODQ4IDE5MS4wNjMgMTU0LjYzNyAxOTAuOTc5IDE1NC4zODlDMTkwLjg5OSAxNTQuMTQyIDE5MC44NDMgMTUzLjg2MyAxOTAuODExIDE1My41NTNWMTUzLjE5NEMxOTAuODQzIDE1Mi44ODIgMTkwLjg5OSAxNTIuNjAyIDE5MC45NzkgMTUyLjM1NEMxOTEuMDYzIDE1Mi4xMDcgMTkxLjE3MyAxNTEuODk2IDE5MS4zMTEgMTUxLjcyMUMxOTEuNDQ5IDE1MS41NDQgMTkxLjYxOSAxNTEuNDEgMTkxLjgxOSAxNTEuMzE5QzE5Mi4wMiAxNTEuMjI1IDE5Mi4yNTMgMTUxLjE3OCAxOTIuNTE4IDE1MS4xNzhDMTkyLjc4NCAxNTEuMTc4IDE5My4wMiAxNTEuMjMxIDE5My4yMjUgMTUxLjMzNUMxOTMuNDMxIDE1MS40MzYgMTkzLjYwMyAxNTEuNTgyIDE5My43NDEgMTUxLjc3MkMxOTMuODgyIDE1MS45NjIgMTkzLjk4OCAxNTIuMTkgMTk0LjA2MSAxNTIuNDU2QzE5NC4xMzQgMTUyLjcxOSAxOTQuMTcxIDE1My4wMTIgMTk0LjE3MSAxNTMuMzM1Wk0xOTMuNDQ0IDE1My40MTdWMTUzLjMzNUMxOTMuNDQ0IDE1My4xMjQgMTkzLjQyNSAxNTIuOTI2IDE5My4zODYgMTUyLjc0MUMxOTMuMzQ3IDE1Mi41NTMgMTkzLjI4NCAxNTIuMzg5IDE5My4xOTggMTUyLjI0OUMxOTMuMTEyIDE1Mi4xMDYgMTkyLjk5OSAxNTEuOTk0IDE5Mi44NTggMTUxLjkxM0MxOTIuNzE4IDE1MS44MyAxOTIuNTQ0IDE1MS43ODggMTkyLjMzOSAxNTEuNzg4QzE5Mi4xNTYgMTUxLjc4OCAxOTEuOTk4IDE1MS44MTkgMTkxLjg2MiAxNTEuODgyQzE5MS43MjkgMTUxLjk0NCAxOTEuNjE2IDE1Mi4wMjkgMTkxLjUyMiAxNTIuMTM1QzE5MS40MjkgMTUyLjI0IDE5MS4zNTIgMTUyLjM1OSAxOTEuMjkyIDE1Mi40OTVDMTkxLjIzNSAxNTIuNjI4IDE5MS4xOTIgMTUyLjc2NiAxOTEuMTYzIDE1Mi45MDlWMTUzLjg1QzE5MS4yMDUgMTU0LjAzMyAxOTEuMjcyIDE1NC4yMDggMTkxLjM2NiAxNTQuMzc4QzE5MS40NjIgMTU0LjU0NCAxOTEuNTkgMTU0LjY4MSAxOTEuNzQ5IDE1NC43ODhDMTkxLjkxIDE1NC44OTUgMTkyLjExIDE1NC45NDggMTkyLjM0NyAxNTQuOTQ4QzE5Mi41NDIgMTU0Ljk0OCAxOTIuNzA4IDE1NC45MDkgMTkyLjg0NyAxNTQuODMxQzE5Mi45ODcgMTU0Ljc1IDE5My4xIDE1NC42MzkgMTkzLjE4NiAxNTQuNDk5QzE5My4yNzUgMTU0LjM1OCAxOTMuMzQgMTU0LjE5NSAxOTMuMzgyIDE1NC4wMUMxOTMuNDIzIDE1My44MjYgMTkzLjQ0NCAxNTMuNjI4IDE5My40NDQgMTUzLjQxN1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPC9nPgo8Y2lyY2xlIGN4PSI0MSIgY3k9IjczLjE2MTEiIHI9IjMiIGZpbGw9IiMyMTk2RjMiLz4KPGNpcmNsZSBjeD0iNTkiIGN5PSI1MyIgcj0iMyIgZmlsbD0iIzIxOTZGMyIvPgo8Y2lyY2xlIGN4PSI3NyIgY3k9IjgxLjE2MTEiIHI9IjMiIGZpbGw9IiM0Q0FGNTAiLz4KPGNpcmNsZSBjeD0iOTUiIGN5PSI4NyIgcj0iMyIgZmlsbD0iIzRDQUY1MCIvPgo8Y2lyY2xlIGN4PSI0MSIgY3k9IjExMy4xNjEiIHI9IjMiIGZpbGw9IiM0Q0FGNTAiLz4KPGNpcmNsZSBjeD0iNTkiIGN5PSI5MyIgcj0iMyIgZmlsbD0iIzRDQUY1MCIvPgo8Y2lyY2xlIGN4PSIxMTIiIGN5PSI5MC4xNjExIiByPSIzIiBmaWxsPSIjNENBRjUwIi8+CjxjaXJjbGUgY3g9IjE0OCIgY3k9IjgxLjE2MTEiIHI9IjMiIGZpbGw9IiM0Q0FGNTAiLz4KPGNpcmNsZSBjeD0iMTMwIiBjeT0iMTAzIiByPSIzIiBmaWxsPSIjNENBRjUwIi8+CjxjaXJjbGUgY3g9IjE4MyIgY3k9IjEyMS4xNjEiIHI9IjMiIGZpbGw9IiM0Q0FGNTAiLz4KPGNpcmNsZSBjeD0iMTY2IiBjeT0iMTA5IiByPSIzIiBmaWxsPSIjNENBRjUwIi8+CjxjaXJjbGUgY3g9Ijc3IiBjeT0iMzciIHI9IjMiIGZpbGw9IiMyMTk2RjMiLz4KPGNpcmNsZSBjeD0iOTUiIGN5PSIzOSIgcj0iMyIgZmlsbD0iIzIxOTZGMyIvPgo8Y2lyY2xlIGN4PSIxMTIiIGN5PSIxOS4xNjExIiByPSIzIiBmaWxsPSIjMjE5NkYzIi8+CjxjaXJjbGUgY3g9IjE0OCIgY3k9IjM3IiByPSIzIiBmaWxsPSIjMjE5NkYzIi8+CjxjaXJjbGUgY3g9IjEzMCIgY3k9IjQ0IiByPSIzIiBmaWxsPSIjMjE5NkYzIi8+CjxjaXJjbGUgY3g9IjE4MyIgY3k9IjIzIiByPSIzIiBmaWxsPSIjMjE5NkYzIi8+CjxjaXJjbGUgY3g9IjE2NiIgY3k9IjQ0IiByPSIzIiBmaWxsPSIjMjE5NkYzIi8+CjxjaXJjbGUgY3g9Ijc3IiBjeT0iNzIiIHI9IjMiIGZpbGw9IiNGRkMxMDciLz4KPGNpcmNsZSBjeD0iOTUiIGN5PSI1NSIgcj0iMyIgZmlsbD0iI0ZGQzEwNyIvPgo8Y2lyY2xlIGN4PSIxMTIiIGN5PSI2Ni4xNjExIiByPSIzIiBmaWxsPSIjRkZDMTA3Ii8+CjxjaXJjbGUgY3g9IjE0OCIgY3k9IjUxLjE2MTEiIHI9IjMiIGZpbGw9IiNGRkMxMDciLz4KPGNpcmNsZSBjeD0iMTMwIiBjeT0iNzIiIHI9IjMiIGZpbGw9IiNGRkMxMDciLz4KPGNpcmNsZSBjeD0iMTgzIiBjeT0iODEiIHI9IjMiIGZpbGw9IiNGRkMxMDciLz4KPGNpcmNsZSBjeD0iMTY2IiBjeT0iNTkiIHI9IjMiIGZpbGw9IiNGRkMxMDciLz4KPGNpcmNsZSBjeD0iMjgiIGN5PSI4OSIgcj0iMyIgZmlsbD0iI0ZGQzEwNyIvPgo8Y2lyY2xlIGN4PSI0MSIgY3k9Ijk3IiByPSIzIiBmaWxsPSIjRkZDMTA3Ii8+CjxjaXJjbGUgY3g9IjU5IiBjeT0iNjguNTc4MSIgcj0iMyIgZmlsbD0iI0ZGQzEwNyIvPgo8Y2lyY2xlIGN4PSIyOCIgY3k9IjExOC4xNjEiIHI9IjMiIGZpbGw9IiMyMTk2RjMiLz4KPGNpcmNsZSBjeD0iMjgiIGN5PSIxMjkuNDg5IiByPSIzIiBmaWxsPSIjNENBRjUwIi8+CjwvZz4KPGRlZnM+CjxjbGlwUGF0aCBpZD0iY2xpcDBfNDE4Ml8xMTE5MyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTYwIiBmaWxsPSJ3aGl0ZSIvPgo8L2NsaXBQYXRoPgo8Y2xpcFBhdGggaWQ9ImNsaXAxXzQxODJfMTExOTMiPgo8cmVjdCB3aWR0aD0iMzUuNCIgaGVpZ2h0PSIxMCIgZmlsbD0id2hpdGUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIzIDE0Ni4xNjEpIi8+CjwvY2xpcFBhdGg+CjxjbGlwUGF0aCBpZD0iY2xpcDJfNDE4Ml8xMTE5MyI+CjxyZWN0IHdpZHRoPSIzNS40IiBoZWlnaHQ9IjEwLjMyMiIgZmlsbD0id2hpdGUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDU4LjM5OTkgMTQ2LjE2MSkiLz4KPC9jbGlwUGF0aD4KPGNsaXBQYXRoIGlkPSJjbGlwM180MTgyXzExMTkzIj4KPHJlY3Qgd2lkdGg9IjM1LjQiIGhlaWdodD0iMTAuMzIyIiBmaWxsPSJ3aGl0ZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoOTMuOCAxNDYuMTYxKSIvPgo8L2NsaXBQYXRoPgo8Y2xpcFBhdGggaWQ9ImNsaXA0XzQxODJfMTExOTMiPgo8cmVjdCB3aWR0aD0iMzUuNCIgaGVpZ2h0PSIxMC4zMjIiIGZpbGw9IndoaXRlIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxMjkuMiAxNDYuMTYxKSIvPgo8L2NsaXBQYXRoPgo8Y2xpcFBhdGggaWQ9ImNsaXA1XzQxODJfMTExOTMiPgo8cmVjdCB3aWR0aD0iMzUuNCIgaGVpZ2h0PSIxMC4zMjIiIGZpbGw9IndoaXRlIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxNjQuNiAxNDYuMTYxKSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo=", - "description": "Displays changes to time-series data over time—for example, temperature or humidity readings.", + "description": "Displays changes to time series data over time—for example, temperature or humidity readings.", "descriptor": { "type": "timeseries", "sizeX": 8, diff --git a/application/src/main/data/json/system/widget_types/polar_area.json b/application/src/main/data/json/system/widget_types/polar_area.json index 8d4a0b24a6..e800c7ec5f 100644 --- a/application/src/main/data/json/system/widget_types/polar_area.json +++ b/application/src/main/data/json/system/widget_types/polar_area.json @@ -3,7 +3,7 @@ "name": "Polar area", "deprecated": false, "image": "tb-image:cG9sYXItYXJlYS5zdmc=:IlBvbGFyIGFyZWEiIHN5c3RlbSB3aWRnZXQgaW1hZ2U=;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTYwIiByeD0iNCIgZmlsbD0id2hpdGUiLz4KPGNpcmNsZSBjeD0iOTkuNDc3NiIgY3k9IjgwIiByPSI2OS43Mzg4IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC4zOCIgc3Ryb2tlLXdpZHRoPSIwLjUyMjM4OCIvPgo8Y2lyY2xlIGN4PSI5OS40Nzc2IiBjeT0iODAiIHI9IjUzLjAyMjQiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjEyIiBzdHJva2Utd2lkdGg9IjAuNTIyMzg4Ii8+CjxjaXJjbGUgY3g9Ijk5LjQ3NzYiIGN5PSI4MCIgcj0iMzYuMzA2IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC4xMiIgc3Ryb2tlLXdpZHRoPSIwLjUyMjM4OCIvPgo8Y2lyY2xlIGN4PSI5OS40Nzc2IiBjeT0iODAiIHI9IjE5LjU4OTYiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjEyIiBzdHJva2Utd2lkdGg9IjAuNTIyMzg4Ii8+CjxjaXJjbGUgY3g9Ijk5LjQ3NzYiIGN5PSI4MC4wMDAxIiByPSIyLjg3MzEzIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC4xMiIgc3Ryb2tlLXdpZHRoPSIwLjUyMjM4OCIvPgo8cGF0aCBkPSJNOTkuNDc3NiAxMC41MjIzVjgwIiBzdHJva2U9IiM5RTlFOUUiIHN0cm9rZS13aWR0aD0iMC41MjIzODgiLz4KPHBhdGggZD0iTTk5LjQ3NzYgMjYuNzE2NEg5Ny4zODgxIiBzdHJva2U9IiM5RTlFOUUiIHN0cm9rZS13aWR0aD0iMC41MjIzODgiLz4KPHBhdGggZD0iTTk5LjQ3NzYgNDMuNDMyOUg5Ny4zODgxIiBzdHJva2U9IiM5RTlFOUUiIHN0cm9rZS13aWR0aD0iMC41MjIzODgiLz4KPHBhdGggZD0iTTk5LjQ3NzYgNjAuMTQ5M0g5Ny4zODgxIiBzdHJva2U9IiM5RTlFOUUiIHN0cm9rZS13aWR0aD0iMC41MjIzODgiLz4KPHJlY3Qgd2lkdGg9IjE1IiBoZWlnaHQ9IjguMzU4MjEiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDgyLjc2MTIgMzkuMjUzOCkiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik04NS45MjI5IDM5Ljk1OFY0NS45MzI5SDg1LjE2NzlWNDAuOTAwOEw4My42NDU2IDQxLjQ1NThWNDAuNzc0M0w4NS44MDQ2IDM5Ljk1OEg4NS45MjI5Wk05Mi4xMzQ1IDQyLjQ4ODRWNDMuMzk0NEM5Mi4xMzQ1IDQzLjg4MTQgOTIuMDkwOSA0NC4yOTIyIDkyLjAwMzkgNDQuNjI2OUM5MS45MTY4IDQ0Ljk2MTUgOTEuNzkxNiA0NS4yMzA5IDkxLjYyODQgNDUuNDM1QzkxLjQ2NTEgNDUuNjM5IDkxLjI2NzkgNDUuNzg3MyA5MS4wMzY2IDQ1Ljg3OThDOTAuODA4MSA0NS45Njk2IDkwLjU0OTYgNDYuMDE0NSA5MC4yNjEyIDQ2LjAxNDVDOTAuMDMyNyA0Ni4wMTQ1IDg5LjgyMTggNDUuOTg1OSA4OS42Mjg2IDQ1LjkyODhDODkuNDM1NCA0NS44NzE2IDg5LjI2MTMgNDUuNzgwNSA4OS4xMDYyIDQ1LjY1NTNDODguOTUzOSA0NS41Mjc1IDg4LjgyMzMgNDUuMzYxNSA4OC43MTQ0IDQ1LjE1NzRDODguNjA1NiA0NC45NTM0IDg4LjUyMjYgNDQuNzA1OCA4OC40NjU1IDQ0LjQxNDdDODguNDA4NCA0NC4xMjM1IDg4LjM3OTggNDMuNzgzNSA4OC4zNzk4IDQzLjM5NDRWNDIuNDg4NEM4OC4zNzk4IDQyLjAwMTMgODguNDIzMyA0MS41OTMyIDg4LjUxMDQgNDEuMjY0Qzg4LjYwMDIgNDAuOTM0OCA4OC43MjY3IDQwLjY3MDkgODguODg5OSA0MC40NzIzQzg5LjA1MzIgNDAuMjcwOSA4OS4yNDkxIDQwLjEyNjcgODkuNDc3NiA0MC4wMzk3Qzg5LjcwODkgMzkuOTUyNiA4OS45Njc0IDM5LjkwOTEgOTAuMjUzIDM5LjkwOTFDOTAuNDg0MyAzOS45MDkxIDkwLjY5NjUgMzkuOTM3NiA5MC44ODk3IDM5Ljk5NDhDOTEuMDg1NiA0MC4wNDkyIDkxLjI1OTcgNDAuMTM3NiA5MS40MTIxIDQwLjI2MDFDOTEuNTY0NCA0MC4zNzk4IDkxLjY5MzcgNDAuNTQwMyA5MS43OTk4IDQwLjc0MTZDOTEuOTA4NiA0MC45NDAyIDkxLjk5MTYgNDEuMTgzOCA5Mi4wNDg3IDQxLjQ3MjJDOTIuMTA1OSA0MS43NjA2IDkyLjEzNDUgNDIuMDk5MyA5Mi4xMzQ1IDQyLjQ4ODRaTTkxLjM3NTQgNDMuNTE2OFY0Mi4zNjE4QzkxLjM3NTQgNDIuMDk1MiA5MS4zNTkgNDEuODYxMiA5MS4zMjY0IDQxLjY1OTlDOTEuMjk2NSA0MS40NTU4IDkxLjI1MTYgNDEuMjgxNyA5MS4xOTE3IDQxLjEzNzVDOTEuMTMxOCA0MC45OTMzIDkxLjA1NTcgNDAuODc2MyA5MC45NjMyIDQwLjc4NjVDOTAuODczNCA0MC42OTY3IDkwLjc2ODYgNDAuNjMxNCA5MC42NDg5IDQwLjU5MDZDOTAuNTMxOSA0MC41NDcxIDkwLjQgNDAuNTI1MyA5MC4yNTMgNDAuNTI1M0M5MC4wNzM1IDQwLjUyNTMgODkuOTE0MyA0MC41NTkzIDg5Ljc3NTUgNDAuNjI3NEM4OS42MzY4IDQwLjY5MjcgODkuNTE5OCA0MC43OTc0IDg5LjQyNDYgNDAuOTQxNkM4OS4zMzIxIDQxLjA4NTggODkuMjYxMyA0MS4yNzQ5IDg5LjIxMjMgNDEuNTA4OUM4OS4xNjM0IDQxLjc0MjkgODkuMTM4OSA0Mi4wMjcyIDg5LjEzODkgNDIuMzYxOFY0My41MTY4Qzg5LjEzODkgNDMuNzgzNSA4OS4xNTM4IDQ0LjAxODggODkuMTgzOCA0NC4yMjI5Qzg5LjIxNjQgNDQuNDI2OSA4OS4yNjQgNDQuNjAzOCA4OS4zMjY2IDQ0Ljc1MzRDODkuMzg5MiA0NC45MDAzIDg5LjQ2NTQgNDUuMDIxNCA4OS41NTUyIDQ1LjExNjZDODkuNjQ0OSA0NS4yMTE5IDg5Ljc0ODMgNDUuMjgyNiA4OS44NjUzIDQ1LjMyODlDODkuOTg1IDQ1LjM3MjQgOTAuMTE3IDQ1LjM5NDEgOTAuMjYxMiA0NS4zOTQxQzkwLjQ0NjIgNDUuMzk0MSA5MC42MDgxIDQ1LjM1ODggOTAuNzQ2OSA0NS4yODhDOTAuODg1NiA0NS4yMTczIDkxLjAwMTIgNDUuMTA3MSA5MS4wOTM4IDQ0Ljk1NzVDOTEuMTg5IDQ0LjgwNTEgOTEuMjU5NyA0NC42MTA2IDkxLjMwNiA0NC4zNzM5QzkxLjM1MjIgNDQuMTM0NCA5MS4zNzU0IDQzLjg0ODggOTEuMzc1NCA0My41MTY4Wk05Ny4wOTcxIDQyLjQ4ODRWNDMuMzk0NEM5Ny4wOTcxIDQzLjg4MTQgOTcuMDUzNiA0NC4yOTIyIDk2Ljk2NjUgNDQuNjI2OUM5Ni44Nzk1IDQ0Ljk2MTUgOTYuNzU0MyA0NS4yMzA5IDk2LjU5MTEgNDUuNDM1Qzk2LjQyNzggNDUuNjM5IDk2LjIzMDYgNDUuNzg3MyA5NS45OTkzIDQ1Ljg3OThDOTUuNzcwOCA0NS45Njk2IDk1LjUxMjMgNDYuMDE0NSA5NS4yMjM5IDQ2LjAxNDVDOTQuOTk1MyA0Ni4wMTQ1IDk0Ljc4NDUgNDUuOTg1OSA5NC41OTEzIDQ1LjkyODhDOTQuMzk4MSA0NS44NzE2IDk0LjIyNCA0NS43ODA1IDk0LjA2ODkgNDUuNjU1M0M5My45MTY2IDQ1LjUyNzUgOTMuNzg2IDQ1LjM2MTUgOTMuNjc3MSA0NS4xNTc0QzkzLjU2ODMgNDQuOTUzNCA5My40ODUzIDQ0LjcwNTggOTMuNDI4MiA0NC40MTQ3QzkzLjM3MSA0NC4xMjM1IDkzLjM0MjUgNDMuNzgzNSA5My4zNDI1IDQzLjM5NDRWNDIuNDg4NEM5My4zNDI1IDQyLjAwMTMgOTMuMzg2IDQxLjU5MzIgOTMuNDczMSA0MS4yNjRDOTMuNTYyOSA0MC45MzQ4IDkzLjY4OTQgNDAuNjcwOSA5My44NTI2IDQwLjQ3MjNDOTQuMDE1OSA0MC4yNzA5IDk0LjIxMTggNDAuMTI2NyA5NC40NDAzIDQwLjAzOTdDOTQuNjcxNiAzOS45NTI2IDk0LjkzIDM5LjkwOTEgOTUuMjE1NyAzOS45MDkxQzk1LjQ0NyAzOS45MDkxIDk1LjY1OTIgMzkuOTM3NiA5NS44NTI0IDM5Ljk5NDhDOTYuMDQ4MyA0MC4wNDkyIDk2LjIyMjQgNDAuMTM3NiA5Ni4zNzQ4IDQwLjI2MDFDOTYuNTI3MSA0MC4zNzk4IDk2LjY1NjQgNDAuNTQwMyA5Ni43NjI1IDQwLjc0MTZDOTYuODcxMyA0MC45NDAyIDk2Ljk1NDMgNDEuMTgzOCA5Ny4wMTE0IDQxLjQ3MjJDOTcuMDY4NiA0MS43NjA2IDk3LjA5NzEgNDIuMDk5MyA5Ny4wOTcxIDQyLjQ4ODRaTTk2LjMzOCA0My41MTY4VjQyLjM2MThDOTYuMzM4IDQyLjA5NTIgOTYuMzIxNyA0MS44NjEyIDk2LjI4OTEgNDEuNjU5OUM5Ni4yNTkxIDQxLjQ1NTggOTYuMjE0MiA0MS4yODE3IDk2LjE1NDQgNDEuMTM3NUM5Ni4wOTQ1IDQwLjk5MzMgOTYuMDE4NCA0MC44NzYzIDk1LjkyNTggNDAuNzg2NUM5NS44MzYxIDQwLjY5NjcgOTUuNzMxMyA0MC42MzE0IDk1LjYxMTYgNDAuNTkwNkM5NS40OTQ2IDQwLjU0NzEgOTUuMzYyNiA0MC41MjUzIDk1LjIxNTcgNDAuNTI1M0M5NS4wMzYyIDQwLjUyNTMgOTQuODc3IDQwLjU1OTMgOTQuNzM4MiA0MC42Mjc0Qzk0LjU5OTUgNDAuNjkyNyA5NC40ODI1IDQwLjc5NzQgOTQuMzg3MiA0MC45NDE2Qzk0LjI5NDcgNDEuMDg1OCA5NC4yMjQgNDEuMjc0OSA5NC4xNzUgNDEuNTA4OUM5NC4xMjYxIDQxLjc0MjkgOTQuMTAxNiA0Mi4wMjcyIDk0LjEwMTYgNDIuMzYxOFY0My41MTY4Qzk0LjEwMTYgNDMuNzgzNSA5NC4xMTY1IDQ0LjAxODggOTQuMTQ2NSA0NC4yMjI5Qzk0LjE3OTEgNDQuNDI2OSA5NC4yMjY3IDQ0LjYwMzggOTQuMjg5MyA0NC43NTM0Qzk0LjM1MTkgNDQuOTAwMyA5NC40MjgxIDQ1LjAyMTQgOTQuNTE3OCA0NS4xMTY2Qzk0LjYwNzYgNDUuMjExOSA5NC43MTEgNDUuMjgyNiA5NC44MjggNDUuMzI4OUM5NC45NDc3IDQ1LjM3MjQgOTUuMDc5NyA0NS4zOTQxIDk1LjIyMzkgNDUuMzk0MUM5NS40MDg5IDQ1LjM5NDEgOTUuNTcwOCA0NS4zNTg4IDk1LjcwOTUgNDUuMjg4Qzk1Ljg0ODMgNDUuMjE3MyA5NS45NjM5IDQ1LjEwNzEgOTYuMDU2NCA0NC45NTc1Qzk2LjE1MTcgNDQuODA1MSA5Ni4yMjI0IDQ0LjYxMDYgOTYuMjY4NyA0NC4zNzM5Qzk2LjMxNDkgNDQuMTM0NCA5Ni4zMzggNDMuODQ4OCA5Ni4zMzggNDMuNTE2OFoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHJlY3Qgd2lkdGg9IjEwIiBoZWlnaHQ9IjguMzU4MjEiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDg2Ljk0MDMgNTUuOTcwMSkiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik04OC41NTcgNTkuODIwOUw4Ny45NTMgNTkuNjY1OEw4OC4yNTA5IDU2LjcwN0g5MS4yOTk2VjU3LjQwNDlIODguODkxN0w4OC43MTIxIDU5LjAyMUM4OC44MjA5IDU4Ljk1ODQgODguOTU4MyA1OC44OTk5IDg5LjEyNDMgNTguODQ1NUM4OS4yOTMgNTguNzkxMSA4OS40ODYyIDU4Ljc2MzkgODkuNzAzOCA1OC43NjM5Qzg5Ljk3ODYgNTguNzYzOSA5MC4yMjQ5IDU4LjgxMTUgOTAuNDQyNSA1OC45MDY3QzkwLjY2MDIgNTguOTk5MyA5MC44NDUyIDU5LjEzMjYgOTAuOTk3NiA1OS4zMDY3QzkxLjE1MjYgNTkuNDgwOCA5MS4yNzEgNTkuNjkwMyA5MS4zNTI2IDU5LjkzNTJDOTEuNDM0MiA2MC4xODAxIDkxLjQ3NTEgNjAuNDUzNSA5MS40NzUxIDYwLjc1NTVDOTEuNDc1MSA2MS4wNDEyIDkxLjQzNTYgNjEuMzAzNyA5MS4zNTY3IDYxLjU0MzJDOTEuMjgwNSA2MS43ODI2IDkxLjE2NDkgNjEuOTkyMSA5MS4wMDk4IDYyLjE3MTdDOTAuODU0NyA2Mi4zNDg1IDkwLjY1ODggNjIuNDg1OSA5MC40MjIxIDYyLjU4MzlDOTAuMTg4MSA2Mi42ODE4IDg5LjkxMiA2Mi43MzA4IDg5LjU5MzYgNjIuNzMwOEM4OS4zNTQyIDYyLjczMDggODkuMTI3IDYyLjY5ODEgODguOTEyMSA2Mi42MzI4Qzg4LjY5OTkgNjIuNTY0OCA4OC41MDk0IDYyLjQ2MjggODguMzQwNyA2Mi4zMjY4Qzg4LjE3NDggNjIuMTg4IDg4LjAzODcgNjIuMDE2NiA4Ny45MzI2IDYxLjgxMjVDODcuODI5MiA2MS42MDU4IDg3Ljc2MzkgNjEuMzYzNiA4Ny43MzY3IDYxLjA4NjFIODguNDU1Qzg4LjQ4NzYgNjEuMzA5MiA4OC41NTI5IDYxLjQ5NjkgODguNjUwOSA2MS42NDkzQzg4Ljc0ODggNjEuODAxNiA4OC44NzY3IDYxLjkxNzMgODkuMDM0NSA2MS45OTYyQzg5LjE5NSA2Mi4wNzI0IDg5LjM4MTQgNjIuMTEwNSA4OS41OTM2IDYyLjExMDVDODkuNzczMiA2Mi4xMTA1IDg5LjkzMjQgNjIuMDc5MiA5MC4wNzExIDYyLjAxNjZDOTAuMjA5OSA2MS45NTQgOTAuMzI2OSA2MS44NjQyIDkwLjQyMjEgNjEuNzQ3MkM5MC41MTczIDYxLjYzMDIgOTAuNTg5NCA2MS40ODg4IDkwLjYzODQgNjEuMzIyOEM5MC42OTAxIDYxLjE1NjggOTAuNzE2IDYwLjk3MDUgOTAuNzE2IDYwLjc2MzdDOTAuNzE2IDYwLjU3NTkgOTAuNjkwMSA2MC40MDE4IDkwLjYzODQgNjAuMjQxM0M5MC41ODY3IDYwLjA4MDggOTAuNTA5MiA1OS45NDA2IDkwLjQwNTggNTkuODIwOUM5MC4zMDUxIDU5LjcwMTIgOTAuMTgxMyA1OS42MDg3IDkwLjAzNDQgNTkuNTQzNEM4OS44ODc1IDU5LjQ3NTQgODkuNzE4OCA1OS40NDE0IDg5LjUyODMgNTkuNDQxNEM4OS4yNzUzIDU5LjQ0MTQgODkuMDgzNSA1OS40NzU0IDg4Ljk1MjkgNTkuNTQzNEM4OC44MjUgNTkuNjExNCA4OC42OTMxIDU5LjcwMzkgODguNTU3IDU5LjgyMDlaTTk2LjI5NDkgNTkuMjA0N1Y2MC4xMTA3Qzk2LjI5NDkgNjAuNTk3NyA5Ni4yNTE0IDYxLjAwODUgOTYuMTY0MyA2MS4zNDMyQzk2LjA3NzIgNjEuNjc3OSA5NS45NTIxIDYxLjk0NzIgOTUuNzg4OCA2Mi4xNTEzQzk1LjYyNTYgNjIuMzU1MyA5NS40MjgzIDYyLjUwMzYgOTUuMTk3MSA2Mi41OTYxQzk0Ljk2ODUgNjIuNjg1OSA5NC43MTAxIDYyLjczMDggOTQuNDIxNyA2Mi43MzA4Qzk0LjE5MzEgNjIuNzMwOCA5My45ODIyIDYyLjcwMjIgOTMuNzg5MSA2Mi42NDUxQzkzLjU5NTkgNjIuNTg4IDkzLjQyMTggNjIuNDk2OCA5My4yNjY3IDYyLjM3MTdDOTMuMTE0MyA2Mi4yNDM4IDkyLjk4MzcgNjIuMDc3OCA5Mi44NzQ5IDYxLjg3MzhDOTIuNzY2MSA2MS42Njk3IDkyLjY4MzEgNjEuNDIyMSA5Mi42MjU5IDYxLjEzMUM5Mi41Njg4IDYwLjgzOTkgOTIuNTQwMiA2MC40OTk4IDkyLjU0MDIgNjAuMTEwN1Y1OS4yMDQ3QzkyLjU0MDIgNTguNzE3NyA5Mi41ODM4IDU4LjMwOTUgOTIuNjcwOCA1Ny45ODAzQzkyLjc2MDYgNTcuNjUxMSA5Mi44ODcxIDU3LjM4NzIgOTMuMDUwNCA1Ny4xODg2QzkzLjIxMzYgNTYuOTg3MiA5My40MDk1IDU2Ljg0MyA5My42MzgxIDU2Ljc1NkM5My44NjkzIDU2LjY2ODkgOTQuMTI3OCA1Ni42MjU0IDk0LjQxMzUgNTYuNjI1NEM5NC42NDQ4IDU2LjYyNTQgOTQuODU3IDU2LjY1NCA5NS4wNTAxIDU2LjcxMTFDOTUuMjQ2IDU2Ljc2NTUgOTUuNDIwMiA1Ni44NTM5IDk1LjU3MjUgNTYuOTc2NEM5NS43MjQ5IDU3LjA5NjEgOTUuODU0MSA1Ny4yNTY2IDk1Ljk2MDIgNTcuNDU3OUM5Ni4wNjkxIDU3LjY1NjYgOTYuMTUyMSA1Ny45MDAxIDk2LjIwOTIgNTguMTg4NUM5Ni4yNjYzIDU4LjQ3NjkgOTYuMjk0OSA1OC44MTU2IDk2LjI5NDkgNTkuMjA0N1pNOTUuNTM1OCA2MC4yMzMxVjU5LjA3ODJDOTUuNTM1OCA1OC44MTE1IDk1LjUxOTUgNTguNTc3NSA5NS40ODY4IDU4LjM3NjJDOTUuNDU2OSA1OC4xNzIxIDk1LjQxMiA1Ny45OTggOTUuMzUyMiA1Ny44NTM4Qzk1LjI5MjMgNTcuNzA5NiA5NS4yMTYxIDU3LjU5MjYgOTUuMTIzNiA1Ny41MDI4Qzk1LjAzMzggNTcuNDEzIDk0LjkyOTEgNTcuMzQ3NyA5NC44MDk0IDU3LjMwNjlDOTQuNjkyNCA1Ny4yNjM0IDk0LjU2MDQgNTcuMjQxNiA5NC40MTM1IDU3LjI0MTZDOTQuMjMzOSA1Ny4yNDE2IDk0LjA3NDggNTcuMjc1NiA5My45MzYgNTcuMzQzN0M5My43OTcyIDU3LjQwOSA5My42ODAyIDU3LjUxMzcgOTMuNTg1IDU3LjY1NzlDOTMuNDkyNSA1Ny44MDIxIDkzLjQyMTggNTcuOTkxMiA5My4zNzI4IDU4LjIyNTJDOTMuMzIzOCA1OC40NTkyIDkzLjI5OTMgNTguNzQzNSA5My4yOTkzIDU5LjA3ODJWNjAuMjMzMUM5My4yOTkzIDYwLjQ5OTggOTMuMzE0MyA2MC43MzUxIDkzLjM0NDIgNjAuOTM5MkM5My4zNzY5IDYxLjE0MzIgOTMuNDI0NSA2MS4zMjAxIDkzLjQ4NzEgNjEuNDY5N0M5My41NDk2IDYxLjYxNjYgOTMuNjI1OCA2MS43Mzc3IDkzLjcxNTYgNjEuODMyOUM5My44MDU0IDYxLjkyODIgOTMuOTA4OCA2MS45OTg5IDk0LjAyNTggNjIuMDQ1MkM5NC4xNDU1IDYyLjA4ODcgOTQuMjc3NSA2Mi4xMTA1IDk0LjQyMTcgNjIuMTEwNUM5NC42MDY3IDYyLjExMDUgOTQuNzY4NSA2Mi4wNzUxIDk0LjkwNzMgNjIuMDA0M0M5NS4wNDYxIDYxLjkzMzYgOTUuMTYxNyA2MS44MjM0IDk1LjI1NDIgNjEuNjczOEM5NS4zNDk0IDYxLjUyMTQgOTUuNDIwMiA2MS4zMjY5IDk1LjQ2NjQgNjEuMDkwMkM5NS41MTI3IDYwLjg1MDcgOTUuNTM1OCA2MC41NjUxIDk1LjUzNTggNjAuMjMzMVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHJlY3Qgd2lkdGg9IjUiIGhlaWdodD0iOC4zNTgyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoOTIuMTY0MiA3Mi42ODY1KSIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTk2LjY4NjcgNzUuOTIxMVY3Ni44MjcxQzk2LjY4NjcgNzcuMzE0MSA5Ni42NDMyIDc3LjcyNSA5Ni41NTYxIDc4LjA1OTZDOTYuNDY5IDc4LjM5NDMgOTYuMzQzOSA3OC42NjM2IDk2LjE4MDYgNzguODY3N0M5Ni4wMTc0IDc5LjA3MTggOTUuODIwMSA3OS4yMiA5NS41ODg5IDc5LjMxMjVDOTUuMzYwMyA3OS40MDIzIDk1LjEwMTggNzkuNDQ3MiA5NC44MTM0IDc5LjQ0NzJDOTQuNTg0OSA3OS40NDcyIDk0LjM3NCA3OS40MTg3IDk0LjE4MDkgNzkuMzYxNUM5My45ODc3IDc5LjMwNDQgOTMuODEzNiA3OS4yMTMyIDkzLjY1ODUgNzkuMDg4MUM5My41MDYxIDc4Ljk2MDIgOTMuMzc1NSA3OC43OTQyIDkzLjI2NjcgNzguNTkwMkM5My4xNTc4IDc4LjM4NjEgOTMuMDc0OSA3OC4xMzg1IDkzLjAxNzcgNzcuODQ3NEM5Mi45NjA2IDc3LjU1NjMgOTIuOTMyIDc3LjIxNjIgOTIuOTMyIDc2LjgyNzFWNzUuOTIxMUM5Mi45MzIgNzUuNDM0MSA5Mi45NzU2IDc1LjAyNiA5My4wNjI2IDc0LjY5NjhDOTMuMTUyNCA3NC4zNjc1IDkzLjI3ODkgNzQuMTAzNiA5My40NDIyIDczLjkwNUM5My42MDU0IDczLjcwMzcgOTMuODAxMyA3My41NTk1IDk0LjAyOTkgNzMuNDcyNEM5NC4yNjExIDczLjM4NTMgOTQuNTE5NiA3My4zNDE4IDk0LjgwNTMgNzMuMzQxOEM5NS4wMzY1IDczLjM0MTggOTUuMjQ4OCA3My4zNzA0IDk1LjQ0MTkgNzMuNDI3NUM5NS42Mzc4IDczLjQ4MTkgOTUuODEyIDczLjU3MDQgOTUuOTY0MyA3My42OTI4Qzk2LjExNjcgNzMuODEyNSA5Ni4yNDU5IDczLjk3MyA5Ni4zNTIgNzQuMTc0NEM5Ni40NjA5IDc0LjM3MyA5Ni41NDM4IDc0LjYxNjUgOTYuNjAxIDc0LjkwNDlDOTYuNjU4MSA3NS4xOTMzIDk2LjY4NjcgNzUuNTMyIDk2LjY4NjcgNzUuOTIxMVpNOTUuOTI3NiA3Ni45NDk2Vjc1Ljc5NDZDOTUuOTI3NiA3NS41MjggOTUuOTExMyA3NS4yOTQgOTUuODc4NiA3NS4wOTI2Qzk1Ljg0ODcgNzQuODg4NiA5NS44MDM4IDc0LjcxNDQgOTUuNzQzOSA3NC41NzAyQzk1LjY4NDEgNzQuNDI2IDk1LjYwNzkgNzQuMzA5IDk1LjUxNTQgNzQuMjE5M0M5NS40MjU2IDc0LjEyOTUgOTUuMzIwOSA3NC4wNjQyIDk1LjIwMTEgNzQuMDIzNEM5NS4wODQyIDczLjk3OTggOTQuOTUyMiA3My45NTgxIDk0LjgwNTMgNzMuOTU4MUM5NC42MjU3IDczLjk1ODEgOTQuNDY2NSA3My45OTIxIDk0LjMyNzggNzQuMDYwMUM5NC4xODkgNzQuMTI1NCA5NC4wNzIgNzQuMjMwMSA5My45NzY4IDc0LjM3NDNDOTMuODg0MyA3NC41MTg1IDkzLjgxMzYgNzQuNzA3NiA5My43NjQ2IDc0Ljk0MTZDOTMuNzE1NiA3NS4xNzU2IDkzLjY5MTEgNzUuNDU5OSA5My42OTExIDc1Ljc5NDZWNzYuOTQ5NkM5My42OTExIDc3LjIxNjIgOTMuNzA2MSA3Ny40NTE1IDkzLjczNiA3Ny42NTU2QzkzLjc2ODcgNzcuODU5NyA5My44MTYzIDc4LjAzNjUgOTMuODc4OSA3OC4xODYxQzkzLjk0MTQgNzguMzMzMSA5NC4wMTc2IDc4LjQ1NDEgOTQuMTA3NCA3OC41NDk0Qzk0LjE5NzIgNzguNjQ0NiA5NC4zMDA2IDc4LjcxNTMgOTQuNDE3NiA3OC43NjE2Qzk0LjUzNzMgNzguODA1MSA5NC42NjkyIDc4LjgyNjkgOTQuODEzNCA3OC44MjY5Qzk0Ljk5ODUgNzguODI2OSA5NS4xNjAzIDc4Ljc5MTUgOTUuMjk5MSA3OC43MjA4Qzk1LjQzNzkgNzguNjUgOTUuNTUzNSA3OC41Mzk4IDk1LjY0NiA3OC4zOTAyQzk1Ljc0MTIgNzguMjM3OCA5NS44MTIgNzguMDQzMyA5NS44NTgyIDc3LjgwNjZDOTUuOTA0NSA3Ny41NjcyIDk1LjkyNzYgNzcuMjgxNSA5NS45Mjc2IDc2Ljk0OTZaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxyZWN0IHdpZHRoPSIxNSIgaGVpZ2h0PSI4LjM1ODIxIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg4Mi43NjEyIDIyLjUzNzQpIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNODUuOTIyOSAyMy4yNDE2VjI5LjIxNjRIODUuMTY3OVYyNC4xODQ0TDgzLjY0NTYgMjQuNzM5NFYyNC4wNTc4TDg1LjgwNDYgMjMuMjQxNkg4NS45MjI5Wk04OS4zNTkzIDI2LjM4ODJMODguNzU1MyAyNi4yMzMxTDg5LjA1MzIgMjMuMjc0M0g5Mi4xMDE4VjIzLjk3MjFIODkuNjkzOUw4OS41MTQzIDI1LjU4ODNDODkuNjIzMiAyNS41MjU3IDg5Ljc2MDYgMjUuNDY3MiA4OS45MjY1IDI1LjQxMjhDOTAuMDk1MiAyNS4zNTg0IDkwLjI4ODQgMjUuMzMxMiA5MC41MDYxIDI1LjMzMTJDOTAuNzgwOSAyNS4zMzEyIDkxLjAyNzEgMjUuMzc4OCA5MS4yNDQ4IDI1LjQ3NEM5MS40NjI0IDI1LjU2NjUgOTEuNjQ3NCAyNS42OTk4IDkxLjc5OTggMjUuODc0QzkxLjk1NDkgMjYuMDQ4MSA5Mi4wNzMyIDI2LjI1NzYgOTIuMTU0OSAyNi41MDI1QzkyLjIzNjUgMjYuNzQ3MyA5Mi4yNzczIDI3LjAyMDggOTIuMjc3MyAyNy4zMjI4QzkyLjI3NzMgMjcuNjA4NSA5Mi4yMzc4IDI3Ljg3MSA5Mi4xNTg5IDI4LjExMDRDOTIuMDgyOCAyOC4zNDk5IDkxLjk2NzEgMjguNTU5NCA5MS44MTIgMjguNzM4OUM5MS42NTcgMjguOTE1OCA5MS40NjExIDI5LjA1MzIgOTEuMjI0NCAyOS4xNTExQzkwLjk5MDQgMjkuMjQ5MSA5MC43MTQyIDI5LjI5ODEgOTAuMzk1OSAyOS4yOTgxQzkwLjE1NjUgMjkuMjk4MSA4OS45MjkzIDI5LjI2NTQgODkuNzE0MyAyOS4yMDAxQzg5LjUwMjEgMjkuMTMyMSA4OS4zMTE3IDI5LjAzMDEgODkuMTQzIDI4Ljg5NEM4OC45NzcgMjguNzU1MyA4OC44NDEgMjguNTgzOSA4OC43MzQ4IDI4LjM3OThDODguNjMxNSAyOC4xNzMgODguNTY2MiAyNy45MzA5IDg4LjUzOSAyNy42NTMzSDg5LjI1NzJDODkuMjg5OSAyNy44NzY1IDg5LjM1NTIgMjguMDY0MiA4OS40NTMxIDI4LjIxNjVDODkuNTUxMSAyOC4zNjg5IDg5LjY3OSAyOC40ODQ1IDg5LjgzNjggMjguNTYzNEM4OS45OTczIDI4LjYzOTYgOTAuMTgzNyAyOC42Nzc3IDkwLjM5NTkgMjguNjc3N0M5MC41NzU0IDI4LjY3NzcgOTAuNzM0NiAyOC42NDY0IDkwLjg3MzQgMjguNTgzOUM5MS4wMTIxIDI4LjUyMTMgOTEuMTI5MSAyOC40MzE1IDkxLjIyNDQgMjguMzE0NUM5MS4zMTk2IDI4LjE5NzUgOTEuMzkxNyAyOC4wNTYgOTEuNDQwNyAyNy44OTAxQzkxLjQ5MjMgMjcuNzI0MSA5MS41MTgyIDI3LjUzNzcgOTEuNTE4MiAyNy4zMzA5QzkxLjUxODIgMjcuMTQzMiA5MS40OTIzIDI2Ljk2OTEgOTEuNDQwNyAyNi44MDg1QzkxLjM4OSAyNi42NDggOTEuMzExNCAyNi41MDc5IDkxLjIwOCAyNi4zODgyQzkxLjEwNzQgMjYuMjY4NSA5MC45ODM2IDI2LjE3NiA5MC44MzY2IDI2LjExMDdDOTAuNjg5NyAyNi4wNDI3IDkwLjUyMSAyNi4wMDg2IDkwLjMzMDYgMjYuMDA4NkM5MC4wNzc1IDI2LjAwODYgODkuODg1NyAyNi4wNDI3IDg5Ljc1NTEgMjYuMTEwN0M4OS42MjczIDI2LjE3ODcgODkuNDk1MyAyNi4yNzEyIDg5LjM1OTMgMjYuMzg4MlpNOTcuMDk3MSAyNS43NzE5VjI2LjY3OEM5Ny4wOTcxIDI3LjE2NSA5Ny4wNTM2IDI3LjU3NTggOTYuOTY2NSAyNy45MTA1Qzk2Ljg3OTUgMjguMjQ1MSA5Ni43NTQzIDI4LjUxNDUgOTYuNTkxMSAyOC43MTg1Qzk2LjQyNzggMjguOTIyNiA5Ni4yMzA2IDI5LjA3MDkgOTUuOTk5MyAyOS4xNjM0Qzk1Ljc3MDggMjkuMjUzMiA5NS41MTIzIDI5LjI5ODEgOTUuMjIzOSAyOS4yOTgxQzk0Ljk5NTMgMjkuMjk4MSA5NC43ODQ1IDI5LjI2OTUgOTQuNTkxMyAyOS4yMTIzQzk0LjM5ODEgMjkuMTU1MiA5NC4yMjQgMjkuMDY0MSA5NC4wNjg5IDI4LjkzODlDOTMuOTE2NiAyOC44MTEgOTMuNzg2IDI4LjY0NTEgOTMuNjc3MSAyOC40NDFDOTMuNTY4MyAyOC4yMzcgOTMuNDg1MyAyNy45ODk0IDkzLjQyODIgMjcuNjk4MkM5My4zNzEgMjcuNDA3MSA5My4zNDI1IDI3LjA2NyA5My4zNDI1IDI2LjY3OFYyNS43NzE5QzkzLjM0MjUgMjUuMjg0OSA5My4zODYgMjQuODc2OCA5My40NzMxIDI0LjU0NzZDOTMuNTYyOSAyNC4yMTg0IDkzLjY4OTQgMjMuOTU0NSA5My44NTI2IDIzLjc1NThDOTQuMDE1OSAyMy41NTQ1IDk0LjIxMTggMjMuNDEwMyA5NC40NDAzIDIzLjMyMzJDOTQuNjcxNiAyMy4yMzYyIDk0LjkzIDIzLjE5MjYgOTUuMjE1NyAyMy4xOTI2Qzk1LjQ0NyAyMy4xOTI2IDk1LjY1OTIgMjMuMjIxMiA5NS44NTI0IDIzLjI3ODNDOTYuMDQ4MyAyMy4zMzI4IDk2LjIyMjQgMjMuNDIxMiA5Ni4zNzQ4IDIzLjU0MzZDOTYuNTI3MSAyMy42NjMzIDk2LjY1NjQgMjMuODIzOSA5Ni43NjI1IDI0LjAyNTJDOTYuODcxMyAyNC4yMjM4IDk2Ljk1NDMgMjQuNDY3MyA5Ny4wMTE0IDI0Ljc1NTdDOTcuMDY4NiAyNS4wNDQxIDk3LjA5NzEgMjUuMzgyOSA5Ny4wOTcxIDI1Ljc3MTlaTTk2LjMzOCAyNi44MDA0VjI1LjY0NTRDOTYuMzM4IDI1LjM3ODggOTYuMzIxNyAyNS4xNDQ4IDk2LjI4OTEgMjQuOTQzNUM5Ni4yNTkxIDI0LjczOTQgOTYuMjE0MiAyNC41NjUzIDk2LjE1NDQgMjQuNDIxMUM5Ni4wOTQ1IDI0LjI3NjkgOTYuMDE4NCAyNC4xNTk5IDk1LjkyNTggMjQuMDcwMUM5NS44MzYxIDIzLjk4MDMgOTUuNzMxMyAyMy45MTUgOTUuNjExNiAyMy44NzQyQzk1LjQ5NDYgMjMuODMwNyA5NS4zNjI2IDIzLjgwODkgOTUuMjE1NyAyMy44MDg5Qzk1LjAzNjIgMjMuODA4OSA5NC44NzcgMjMuODQyOSA5NC43MzgyIDIzLjkxMDlDOTQuNTk5NSAyMy45NzYyIDk0LjQ4MjUgMjQuMDgxIDk0LjM4NzIgMjQuMjI1MkM5NC4yOTQ3IDI0LjM2OTQgOTQuMjI0IDI0LjU1ODUgOTQuMTc1IDI0Ljc5MjVDOTQuMTI2MSAyNS4wMjY0IDk0LjEwMTYgMjUuMzEwOCA5NC4xMDE2IDI1LjY0NTRWMjYuODAwNEM5NC4xMDE2IDI3LjA2NyA5NC4xMTY1IDI3LjMwMjQgOTQuMTQ2NSAyNy41MDY0Qzk0LjE3OTEgMjcuNzEwNSA5NC4yMjY3IDI3Ljg4NzMgOTQuMjg5MyAyOC4wMzdDOTQuMzUxOSAyOC4xODM5IDk0LjQyODEgMjguMzA1IDk0LjUxNzggMjguNDAwMkM5NC42MDc2IDI4LjQ5NTQgOTQuNzExIDI4LjU2NjIgOTQuODI4IDI4LjYxMjRDOTQuOTQ3NyAyOC42NTYgOTUuMDc5NyAyOC42Nzc3IDk1LjIyMzkgMjguNjc3N0M5NS40MDg5IDI4LjY3NzcgOTUuNTcwOCAyOC42NDIzIDk1LjcwOTUgMjguNTcxNkM5NS44NDgzIDI4LjUwMDkgOTUuOTYzOSAyOC4zOTA3IDk2LjA1NjQgMjguMjQxQzk2LjE1MTcgMjguMDg4NyA5Ni4yMjI0IDI3Ljg5NDEgOTYuMjY4NyAyNy42NTc0Qzk2LjMxNDkgMjcuNDE4IDk2LjMzOCAyNy4xMzIzIDk2LjMzOCAyNi44MDA0WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC43NiIvPgo8cmVjdCB3aWR0aD0iMTUiIGhlaWdodD0iOC4zNTgyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoODIuNzYxMiA1LjgyMDkyKSIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTg3LjMzNSAxMS44Nzk3VjEyLjVIODMuNDQ1N1YxMS45NTcyTDg1LjM5MjQgOS43OTAxMUM4NS42MzE4IDkuNTIzNDggODUuODE2OCA5LjI5NzY1IDg1Ljk0NzQgOS4xMTI2NEM4Ni4wODA3IDguOTI0OTEgODYuMTczMiA4Ljc1NzU4IDg2LjIyNDkgOC42MTA2NkM4Ni4yNzk0IDguNDYxMDIgODYuMzA2NiA4LjMwODY1IDg2LjMwNjYgOC4xNTM1N0M4Ni4zMDY2IDcuOTU3NjcgODYuMjY1NyA3Ljc4MDgyIDg2LjE4NDEgNy42MjMwMkM4Ni4xMDUyIDcuNDYyNDkgODUuOTg4MiA3LjMzNDYyIDg1LjgzMzEgNy4yMzkzOUM4NS42NzgxIDcuMTQ0MTYgODUuNDkwMyA3LjA5NjU1IDg1LjI2OTkgNy4wOTY1NUM4NS4wMDYgNy4wOTY1NSA4NC43ODU2IDcuMTQ4MjQgODQuNjA4OCA3LjI1MTYzQzg0LjQzNDcgNy4zNTIzIDg0LjMwNDEgNy40OTM3OCA4NC4yMTcgNy42NzYwN0M4NC4xMjk5IDcuODU4MzYgODQuMDg2NCA4LjA2Nzg2IDg0LjA4NjQgOC4zMDQ1N0g4My4zMzE0QzgzLjMzMTQgNy45Njk5MiA4My40MDQ5IDcuNjYzODMgODMuNTUxOCA3LjM4NjMxQzgzLjY5ODcgNy4xMDg3OSA4My45MTY0IDYuODg4NDEgODQuMjA0OCA2LjcyNTE2Qzg0LjQ5MzIgNi41NTkyIDg0Ljg0ODIgNi40NzYyMSA4NS4yNjk5IDYuNDc2MjFDODUuNjQ1NCA2LjQ3NjIxIDg1Ljk2NjUgNi41NDI4NyA4Ni4yMzMxIDYuNjc2MTlDODYuNDk5NyA2LjgwNjc5IDg2LjcwMzggNi45OTE4IDg2Ljg0NTMgNy4yMzEyM0M4Ni45ODk1IDcuNDY3OTMgODcuMDYxNiA3Ljc0NTQ1IDg3LjA2MTYgOC4wNjM3OEM4Ny4wNjE2IDguMjM3OTEgODcuMDMxNiA4LjQxNDc2IDg2Ljk3MTggOC41OTQzM0M4Ni45MTQ3IDguNzcxMTggODYuODM0NCA4Ljk0ODAzIDg2LjczMSA5LjEyNDg4Qzg2LjYzMDMgOS4zMDE3MyA4Ni41MTIgOS40NzU4NiA4Ni4zNzU5IDkuNjQ3MjdDODYuMjQyNiA5LjgxODY4IDg2LjA5OTggOS45ODczNyA4NS45NDc0IDEwLjE1MzNMODQuMzU1OCAxMS44Nzk3SDg3LjMzNVpNOTIuMTM0NSA5LjA1NTVWOS45NjE1MkM5Mi4xMzQ1IDEwLjQ0ODUgOTIuMDkwOSAxMC44NTk0IDkyLjAwMzkgMTEuMTk0QzkxLjkxNjggMTEuNTI4NyA5MS43OTE2IDExLjc5OCA5MS42Mjg0IDEyLjAwMjFDOTEuNDY1MSAxMi4yMDYyIDkxLjI2NzkgMTIuMzU0NCA5MS4wMzY2IDEyLjQ0NjlDOTAuODA4MSAxMi41MzY3IDkwLjU0OTYgMTIuNTgxNiA5MC4yNjEyIDEyLjU4MTZDOTAuMDMyNyAxMi41ODE2IDg5LjgyMTggMTIuNTUzMSA4OS42Mjg2IDEyLjQ5NTlDODkuNDM1NCAxMi40Mzg4IDg5LjI2MTMgMTIuMzQ3NiA4OS4xMDYyIDEyLjIyMjVDODguOTUzOSAxMi4wOTQ2IDg4LjgyMzMgMTEuOTI4NiA4OC43MTQ0IDExLjcyNDZDODguNjA1NiAxMS41MjA1IDg4LjUyMjYgMTEuMjcyOSA4OC40NjU1IDEwLjk4MThDODguNDA4NCAxMC42OTA3IDg4LjM3OTggMTAuMzUwNiA4OC4zNzk4IDkuOTYxNTJWOS4wNTU1Qzg4LjM3OTggOC41Njg0OSA4OC40MjMzIDguMTYwMzcgODguNTEwNCA3LjgzMTE2Qzg4LjYwMDIgNy41MDE5NCA4OC43MjY3IDcuMjM4MDMgODguODg5OSA3LjAzOTQxQzg5LjA1MzIgNi44MzgwOCA4OS4yNDkxIDYuNjkzODcgODkuNDc3NiA2LjYwNjgxQzg5LjcwODkgNi41MTk3NSA4OS45Njc0IDYuNDc2MjEgOTAuMjUzIDYuNDc2MjFDOTAuNDg0MyA2LjQ3NjIxIDkwLjY5NjUgNi41MDQ3OCA5MC44ODk3IDYuNTYxOTJDOTEuMDg1NiA2LjYxNjMzIDkxLjI1OTcgNi43MDQ3NiA5MS40MTIxIDYuODI3MTlDOTEuNTY0NCA2Ljk0NjkxIDkxLjY5MzcgNy4xMDc0MyA5MS43OTk4IDcuMzA4NzdDOTEuOTA4NiA3LjUwNzM5IDkxLjk5MTYgNy43NTA4OSA5Mi4wNDg3IDguMDM5M0M5Mi4xMDU5IDguMzI3NyA5Mi4xMzQ1IDguNjY2NDMgOTIuMTM0NSA5LjA1NTVaTTkxLjM3NTQgMTAuMDg0VjguOTI4OTlDOTEuMzc1NCA4LjY2MjM1IDkxLjM1OSA4LjQyODM3IDkxLjMyNjQgOC4yMjcwM0M5MS4yOTY1IDguMDIyOTcgOTEuMjUxNiA3Ljg0ODg0IDkxLjE5MTcgNy43MDQ2NEM5MS4xMzE4IDcuNTYwNDQgOTEuMDU1NyA3LjQ0MzQ1IDkwLjk2MzIgNy4zNTM2NkM5MC44NzM0IDcuMjYzODggOTAuNzY4NiA3LjE5ODU4IDkwLjY0ODkgNy4xNTc3N0M5MC41MzE5IDcuMTE0MjMgOTAuNCA3LjA5MjQ3IDkwLjI1MyA3LjA5MjQ3QzkwLjA3MzUgNy4wOTI0NyA4OS45MTQzIDcuMTI2NDggODkuNzc1NSA3LjE5NDVDODkuNjM2OCA3LjI1OTc5IDg5LjUxOTggNy4zNjQ1NCA4OS40MjQ2IDcuNTA4NzVDODkuMzMyMSA3LjY1Mjk1IDg5LjI2MTMgNy44NDIwNCA4OS4yMTIzIDguMDc2MDNDODkuMTYzNCA4LjMxMDAxIDg5LjEzODkgOC41OTQzMyA4OS4xMzg5IDguOTI4OTlWMTAuMDg0Qzg5LjEzODkgMTAuMzUwNiA4OS4xNTM4IDEwLjU4NTkgODkuMTgzOCAxMC43OUM4OS4yMTY0IDEwLjk5NDEgODkuMjY0IDExLjE3MDkgODkuMzI2NiAxMS4zMjA1Qzg5LjM4OTIgMTEuNDY3NSA4OS40NjU0IDExLjU4ODUgODkuNTU1MiAxMS42ODM4Qzg5LjY0NDkgMTEuNzc5IDg5Ljc0ODMgMTEuODQ5NyA4OS44NjUzIDExLjg5NkM4OS45ODUgMTEuOTM5NSA5MC4xMTcgMTEuOTYxMyA5MC4yNjEyIDExLjk2MTNDOTAuNDQ2MiAxMS45NjEzIDkwLjYwODEgMTEuOTI1OSA5MC43NDY5IDExLjg1NTJDOTAuODg1NiAxMS43ODQ0IDkxLjAwMTIgMTEuNjc0MiA5MS4wOTM4IDExLjUyNDZDOTEuMTg5IDExLjM3MjIgOTEuMjU5NyAxMS4xNzc3IDkxLjMwNiAxMC45NDFDOTEuMzUyMiAxMC43MDE2IDkxLjM3NTQgMTAuNDE1OSA5MS4zNzU0IDEwLjA4NFpNOTcuMDk3MSA5LjA1NTVWOS45NjE1MkM5Ny4wOTcxIDEwLjQ0ODUgOTcuMDUzNiAxMC44NTk0IDk2Ljk2NjUgMTEuMTk0Qzk2Ljg3OTUgMTEuNTI4NyA5Ni43NTQzIDExLjc5OCA5Ni41OTExIDEyLjAwMjFDOTYuNDI3OCAxMi4yMDYyIDk2LjIzMDYgMTIuMzU0NCA5NS45OTkzIDEyLjQ0NjlDOTUuNzcwOCAxMi41MzY3IDk1LjUxMjMgMTIuNTgxNiA5NS4yMjM5IDEyLjU4MTZDOTQuOTk1MyAxMi41ODE2IDk0Ljc4NDUgMTIuNTUzMSA5NC41OTEzIDEyLjQ5NTlDOTQuMzk4MSAxMi40Mzg4IDk0LjIyNCAxMi4zNDc2IDk0LjA2ODkgMTIuMjIyNUM5My45MTY2IDEyLjA5NDYgOTMuNzg2IDExLjkyODYgOTMuNjc3MSAxMS43MjQ2QzkzLjU2ODMgMTEuNTIwNSA5My40ODUzIDExLjI3MjkgOTMuNDI4MiAxMC45ODE4QzkzLjM3MSAxMC42OTA3IDkzLjM0MjUgMTAuMzUwNiA5My4zNDI1IDkuOTYxNTJWOS4wNTU1QzkzLjM0MjUgOC41Njg0OSA5My4zODYgOC4xNjAzNyA5My40NzMxIDcuODMxMTZDOTMuNTYyOSA3LjUwMTk0IDkzLjY4OTQgNy4yMzgwMyA5My44NTI2IDcuMDM5NDFDOTQuMDE1OSA2LjgzODA4IDk0LjIxMTggNi42OTM4NyA5NC40NDAzIDYuNjA2ODFDOTQuNjcxNiA2LjUxOTc1IDk0LjkzIDYuNDc2MjEgOTUuMjE1NyA2LjQ3NjIxQzk1LjQ0NyA2LjQ3NjIxIDk1LjY1OTIgNi41MDQ3OCA5NS44NTI0IDYuNTYxOTJDOTYuMDQ4MyA2LjYxNjMzIDk2LjIyMjQgNi43MDQ3NiA5Ni4zNzQ4IDYuODI3MTlDOTYuNTI3MSA2Ljk0NjkxIDk2LjY1NjQgNy4xMDc0MyA5Ni43NjI1IDcuMzA4NzdDOTYuODcxMyA3LjUwNzM5IDk2Ljk1NDMgNy43NTA4OSA5Ny4wMTE0IDguMDM5M0M5Ny4wNjg2IDguMzI3NyA5Ny4wOTcxIDguNjY2NDMgOTcuMDk3MSA5LjA1NTVaTTk2LjMzOCAxMC4wODRWOC45Mjg5OUM5Ni4zMzggOC42NjIzNSA5Ni4zMjE3IDguNDI4MzcgOTYuMjg5MSA4LjIyNzAzQzk2LjI1OTEgOC4wMjI5NyA5Ni4yMTQyIDcuODQ4ODQgOTYuMTU0NCA3LjcwNDY0Qzk2LjA5NDUgNy41NjA0NCA5Ni4wMTg0IDcuNDQzNDUgOTUuOTI1OCA3LjM1MzY2Qzk1LjgzNjEgNy4yNjM4OCA5NS43MzEzIDcuMTk4NTggOTUuNjExNiA3LjE1Nzc3Qzk1LjQ5NDYgNy4xMTQyMyA5NS4zNjI2IDcuMDkyNDcgOTUuMjE1NyA3LjA5MjQ3Qzk1LjAzNjIgNy4wOTI0NyA5NC44NzcgNy4xMjY0OCA5NC43MzgyIDcuMTk0NUM5NC41OTk1IDcuMjU5NzkgOTQuNDgyNSA3LjM2NDU0IDk0LjM4NzIgNy41MDg3NUM5NC4yOTQ3IDcuNjUyOTUgOTQuMjI0IDcuODQyMDQgOTQuMTc1IDguMDc2MDNDOTQuMTI2MSA4LjMxMDAxIDk0LjEwMTYgOC41OTQzMyA5NC4xMDE2IDguOTI4OTlWMTAuMDg0Qzk0LjEwMTYgMTAuMzUwNiA5NC4xMTY1IDEwLjU4NTkgOTQuMTQ2NSAxMC43OUM5NC4xNzkxIDEwLjk5NDEgOTQuMjI2NyAxMS4xNzA5IDk0LjI4OTMgMTEuMzIwNUM5NC4zNTE5IDExLjQ2NzUgOTQuNDI4MSAxMS41ODg1IDk0LjUxNzggMTEuNjgzOEM5NC42MDc2IDExLjc3OSA5NC43MTEgMTEuODQ5NyA5NC44MjggMTEuODk2Qzk0Ljk0NzcgMTEuOTM5NSA5NS4wNzk3IDExLjk2MTMgOTUuMjIzOSAxMS45NjEzQzk1LjQwODkgMTEuOTYxMyA5NS41NzA4IDExLjkyNTkgOTUuNzA5NSAxMS44NTUyQzk1Ljg0ODMgMTEuNzg0NCA5NS45NjM5IDExLjY3NDIgOTYuMDU2NCAxMS41MjQ2Qzk2LjE1MTcgMTEuMzcyMiA5Ni4yMjI0IDExLjE3NzcgOTYuMjY4NyAxMC45NDFDOTYuMzE0OSAxMC43MDE2IDk2LjMzOCAxMC40MTU5IDk2LjMzOCAxMC4wODRaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik03Ni4yNzY1IDkyLjI2NEM3OC41NjEgOTYuNDIwNSA4MS45MTkgOTkuODg3NiA4Ni4wMDAzIDEwMi4zMDRDOTAuMDgxNiAxMDQuNzIgOTQuNzM2NSAxMDUuOTk2IDk5LjQ3OTQgMTA2QzEwNC4yMjIgMTA2LjAwNCAxMDguODc5IDEwNC43MzQgMTEyLjk2NCAxMDIuMzI1QzExNy4wNDkgOTkuOTE0OSAxMjAuNDEzIDk2LjQ1MyAxMjIuNzA0IDkyLjMwMDJMOTkuNSA3OS41TDc2LjI3NjUgOTIuMjY0WiIgZmlsbD0iIzRCOTNGRiIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIxLjA0NDc4Ii8+CjxwYXRoIGQ9Ik0xMzUuMDExIDk5LjM0NDZDMTM4LjM3NyA5My4yNTM3IDE0MC4wOTUgODYuMzkwMyAxMzkuOTk2IDc5LjQzMjNDMTM5Ljg5NyA3Mi40NzQyIDEzNy45ODUgNjUuNjYyMyAxMzQuNDQ4IDU5LjY2OTVDMTMwLjkxMSA1My42NzY2IDEyNS44NzIgNDguNzEwMSAxMTkuODI5IDQ1LjI2MDZDMTEzLjc4NSA0MS44MTEgMTA2Ljk0NiAzOS45OTc4IDk5Ljk4NzMgNDBMMTAwIDgwTDEzNS4wMTEgOTkuMzQ0NloiIGZpbGw9IiNGRjRENUEiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iMS4wNDQ3OCIvPgo8ZyBmaWx0ZXI9InVybCgjZmlsdGVyMF9kaV80NjE3XzQzNDk5KSI+CjxwYXRoIGQ9Ik05OS40Nzc2IDI3Ljc2MTJDOTAuMzk4MSAyNy43NjEyIDgxLjQ3NTMgMzAuMTI3NyA3My41ODkxIDM0LjYyNzRDNjUuNzAzIDM5LjEyNyA1OS4xMjU4IDQ1LjYwNDQgNTQuNTA2MSA1My40MjA4QzQ5Ljg4NjQgNjEuMjM3MyA0Ny4zODM3IDcwLjEyMjggNDcuMjQ0OSA3OS4yMDEzQzQ3LjEwNjEgODguMjc5OCA0OS4zMzU5IDk3LjIzNzcgNTMuNzE0NCAxMDUuMTkyTDk5LjQ3NzYgODBWMjcuNzYxMloiIGZpbGw9IiMwODg3MkIiLz4KPHBhdGggZD0iTTk5LjQ3NzYgMjcuNzYxMkM5MC4zOTgxIDI3Ljc2MTIgODEuNDc1MyAzMC4xMjc3IDczLjU4OTEgMzQuNjI3NEM2NS43MDMgMzkuMTI3IDU5LjEyNTggNDUuNjA0NCA1NC41MDYxIDUzLjQyMDhDNDkuODg2NCA2MS4yMzczIDQ3LjM4MzcgNzAuMTIyOCA0Ny4yNDQ5IDc5LjIwMTNDNDcuMTA2MSA4OC4yNzk4IDQ5LjMzNTkgOTcuMjM3NyA1My43MTQ0IDEwNS4xOTJMOTkuNDc3NiA4MFYyNy43NjEyWiIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIxLjA0NDc4Ii8+CjwvZz4KPHBhdGggZD0iTTc1LjEzODggNjcuOTU5OEw3My40NDUxIDczSDcyLjI1NzVMNzQuNDc3NiA2Ny4wNTc4SDc1LjIzNjdMNzUuMTM4OCA2Ny45NTk4Wk03Ni41NTQ5IDczTDc0Ljg1MzEgNjcuOTU5OEw3NC43NTEgNjcuMDU3OEg3NS41MTQyTDc3Ljc0NjYgNzNINzYuNTU0OVpNNzYuNDc3NCA3MC43OTIxVjcxLjY3NzdINzMuMjlWNzAuNzkyMUg3Ni40Nzc0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTEwMC4zMSA5NC4wNDUxSDEwMS40MzJDMTAxLjQwMiA5NC40NDI0IDEwMS4yOTIgOTQuNzk0NyAxMDEuMTAyIDk1LjEwMjFDMTAwLjkxMSA5NS40MDY5IDEwMC42NDYgOTUuNjQ2MyAxMDAuMzA2IDk1LjgyMDRDOTkuOTY1OSA5NS45OTQ2IDk5LjU1MzcgOTYuMDgxNiA5OS4wNjk0IDk2LjA4MTZDOTguNjk2NiA5Ni4wODE2IDk4LjM2MDYgOTYuMDE2MyA5OC4wNjEzIDk1Ljg4NTdDOTcuNzY0OCA5NS43NTI0IDk3LjUxMDQgOTUuNTYzMyA5Ny4yOTgyIDk1LjMxODRDOTcuMDg4NyA5NS4wNzA5IDk2LjkyODEgOTQuNzc0MyA5Ni44MTY2IDk0LjQyODhDOTYuNzA1IDk0LjA4MDUgOTYuNjQ5MyA5My42OTAxIDk2LjY0OTMgOTMuMjU3NVY5Mi44MDQ1Qzk2LjY0OTMgOTIuMzcxOSA5Ni43MDY0IDkxLjk4MTQgOTYuODIwNyA5MS42MzMyQzk2LjkzNDkgOTEuMjg0OSA5Ny4wOTgyIDkwLjk4ODMgOTcuMzEwNCA5MC43NDM1Qzk3LjUyNTMgOTAuNDk1OSA5Ny43ODI1IDkwLjMwNTQgOTguMDgxNyA5MC4xNzIxQzk4LjM4MzcgOTAuMDM4OCA5OC43MjExIDg5Ljk3MjEgOTkuMDkzOSA4OS45NzIxQzk5LjU3ODIgODkuOTcyMSA5OS45ODc2IDkwLjA2MTkgMTAwLjMyMiA5MC4yNDE1QzEwMC42NTcgOTAuNDE4MyAxMDAuOTE3IDkwLjY2MTggMTAxLjEwMiA5MC45NzJDMTAxLjI4NyA5MS4yODIyIDEwMS4zOTggOTEuNjM4NiAxMDEuNDM2IDkyLjA0MTNIMTAwLjMxNEMxMDAuMjkyIDkxLjc5MSAxMDAuMjM4IDkxLjU3ODcgMTAwLjE1MSA5MS40MDQ2QzEwMC4wNjcgOTEuMjMwNSA5OS45Mzg3IDkxLjA5ODUgOTkuNzY3MyA5MS4wMDg3Qzk5LjU5ODYgOTAuOTE2MiA5OS4zNzQxIDkwLjg3IDk5LjA5MzkgOTAuODdDOTguODc2MiA5MC44NyA5OC42ODQ0IDkwLjkxMDggOTguNTE4NCA5MC45OTI0Qzk4LjM1NTIgOTEuMDc0IDk4LjIxOTEgOTEuMTk2NSA5OC4xMTAzIDkxLjM1OTdDOTguMDAxNSA5MS41MjAzIDk3LjkxOTkgOTEuNzIxNiA5Ny44NjU0IDkxLjk2MzdDOTcuODExIDkyLjIwMzIgOTcuNzgzOCA5Mi40ODA3IDk3Ljc4MzggOTIuNzk2M1Y5My4yNTc1Qzk3Ljc4MzggOTMuNTU5NSA5Ny44MDgzIDkzLjgzMDIgOTcuODU3MyA5NC4wNjk2Qzk3LjkwNjIgOTQuMzA5IDk3Ljk4MjQgOTQuNTExNyA5OC4wODU4IDk0LjY3NzdDOTguMTg5MiA5NC44NDM3IDk4LjMyMjUgOTQuOTcwMiA5OC40ODU4IDk1LjA1NzNDOTguNjQ5IDk1LjE0NDMgOTguODQzNiA5NS4xODc4IDk5LjA2OTQgOTUuMTg3OEM5OS4zNDQyIDk1LjE4NzggOTkuNTY3MyA5NS4xNDQzIDk5LjczODcgOTUuMDU3M0M5OS45MTI4IDk0Ljk3MDIgMTAwLjA0NSA5NC44NDIzIDEwMC4xMzUgOTQuNjczNkMxMDAuMjI3IDk0LjUwNDkgMTAwLjI4NiA5NC4yOTU0IDEwMC4zMSA5NC4wNDUxWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTExNy4xOTIgNzAuMzUxM0gxMTUuNjc0TDExNS42NjUgNjkuNTU5NkgxMTYuOTY3QzExNy4xOSA2OS41NTk2IDExNy4zNzcgNjkuNTI4MyAxMTcuNTI2IDY5LjQ2NTdDMTE3LjY3NiA2OS40MDMxIDExNy43ODkgNjkuMzEyIDExNy44NjUgNjkuMTkyM0MxMTcuOTQ0IDY5LjA3MjYgMTE3Ljk4NCA2OC45MjcgMTE3Ljk4NCA2OC43NTU2QzExNy45ODQgNjguNTY1MSAxMTcuOTQ3IDY4LjQxMDEgMTE3Ljg3MyA2OC4yOTAzQzExNy44MDMgNjguMTcwNiAxMTcuNjkxIDY4LjA4MzYgMTE3LjUzOSA2OC4wMjkyQzExNy4zODYgNjcuOTcyIDExNy4xOTMgNjcuOTQzNCAxMTYuOTU5IDY3Ljk0MzRIMTE2LjAzN1Y3M0gxMTQuOTE1VjY3LjA1NzhIMTE2Ljk1OUMxMTcuMjk3IDY3LjA1NzggMTE3LjU5NyA2Ny4wOTA1IDExNy44NjEgNjcuMTU1OEMxMTguMTI4IDY3LjIxODQgMTE4LjM1NCA2Ny4zMTYzIDExOC41MzkgNjcuNDQ5NkMxMTguNzI0IDY3LjU4MjkgMTE4Ljg2NCA2Ny43NTAzIDExOC45NTkgNjcuOTUxNkMxMTkuMDU3IDY4LjE1MjkgMTE5LjEwNiA2OC4zOTI0IDExOS4xMDYgNjguNjY5OUMxMTkuMTA2IDY4LjkxNDggMTE5LjA0OSA2OS4xNDA2IDExOC45MzQgNjkuMzQ3NEMxMTguODIzIDY5LjU1MTQgMTE4LjY1IDY5LjcxNzQgMTE4LjQxNiA2OS44NDUzQzExOC4xODUgNjkuOTczMSAxMTcuODkxIDcwLjA0NjYgMTE3LjUzNSA3MC4wNjU2TDExNy4xOTIgNzAuMzUxM1pNMTE3LjE0MyA3M0gxMTUuMzQzTDExNS44MTIgNzIuMTE4NUgxMTcuMTQzQzExNy4zNjYgNzIuMTE4NSAxMTcuNTUgNzIuMDgxNyAxMTcuNjk0IDcyLjAwODNDMTE3Ljg0MSA3MS45MzQ4IDExNy45NSA3MS44MzQxIDExOC4wMiA3MS43MDYzQzExOC4wOTQgNzEuNTc1NyAxMTguMTMgNzEuNDI2IDExOC4xMyA3MS4yNTczQzExOC4xMyA3MS4wNzIzIDExOC4wOTggNzAuOTExOCAxMTguMDMzIDcwLjc3NThDMTE3Ljk3IDcwLjYzOTcgMTE3Ljg2OSA3MC41MzUgMTE3LjczMSA3MC40NjE1QzExNy41OTQgNzAuMzg4MSAxMTcuNDE1IDcwLjM1MTMgMTE3LjE5MiA3MC4zNTEzSDExNi4wMjVMMTE2LjAzMyA2OS41NTk2SDExNy41MThMMTE3Ljc3NSA2OS44NjU3QzExOC4xMTggNjkuODY4NCAxMTguMzk3IDY5LjkzNjQgMTE4LjYxMiA3MC4wNjk3QzExOC44MyA3MC4yMDMgMTE4Ljk5IDcwLjM3NDUgMTE5LjA5NCA3MC41ODRDMTE5LjE5NyA3MC43OTM1IDExOS4yNDkgNzEuMDE5MyAxMTkuMjQ5IDcxLjI2MTRDMTE5LjI0OSA3MS42NDIzIDExOS4xNjYgNzEuOTYyIDExOSA3Mi4yMjA1QzExOC44MzcgNzIuNDc5IDExOC41OTcgNzIuNjczNSAxMTguMjgxIDcyLjgwNDFDMTE3Ljk2OSA3Mi45MzQ3IDExNy41ODkgNzMgMTE3LjE0MyA3M1oiIGZpbGw9IndoaXRlIi8+CjxkZWZzPgo8ZmlsdGVyIGlkPSJmaWx0ZXIwX2RpXzQ2MTdfNDM0OTkiIHg9IjM0LjE3OTEiIHk9IjE0LjcwMTYiIHdpZHRoPSI3OC4zNTgyIiBoZWlnaHQ9IjEwMy43MzciIGZpbHRlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzPSJzUkdCIj4KPGZlRmxvb2QgZmxvb2Qtb3BhY2l0eT0iMCIgcmVzdWx0PSJCYWNrZ3JvdW5kSW1hZ2VGaXgiLz4KPGZlQ29sb3JNYXRyaXggaW49IlNvdXJjZUFscGhhIiB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMTI3IDAiIHJlc3VsdD0iaGFyZEFscGhhIi8+CjxmZU9mZnNldC8+CjxmZUdhdXNzaWFuQmx1ciBzdGREZXZpYXRpb249IjYuMjY4NjYiLz4KPGZlQ29tcG9zaXRlIGluMj0iaGFyZEFscGhhIiBvcGVyYXRvcj0ib3V0Ii8+CjxmZUNvbG9yTWF0cml4IHR5cGU9Im1hdHJpeCIgdmFsdWVzPSIwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwLjM2IDAiLz4KPGZlQmxlbmQgbW9kZT0ibm9ybWFsIiBpbjI9IkJhY2tncm91bmRJbWFnZUZpeCIgcmVzdWx0PSJlZmZlY3QxX2Ryb3BTaGFkb3dfNDYxN180MzQ5OSIvPgo8ZmVCbGVuZCBtb2RlPSJub3JtYWwiIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9ImVmZmVjdDFfZHJvcFNoYWRvd180NjE3XzQzNDk5IiByZXN1bHQ9InNoYXBlIi8+CjxmZUNvbG9yTWF0cml4IGluPSJTb3VyY2VBbHBoYSIgdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDEyNyAwIiByZXN1bHQ9ImhhcmRBbHBoYSIvPgo8ZmVPZmZzZXQvPgo8ZmVHYXVzc2lhbkJsdXIgc3RkRGV2aWF0aW9uPSIzLjEzNDMzIi8+CjxmZUNvbXBvc2l0ZSBpbjI9ImhhcmRBbHBoYSIgb3BlcmF0b3I9ImFyaXRobWV0aWMiIGsyPSItMSIgazM9IjEiLz4KPGZlQ29sb3JNYXRyaXggdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAuNDUgMCIvPgo8ZmVCbGVuZCBtb2RlPSJub3JtYWwiIGluMj0ic2hhcGUiIHJlc3VsdD0iZWZmZWN0Ml9pbm5lclNoYWRvd180NjE3XzQzNDk5Ii8+CjwvZmlsdGVyPgo8L2RlZnM+Cjwvc3ZnPgo=", - "description": "Displays the latest values of the attributes or time-series data in a polar area chart. Supports numeric values only.", + "description": "Displays the latest values of the attributes or time series data in a polar area chart. Supports numeric values only.", "descriptor": { "type": "latest", "sizeX": 5, diff --git a/application/src/main/data/json/system/widget_types/power_button.json b/application/src/main/data/json/system/widget_types/power_button.json index d90439d82f..30a3facd7b 100644 --- a/application/src/main/data/json/system/widget_types/power_button.json +++ b/application/src/main/data/json/system/widget_types/power_button.json @@ -2,8 +2,8 @@ "fqn": "power_button", "name": "Power button", "deprecated": false, - "image": "tb-image:cG93ZXItYnV0dG9uLnN2Zw==:IlBvd2VyIGJ1dHRvbiIgc3lzdGVtIHdpZGdldCBpbWFnZQ==;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGZpbHRlcj0idXJsKCNmaWx0ZXIwX2lfNDU4OF84ODQyMCkiPgo8cGF0aCBkPSJNMTQ2LjUgODBDMTQ2LjUgMTA1LjEyOSAxMjUuNjgxIDEyNS41IDEwMCAxMjUuNUM3NC4zMTg4IDEyNS41IDUzLjUgMTA1LjEyOSA1My41IDgwQzUzLjUgNTQuODcxIDc0LjMxODggMzQuNSAxMDAgMzQuNUMxMjUuNjgxIDM0LjUgMTQ2LjUgNTQuODcxIDE0Ni41IDgwWiIgZmlsbD0iIzNGNTJERCIvPgo8L2c+CjxwYXRoIGQ9Ik0xNDUuNSA4MEMxNDUuNSAxMDQuNTU2IDEyNS4xNSAxMjQuNSAxMDAgMTI0LjVDNzQuODUwNCAxMjQuNSA1NC41IDEwNC41NTYgNTQuNSA4MEM1NC41IDU1LjQ0MzcgNzQuODUwNCAzNS41IDEwMCAzNS41QzEyNS4xNSAzNS41IDE0NS41IDU1LjQ0MzcgMTQ1LjUgODBaIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiLz4KPGcgZmlsdGVyPSJ1cmwoI2ZpbHRlcjFfZF80NTg4Xzg4NDIwKSI+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTAwIDEzNS41QzEzMS4yMDQgMTM1LjUgMTU2LjUgMTEwLjY1MiAxNTYuNSA4MEMxNTYuNSA0OS4zNDgyIDEzMS4yMDQgMjQuNSAxMDAgMjQuNUM2OC43OTU5IDI0LjUgNDMuNSA0OS4zNDgyIDQzLjUgODBDNDMuNSAxMTAuNjUyIDY4Ljc5NTkgMTM1LjUgMTAwIDEzNS41Wk0xMDAgMTI1LjVDMTI1LjY4MSAxMjUuNSAxNDYuNSAxMDUuMTI5IDE0Ni41IDgwQzE0Ni41IDU0Ljg3MSAxMjUuNjgxIDM0LjUgMTAwIDM0LjVDNzQuMzE4OCAzNC41IDUzLjUgNTQuODcxIDUzLjUgODBDNTMuNSAxMDUuMTI5IDc0LjMxODggMTI1LjUgMTAwIDEyNS41WiIgZmlsbD0idXJsKCNwYWludDBfbGluZWFyXzQ1ODhfODg0MjApIi8+CjwvZz4KPHBhdGggZD0iTTk4LjM1NzQgNzkuMTg1NVY4MC4xNzM4Qzk4LjM1NzQgODEuMzQ4MyA5OC4yMTA2IDgyLjQwMSA5Ny45MTcgODMuMzMyQzk3LjYyMzQgODQuMjYzIDk3LjIwMDggODUuMDU0NCA5Ni42NDk0IDg1LjcwNjFDOTYuMDk4IDg2LjM1NzcgOTUuNDM1NSA4Ni44NTU1IDk0LjY2MjEgODcuMTk5MkM5My44OTU4IDg3LjU0MyA5My4wMzY1IDg3LjcxNDggOTIuMDg0IDg3LjcxNDhDOTEuMTYwMiA4Ny43MTQ4IDkwLjMxMTUgODcuNTQzIDg5LjUzODEgODcuMTk5MkM4OC43NzE4IDg2Ljg1NTUgODguMTA1OCA4Ni4zNTc3IDg3LjU0IDg1LjcwNjFDODYuOTgxNCA4NS4wNTQ0IDg2LjU0ODIgODQuMjYzIDg2LjI0MDIgODMuMzMyQzg1LjkzMjMgODIuNDAxIDg1Ljc3ODMgODEuMzQ4MyA4NS43NzgzIDgwLjE3MzhWNzkuMTg1NUM4NS43NzgzIDc4LjAxMTEgODUuOTI4NyA3Ni45NjE5IDg2LjIyOTUgNzYuMDM4MUM4Ni41Mzc0IDc1LjEwNzEgODYuOTcwNyA3NC4zMTU4IDg3LjUyOTMgNzMuNjY0MUM4OC4wODc5IDczLjAwNTIgODguNzUwMyA3Mi41MDM5IDg5LjUxNjYgNzIuMTYwMkM5MC4yOSA3MS44MTY0IDkxLjEzODcgNzEuNjQ0NSA5Mi4wNjI1IDcxLjY0NDVDOTMuMDE1IDcxLjY0NDUgOTMuODc0MyA3MS44MTY0IDk0LjY0MDYgNzIuMTYwMkM5NS40MTQxIDcyLjUwMzkgOTYuMDc2NSA3My4wMDUyIDk2LjYyNzkgNzMuNjY0MUM5Ny4xODY1IDc0LjMxNTggOTcuNjEyNiA3NS4xMDcxIDk3LjkwNjIgNzYuMDM4MUM5OC4yMDcgNzYuOTYxOSA5OC4zNTc0IDc4LjAxMTEgOTguMzU3NCA3OS4xODU1Wk05Ni4zMDU3IDgwLjE3MzhWNzkuMTY0MUM5Ni4zMDU3IDc4LjIzMzEgOTYuMjA5IDc3LjQwOTUgOTYuMDE1NiA3Ni42OTM0Qzk1LjgyOTQgNzUuOTc3MiA5NS41NTM3IDc1LjM3NTcgOTUuMTg4NSA3NC44ODg3Qzk0LjgyMzIgNzQuNDAxNyA5NC4zNzU3IDc0LjAzMjkgOTMuODQ1NyA3My43ODIyQzkzLjMyMjkgNzMuNTMxNiA5Mi43Mjg1IDczLjQwNjIgOTIuMDYyNSA3My40MDYyQzkxLjQxOCA3My40MDYyIDkwLjgzNDMgNzMuNTMxNiA5MC4zMTE1IDczLjc4MjJDODkuNzk1OSA3NC4wMzI5IDg5LjM1MTkgNzQuNDAxNyA4OC45Nzk1IDc0Ljg4ODdDODguNjE0MyA3NS4zNzU3IDg4LjMzMTQgNzUuOTc3MiA4OC4xMzA5IDc2LjY5MzRDODcuOTMwMyA3Ny40MDk1IDg3LjgzMDEgNzguMjMzMSA4Ny44MzAxIDc5LjE2NDFWODAuMTczOEM4Ny44MzAxIDgxLjExMiA4Ny45MzAzIDgxLjk0MjcgODguMTMwOSA4Mi42NjZDODguMzMxNCA4My4zODIyIDg4LjYxNzggODMuOTg3MyA4OC45OTAyIDg0LjQ4MTRDODkuMzY5OCA4NC45Njg0IDg5LjgxNzQgODUuMzM3MiA5MC4zMzMgODUuNTg3OUM5MC44NTU4IDg1LjgzODUgOTEuNDM5NSA4NS45NjM5IDkyLjA4NCA4NS45NjM5QzkyLjc1NzIgODUuOTYzOSA5My4zNTUxIDg1LjgzODUgOTMuODc3OSA4NS41ODc5Qzk0LjQwMDcgODUuMzM3MiA5NC44NDExIDg0Ljk2ODQgOTUuMTk5MiA4NC40ODE0Qzk1LjU2NDUgODMuOTg3MyA5NS44NDAyIDgzLjM4MjIgOTYuMDI2NCA4Mi42NjZDOTYuMjEyNiA4MS45NDI3IDk2LjMwNTcgODEuMTEyIDk2LjMwNTcgODAuMTczOFpNMTEzLjQ5MyA3MS44NTk0Vjg3LjVIMTExLjQwOUwxMDMuNTM1IDc1LjQzNjVWODcuNUgxMDEuNDYyVjcxLjg1OTRIMTAzLjUzNUwxMTEuNDQxIDgzLjk1NTFWNzEuODU5NEgxMTMuNDkzWiIgZmlsbD0id2hpdGUiLz4KPGRlZnM+CjxmaWx0ZXIgaWQ9ImZpbHRlcjBfaV80NTg4Xzg4NDIwIiB4PSI0OC41IiB5PSIzNC41IiB3aWR0aD0iOTgiIGhlaWdodD0iOTYiIGZpbHRlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzPSJzUkdCIj4KPGZlRmxvb2QgZmxvb2Qtb3BhY2l0eT0iMCIgcmVzdWx0PSJCYWNrZ3JvdW5kSW1hZ2VGaXgiLz4KPGZlQmxlbmQgbW9kZT0ibm9ybWFsIiBpbj0iU291cmNlR3JhcGhpYyIgaW4yPSJCYWNrZ3JvdW5kSW1hZ2VGaXgiIHJlc3VsdD0ic2hhcGUiLz4KPGZlQ29sb3JNYXRyaXggaW49IlNvdXJjZUFscGhhIiB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMTI3IDAiIHJlc3VsdD0iaGFyZEFscGhhIi8+CjxmZU9mZnNldCBkeD0iLTUiIGR5PSI1Ii8+CjxmZUdhdXNzaWFuQmx1ciBzdGREZXZpYXRpb249IjQiLz4KPGZlQ29tcG9zaXRlIGluMj0iaGFyZEFscGhhIiBvcGVyYXRvcj0iYXJpdGhtZXRpYyIgazI9Ii0xIiBrMz0iMSIvPgo8ZmVDb2xvck1hdHJpeCB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMC4xNSAwIi8+CjxmZUJsZW5kIG1vZGU9Im5vcm1hbCIgaW4yPSJzaGFwZSIgcmVzdWx0PSJlZmZlY3QxX2lubmVyU2hhZG93XzQ1ODhfODg0MjAiLz4KPC9maWx0ZXI+CjxmaWx0ZXIgaWQ9ImZpbHRlcjFfZF80NTg4Xzg4NDIwIiB4PSIzNS41IiB5PSIyMC41IiB3aWR0aD0iMTI5IiBoZWlnaHQ9IjEyNyIgZmlsdGVyVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBjb2xvci1pbnRlcnBvbGF0aW9uLWZpbHRlcnM9InNSR0IiPgo8ZmVGbG9vZCBmbG9vZC1vcGFjaXR5PSIwIiByZXN1bHQ9IkJhY2tncm91bmRJbWFnZUZpeCIvPgo8ZmVDb2xvck1hdHJpeCBpbj0iU291cmNlQWxwaGEiIHR5cGU9Im1hdHJpeCIgdmFsdWVzPSIwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAxMjcgMCIgcmVzdWx0PSJoYXJkQWxwaGEiLz4KPGZlT2Zmc2V0IGR5PSI0Ii8+CjxmZUdhdXNzaWFuQmx1ciBzdGREZXZpYXRpb249IjQiLz4KPGZlQ29tcG9zaXRlIGluMj0iaGFyZEFscGhhIiBvcGVyYXRvcj0ib3V0Ii8+CjxmZUNvbG9yTWF0cml4IHR5cGU9Im1hdHJpeCIgdmFsdWVzPSIwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwLjA4IDAiLz4KPGZlQmxlbmQgbW9kZT0ibm9ybWFsIiBpbjI9IkJhY2tncm91bmRJbWFnZUZpeCIgcmVzdWx0PSJlZmZlY3QxX2Ryb3BTaGFkb3dfNDU4OF84ODQyMCIvPgo8ZmVCbGVuZCBtb2RlPSJub3JtYWwiIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9ImVmZmVjdDFfZHJvcFNoYWRvd180NTg4Xzg4NDIwIiByZXN1bHQ9InNoYXBlIi8+CjwvZmlsdGVyPgo8bGluZWFyR3JhZGllbnQgaWQ9InBhaW50MF9saW5lYXJfNDU4OF84ODQyMCIgeDE9IjY1Ljg5NjQiIHkxPSIxMjQuNSIgeDI9IjEyOS41MTkiIHkyPSIzMy45MjQ4IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CjxzdG9wIHN0b3AtY29sb3I9IiNDQ0NDQ0MiLz4KPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSJ3aGl0ZSIvPgo8L2xpbmVhckdyYWRpZW50Pgo8L2RlZnM+Cjwvc3ZnPgo=", - "description": "Sends the command to the device or updates attribute/time-series when the user pushes the button. Widget settings will enable you to configure behavior how to fetch the initial state and what to trigger when power on/off states.", + "image": "tb-image:cG93ZXItYnV0dG9uLnN2Zw==:IlBvd2VyIGJ1dHRvbiIgc3lzdGVtIHdpZGdldCBpbWFnZQ==:SU1BR0U=;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGZpbHRlcj0idXJsKCNmaWx0ZXIwX2lfNDU4OF84ODQyMCkiPgo8cGF0aCBkPSJNMTQ2LjUgODBDMTQ2LjUgMTA1LjEyOSAxMjUuNjgxIDEyNS41IDEwMCAxMjUuNUM3NC4zMTg4IDEyNS41IDUzLjUgMTA1LjEyOSA1My41IDgwQzUzLjUgNTQuODcxIDc0LjMxODggMzQuNSAxMDAgMzQuNUMxMjUuNjgxIDM0LjUgMTQ2LjUgNTQuODcxIDE0Ni41IDgwWiIgZmlsbD0iIzNGNTJERCIvPgo8L2c+CjxwYXRoIGQ9Ik0xNDUuNSA4MEMxNDUuNSAxMDQuNTU2IDEyNS4xNSAxMjQuNSAxMDAgMTI0LjVDNzQuODUwNCAxMjQuNSA1NC41IDEwNC41NTYgNTQuNSA4MEM1NC41IDU1LjQ0MzcgNzQuODUwNCAzNS41IDEwMCAzNS41QzEyNS4xNSAzNS41IDE0NS41IDU1LjQ0MzcgMTQ1LjUgODBaIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiLz4KPGcgZmlsdGVyPSJ1cmwoI2ZpbHRlcjFfZF80NTg4Xzg4NDIwKSI+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTAwIDEzNS41QzEzMS4yMDQgMTM1LjUgMTU2LjUgMTEwLjY1MiAxNTYuNSA4MEMxNTYuNSA0OS4zNDgyIDEzMS4yMDQgMjQuNSAxMDAgMjQuNUM2OC43OTU5IDI0LjUgNDMuNSA0OS4zNDgyIDQzLjUgODBDNDMuNSAxMTAuNjUyIDY4Ljc5NTkgMTM1LjUgMTAwIDEzNS41Wk0xMDAgMTI1LjVDMTI1LjY4MSAxMjUuNSAxNDYuNSAxMDUuMTI5IDE0Ni41IDgwQzE0Ni41IDU0Ljg3MSAxMjUuNjgxIDM0LjUgMTAwIDM0LjVDNzQuMzE4OCAzNC41IDUzLjUgNTQuODcxIDUzLjUgODBDNTMuNSAxMDUuMTI5IDc0LjMxODggMTI1LjUgMTAwIDEyNS41WiIgZmlsbD0idXJsKCNwYWludDBfbGluZWFyXzQ1ODhfODg0MjApIi8+CjwvZz4KPHBhdGggZD0iTTk4LjM1NzQgNzkuMTg1NVY4MC4xNzM4Qzk4LjM1NzQgODEuMzQ4MyA5OC4yMTA2IDgyLjQwMSA5Ny45MTcgODMuMzMyQzk3LjYyMzQgODQuMjYzIDk3LjIwMDggODUuMDU0NCA5Ni42NDk0IDg1LjcwNjFDOTYuMDk4IDg2LjM1NzcgOTUuNDM1NSA4Ni44NTU1IDk0LjY2MjEgODcuMTk5MkM5My44OTU4IDg3LjU0MyA5My4wMzY1IDg3LjcxNDggOTIuMDg0IDg3LjcxNDhDOTEuMTYwMiA4Ny43MTQ4IDkwLjMxMTUgODcuNTQzIDg5LjUzODEgODcuMTk5MkM4OC43NzE4IDg2Ljg1NTUgODguMTA1OCA4Ni4zNTc3IDg3LjU0IDg1LjcwNjFDODYuOTgxNCA4NS4wNTQ0IDg2LjU0ODIgODQuMjYzIDg2LjI0MDIgODMuMzMyQzg1LjkzMjMgODIuNDAxIDg1Ljc3ODMgODEuMzQ4MyA4NS43NzgzIDgwLjE3MzhWNzkuMTg1NUM4NS43NzgzIDc4LjAxMTEgODUuOTI4NyA3Ni45NjE5IDg2LjIyOTUgNzYuMDM4MUM4Ni41Mzc0IDc1LjEwNzEgODYuOTcwNyA3NC4zMTU4IDg3LjUyOTMgNzMuNjY0MUM4OC4wODc5IDczLjAwNTIgODguNzUwMyA3Mi41MDM5IDg5LjUxNjYgNzIuMTYwMkM5MC4yOSA3MS44MTY0IDkxLjEzODcgNzEuNjQ0NSA5Mi4wNjI1IDcxLjY0NDVDOTMuMDE1IDcxLjY0NDUgOTMuODc0MyA3MS44MTY0IDk0LjY0MDYgNzIuMTYwMkM5NS40MTQxIDcyLjUwMzkgOTYuMDc2NSA3My4wMDUyIDk2LjYyNzkgNzMuNjY0MUM5Ny4xODY1IDc0LjMxNTggOTcuNjEyNiA3NS4xMDcxIDk3LjkwNjIgNzYuMDM4MUM5OC4yMDcgNzYuOTYxOSA5OC4zNTc0IDc4LjAxMTEgOTguMzU3NCA3OS4xODU1Wk05Ni4zMDU3IDgwLjE3MzhWNzkuMTY0MUM5Ni4zMDU3IDc4LjIzMzEgOTYuMjA5IDc3LjQwOTUgOTYuMDE1NiA3Ni42OTM0Qzk1LjgyOTQgNzUuOTc3MiA5NS41NTM3IDc1LjM3NTcgOTUuMTg4NSA3NC44ODg3Qzk0LjgyMzIgNzQuNDAxNyA5NC4zNzU3IDc0LjAzMjkgOTMuODQ1NyA3My43ODIyQzkzLjMyMjkgNzMuNTMxNiA5Mi43Mjg1IDczLjQwNjIgOTIuMDYyNSA3My40MDYyQzkxLjQxOCA3My40MDYyIDkwLjgzNDMgNzMuNTMxNiA5MC4zMTE1IDczLjc4MjJDODkuNzk1OSA3NC4wMzI5IDg5LjM1MTkgNzQuNDAxNyA4OC45Nzk1IDc0Ljg4ODdDODguNjE0MyA3NS4zNzU3IDg4LjMzMTQgNzUuOTc3MiA4OC4xMzA5IDc2LjY5MzRDODcuOTMwMyA3Ny40MDk1IDg3LjgzMDEgNzguMjMzMSA4Ny44MzAxIDc5LjE2NDFWODAuMTczOEM4Ny44MzAxIDgxLjExMiA4Ny45MzAzIDgxLjk0MjcgODguMTMwOSA4Mi42NjZDODguMzMxNCA4My4zODIyIDg4LjYxNzggODMuOTg3MyA4OC45OTAyIDg0LjQ4MTRDODkuMzY5OCA4NC45Njg0IDg5LjgxNzQgODUuMzM3MiA5MC4zMzMgODUuNTg3OUM5MC44NTU4IDg1LjgzODUgOTEuNDM5NSA4NS45NjM5IDkyLjA4NCA4NS45NjM5QzkyLjc1NzIgODUuOTYzOSA5My4zNTUxIDg1LjgzODUgOTMuODc3OSA4NS41ODc5Qzk0LjQwMDcgODUuMzM3MiA5NC44NDExIDg0Ljk2ODQgOTUuMTk5MiA4NC40ODE0Qzk1LjU2NDUgODMuOTg3MyA5NS44NDAyIDgzLjM4MjIgOTYuMDI2NCA4Mi42NjZDOTYuMjEyNiA4MS45NDI3IDk2LjMwNTcgODEuMTEyIDk2LjMwNTcgODAuMTczOFpNMTEzLjQ5MyA3MS44NTk0Vjg3LjVIMTExLjQwOUwxMDMuNTM1IDc1LjQzNjVWODcuNUgxMDEuNDYyVjcxLjg1OTRIMTAzLjUzNUwxMTEuNDQxIDgzLjk1NTFWNzEuODU5NEgxMTMuNDkzWiIgZmlsbD0id2hpdGUiLz4KPGRlZnM+CjxmaWx0ZXIgaWQ9ImZpbHRlcjBfaV80NTg4Xzg4NDIwIiB4PSI0OC41IiB5PSIzNC41IiB3aWR0aD0iOTgiIGhlaWdodD0iOTYiIGZpbHRlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzPSJzUkdCIj4KPGZlRmxvb2QgZmxvb2Qtb3BhY2l0eT0iMCIgcmVzdWx0PSJCYWNrZ3JvdW5kSW1hZ2VGaXgiLz4KPGZlQmxlbmQgbW9kZT0ibm9ybWFsIiBpbj0iU291cmNlR3JhcGhpYyIgaW4yPSJCYWNrZ3JvdW5kSW1hZ2VGaXgiIHJlc3VsdD0ic2hhcGUiLz4KPGZlQ29sb3JNYXRyaXggaW49IlNvdXJjZUFscGhhIiB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMTI3IDAiIHJlc3VsdD0iaGFyZEFscGhhIi8+CjxmZU9mZnNldCBkeD0iLTUiIGR5PSI1Ii8+CjxmZUdhdXNzaWFuQmx1ciBzdGREZXZpYXRpb249IjQiLz4KPGZlQ29tcG9zaXRlIGluMj0iaGFyZEFscGhhIiBvcGVyYXRvcj0iYXJpdGhtZXRpYyIgazI9Ii0xIiBrMz0iMSIvPgo8ZmVDb2xvck1hdHJpeCB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMC4xNSAwIi8+CjxmZUJsZW5kIG1vZGU9Im5vcm1hbCIgaW4yPSJzaGFwZSIgcmVzdWx0PSJlZmZlY3QxX2lubmVyU2hhZG93XzQ1ODhfODg0MjAiLz4KPC9maWx0ZXI+CjxmaWx0ZXIgaWQ9ImZpbHRlcjFfZF80NTg4Xzg4NDIwIiB4PSIzNS41IiB5PSIyMC41IiB3aWR0aD0iMTI5IiBoZWlnaHQ9IjEyNyIgZmlsdGVyVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBjb2xvci1pbnRlcnBvbGF0aW9uLWZpbHRlcnM9InNSR0IiPgo8ZmVGbG9vZCBmbG9vZC1vcGFjaXR5PSIwIiByZXN1bHQ9IkJhY2tncm91bmRJbWFnZUZpeCIvPgo8ZmVDb2xvck1hdHJpeCBpbj0iU291cmNlQWxwaGEiIHR5cGU9Im1hdHJpeCIgdmFsdWVzPSIwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAxMjcgMCIgcmVzdWx0PSJoYXJkQWxwaGEiLz4KPGZlT2Zmc2V0IGR5PSI0Ii8+CjxmZUdhdXNzaWFuQmx1ciBzdGREZXZpYXRpb249IjQiLz4KPGZlQ29tcG9zaXRlIGluMj0iaGFyZEFscGhhIiBvcGVyYXRvcj0ib3V0Ii8+CjxmZUNvbG9yTWF0cml4IHR5cGU9Im1hdHJpeCIgdmFsdWVzPSIwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwLjA4IDAiLz4KPGZlQmxlbmQgbW9kZT0ibm9ybWFsIiBpbjI9IkJhY2tncm91bmRJbWFnZUZpeCIgcmVzdWx0PSJlZmZlY3QxX2Ryb3BTaGFkb3dfNDU4OF84ODQyMCIvPgo8ZmVCbGVuZCBtb2RlPSJub3JtYWwiIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9ImVmZmVjdDFfZHJvcFNoYWRvd180NTg4Xzg4NDIwIiByZXN1bHQ9InNoYXBlIi8+CjwvZmlsdGVyPgo8bGluZWFyR3JhZGllbnQgaWQ9InBhaW50MF9saW5lYXJfNDU4OF84ODQyMCIgeDE9IjY1Ljg5NjQiIHkxPSIxMjQuNSIgeDI9IjEyOS41MTkiIHkyPSIzMy45MjQ4IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CjxzdG9wIHN0b3AtY29sb3I9IiNDQ0NDQ0MiLz4KPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSJ3aGl0ZSIvPgo8L2xpbmVhckdyYWRpZW50Pgo8L2RlZnM+Cjwvc3ZnPgo=", + "description": "Sends the command to the device or updates attribute/time series when the user pushes the button. Widget settings will enable you to configure behavior how to fetch the initial state and what to trigger when power on/off states.", "descriptor": { "type": "rpc", "sizeX": 3.5, @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n self.ctx.$scope.actionWidget.onInit();\n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '280px',\n previewHeight: '280px',\n embedTitlePanel: true,\n displayRpcMessageToast: false\n };\n};\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.actionWidget.onInit();\n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '280px',\n previewHeight: '280px',\n embedTitlePanel: true,\n overflowVisible: true,\n displayRpcMessageToast: false\n };\n};\n\nself.onDestroy = function() {\n}\n", "settingsSchema": "", "dataKeySettingsSchema": "{}\n", "settingsDirective": "tb-power-button-widget-settings", diff --git a/application/src/main/data/json/system/widget_types/radar.json b/application/src/main/data/json/system/widget_types/radar.json index 06c51d6296..c49e42cb64 100644 --- a/application/src/main/data/json/system/widget_types/radar.json +++ b/application/src/main/data/json/system/widget_types/radar.json @@ -3,7 +3,7 @@ "name": "Radar", "deprecated": false, "image": "tb-image:cmFkYXIuc3Zn:IlJhZGFyIiBzeXN0ZW0gd2lkZ2V0IGltYWdl;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTYwIiByeD0iNCIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMwLjk4NDQgMTQwLjA1MUw5OS40OTIxIDIwLjU3MzNMMTY4IDE0MC4wNTFIMzAuOTg0NFoiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjM4IiBzdHJva2Utd2lkdGg9IjAuNTcwMzEzIi8+CjxwYXRoIGQ9Ik00OC42MzU2IDEyOS42MzdMOTkuNDkyIDQwLjI2NDFMMTUwLjM0OCAxMjkuNjM3SDQ4LjYzNTZaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjA0IiBzdHJva2U9IiNFMEUwRTAiIHN0cm9rZS13aWR0aD0iMC41NzAzMTMiLz4KPHBhdGggZD0iTTk5LjQ5MiA1Ny4xODc1TDEzNS45MDIgMTIxLjE3Mkg2My4wODI1TDk5LjQ5MiA1Ny4xODc1WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTYzLjU3MjkgMTIwLjg4N0w5OS40OTIgNTcuNzY0MUwxMzUuNDExIDEyMC44ODdINjMuNTcyOVoiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjEyIiBzdHJva2Utd2lkdGg9IjAuNTcwMzEzIi8+CjxwYXRoIGQ9Ik03OS40NDM4IDExMS42MzdMOTkuNDkyMiA3Ni40MDUyTDExOS41NDEgMTExLjYzN0g3OS40NDM4WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC4wNCIgc3Ryb2tlPSIjRTBFMEUwIiBzdHJva2Utd2lkdGg9IjAuNTcwMzEzIi8+CjxwYXRoIGQ9Ik05OS40OTE5IDkzLjI4MTJMMTA1LjA5MyAxMDMuMTI1SDkzLjg5MDVMOTkuNDkxOSA5My4yODEyWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTk0LjM4MDggMTAyLjg0TDk5LjQ5MTkgOTMuODU3OEwxMDQuNjAzIDEwMi44NEg5NC4zODA4WiIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiIHN0cm9rZS13aWR0aD0iMC41NzAzMTMiLz4KPHBhdGggZD0iTTMxLjA2MjUgMTM5Ljc2Nkw5OS40OTQ1IDk5Ljk1ODUiIHN0cm9rZT0iIzlFOUU5RSIgc3Ryb2tlLXdpZHRoPSIwLjYxMzA5MSIvPgo8cGF0aCBkPSJNMTY3LjkyMiAxMzkuNzY2TDk5LjQ5MTkgOTkuOTU1NCIgc3Ryb2tlPSIjOUU5RTlFIiBzdHJva2Utd2lkdGg9IjAuNjEzMDkxIi8+CjxwYXRoIGQ9Ik05OS40OTIyIDIxLjE0MDFMOTkuNDkyMiA5OS44NDMzIiBzdHJva2U9IiM5RTlFOUUiIHN0cm9rZS13aWR0aD0iMC42MTMwOTEiLz4KPHBhdGggZD0iTTk5LjQyNzIgNTUuMzgyOEwxMTQuNzEgMTA4LjY5MyIgc3Ryb2tlPSIjM0Y1MkREIiBzdHJva2Utd2lkdGg9IjAuNjEzMDkxIi8+CjxwYXRoIGQ9Ik0xMTQuNzExIDEwOC42OTNMNTEuNjYyNyAxMjcuMjYxIiBzdHJva2U9IiMzRjUyREQiIHN0cm9rZS13aWR0aD0iMC42MTMwOTEiLz4KPHBhdGggZD0iTTk5LjQyNzIgNTUuMzgyOEw1MS42NjI0IDEyNy4yNjEiIHN0cm9rZT0iIzNGNTJERCIgc3Ryb2tlLXdpZHRoPSIwLjYxMzA5MSIvPgo8ZWxsaXBzZSBjeD0iNTEuNjYyOCIgY3k9IjEyNy4yNjEiIHJ4PSIyLjE3NDMyIiByeT0iMi4xODQ0NSIgZmlsbD0iIzNGNTJERCIvPgo8ZWxsaXBzZSBjeD0iOTkuNDkyMiIgY3k9IjU1LjE3NDIiIHJ4PSIyLjE3NDMyIiByeT0iMi4xODQ0NSIgZmlsbD0iIzNGNTJERCIvPgo8ZWxsaXBzZSBjeD0iMTE0LjcxMSIgY3k9IjEwOC42OTMiIHJ4PSIyLjE3NDMyIiByeT0iMi4xODQ0NSIgZmlsbD0iIzNGNTJERCIvPgo8cGF0aCBkPSJNMjguMTMyOCAxNDkuMTc2TDI2LjUxMTcgMTU0SDI1LjM3NUwyNy41IDE0OC4zMTJIMjguMjI2NkwyOC4xMzI4IDE0OS4xNzZaTTI5LjQ4ODMgMTU0TDI3Ljg1OTQgMTQ5LjE3NkwyNy43NjE3IDE0OC4zMTJIMjguNDkyMkwzMC42Mjg5IDE1NEgyOS40ODgzWk0yOS40MTQxIDE1MS44ODdWMTUyLjczNEgyNi4zNjMzVjE1MS44ODdIMjkuNDE0MVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTE3Mi4yNTQgMTUyLjEyOUgxNzMuMzI4QzE3My4yOTkgMTUyLjUwOSAxNzMuMTk0IDE1Mi44NDYgMTczLjAxMiAxNTMuMTQxQzE3Mi44MjkgMTUzLjQzMiAxNzIuNTc2IDE1My42NjEgMTcyLjI1IDE1My44MjhDMTcxLjkyNCAxNTMuOTk1IDE3MS41MyAxNTQuMDc4IDE3MS4wNjYgMTU0LjA3OEMxNzAuNzEgMTU0LjA3OCAxNzAuMzg4IDE1NC4wMTYgMTcwLjEwMiAxNTMuODkxQzE2OS44MTggMTUzLjc2MyAxNjkuNTc0IDE1My41ODIgMTY5LjM3MSAxNTMuMzQ4QzE2OS4xNzEgMTUzLjExMSAxNjkuMDE3IDE1Mi44MjcgMTY4LjkxIDE1Mi40OTZDMTY4LjgwMyAxNTIuMTYzIDE2OC43NSAxNTEuNzg5IDE2OC43NSAxNTEuMzc1VjE1MC45NDFDMTY4Ljc1IDE1MC41MjcgMTY4LjgwNSAxNTAuMTU0IDE2OC45MTQgMTQ5LjgyQzE2OS4wMjMgMTQ5LjQ4NyAxNjkuMTggMTQ5LjIwMyAxNjkuMzgzIDE0OC45NjlDMTY5LjU4OSAxNDguNzMyIDE2OS44MzUgMTQ4LjU0OSAxNzAuMTIxIDE0OC40MjJDMTcwLjQxIDE0OC4yOTQgMTcwLjczMyAxNDguMjMgMTcxLjA5IDE0OC4yM0MxNzEuNTUzIDE0OC4yMyAxNzEuOTQ1IDE0OC4zMTYgMTcyLjI2NiAxNDguNDg4QzE3Mi41ODYgMTQ4LjY1OCAxNzIuODM1IDE0OC44OTEgMTczLjAxMiAxNDkuMTg4QzE3My4xODkgMTQ5LjQ4NCAxNzMuMjk2IDE0OS44MjYgMTczLjMzMiAxNTAuMjExSDE3Mi4yNThDMTcyLjIzNyAxNDkuOTcxIDE3Mi4xODUgMTQ5Ljc2OCAxNzIuMTAyIDE0OS42MDJDMTcyLjAyMSAxNDkuNDM1IDE3MS44OTggMTQ5LjMwOSAxNzEuNzM0IDE0OS4yMjNDMTcxLjU3MyAxNDkuMTM0IDE3MS4zNTggMTQ5LjA5IDE3MS4wOSAxNDkuMDlDMTcwLjg4MiAxNDkuMDkgMTcwLjY5OCAxNDkuMTI5IDE3MC41MzkgMTQ5LjIwN0MxNzAuMzgzIDE0OS4yODUgMTcwLjI1MyAxNDkuNDAyIDE3MC4xNDggMTQ5LjU1OUMxNzAuMDQ0IDE0OS43MTIgMTY5Ljk2NiAxNDkuOTA1IDE2OS45MTQgMTUwLjEzN0MxNjkuODYyIDE1MC4zNjYgMTY5LjgzNiAxNTAuNjMyIDE2OS44MzYgMTUwLjkzNFYxNTEuMzc1QzE2OS44MzYgMTUxLjY2NCAxNjkuODU5IDE1MS45MjMgMTY5LjkwNiAxNTIuMTUyQzE2OS45NTMgMTUyLjM4MiAxNzAuMDI2IDE1Mi41NzYgMTcwLjEyNSAxNTIuNzM0QzE3MC4yMjQgMTUyLjg5MyAxNzAuMzUyIDE1My4wMTQgMTcwLjUwOCAxNTMuMDk4QzE3MC42NjQgMTUzLjE4MSAxNzAuODUgMTUzLjIyMyAxNzEuMDY2IDE1My4yMjNDMTcxLjMyOSAxNTMuMjIzIDE3MS41NDMgMTUzLjE4MSAxNzEuNzA3IDE1My4wOThDMTcxLjg3NCAxNTMuMDE0IDE3MiAxNTIuODkyIDE3Mi4wODYgMTUyLjczQzE3Mi4xNzQgMTUyLjU2OSAxNzIuMjMgMTUyLjM2OCAxNzIuMjU0IDE1Mi4xMjlaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0xMDAuMTg0IDguNDY0ODRIOTguNzMwNUw5OC43MjI3IDcuNzA3MDNIOTkuOTY4OEMxMDAuMTgyIDcuNzA3MDMgMTAwLjM2MSA3LjY3NzA4IDEwMC41MDQgNy42MTcxOUMxMDAuNjQ3IDcuNTU3MjkgMTAwLjc1NSA3LjQ3MDA1IDEwMC44MjggNy4zNTU0N0MxMDAuOTA0IDcuMjQwODkgMTAwLjk0MSA3LjEwMTU2IDEwMC45NDEgNi45Mzc1QzEwMC45NDEgNi43NTUyMSAxMDAuOTA2IDYuNjA2NzcgMTAwLjgzNiA2LjQ5MjE5QzEwMC43NjggNi4zNzc2IDEwMC42NjEgNi4yOTQyNyAxMDAuNTE2IDYuMjQyMTlDMTAwLjM3IDYuMTg3NSAxMDAuMTg1IDYuMTYwMTYgOTkuOTYwOSA2LjE2MDE2SDk5LjA3ODFWMTFIOTguMDAzOVY1LjMxMjVIOTkuOTYwOUMxMDAuMjg0IDUuMzEyNSAxMDAuNTcyIDUuMzQzNzUgMTAwLjgyNCA1LjQwNjI1QzEwMS4wNzkgNS40NjYxNSAxMDEuMjk2IDUuNTU5OSAxMDEuNDczIDUuNjg3NUMxMDEuNjUgNS44MTUxIDEwMS43ODQgNS45NzUyNiAxMDEuODc1IDYuMTY3OTdDMTAxLjk2OSA2LjM2MDY4IDEwMi4wMTYgNi41ODk4NCAxMDIuMDE2IDYuODU1NDdDMTAyLjAxNiA3LjA4OTg0IDEwMS45NjEgNy4zMDU5OSAxMDEuODUyIDcuNTAzOTFDMTAxLjc0NSA3LjY5OTIyIDEwMS41NzkgNy44NTgwNyAxMDEuMzU1IDcuOTgwNDdDMTAxLjEzNCA4LjEwMjg2IDEwMC44NTMgOC4xNzMxOCAxMDAuNTEyIDguMTkxNDFMMTAwLjE4NCA4LjQ2NDg0Wk0xMDAuMTM3IDExSDk4LjQxNDFMOTguODYzMyAxMC4xNTYySDEwMC4xMzdDMTAwLjM1IDEwLjE1NjIgMTAwLjUyNiAxMC4xMjExIDEwMC42NjQgMTAuMDUwOEMxMDAuODA1IDkuOTgwNDcgMTAwLjkwOSA5Ljg4NDExIDEwMC45NzcgOS43NjE3MkMxMDEuMDQ3IDkuNjM2NzIgMTAxLjA4MiA5LjQ5MzQ5IDEwMS4wODIgOS4zMzIwM0MxMDEuMDgyIDkuMTU0OTUgMTAxLjA1MSA5LjAwMTMgMTAwLjk4OCA4Ljg3MTA5QzEwMC45MjggOC43NDA4OSAxMDAuODMyIDguNjQwNjIgMTAwLjY5OSA4LjU3MDMxQzEwMC41NjkgOC41IDEwMC4zOTcgOC40NjQ4NCAxMDAuMTg0IDguNDY0ODRIOTkuMDY2NEw5OS4wNzQyIDcuNzA3MDNIMTAwLjQ5NkwxMDAuNzQyIDhDMTAxLjA3IDguMDAyNiAxMDEuMzM3IDguMDY3NzEgMTAxLjU0MyA4LjE5NTMxQzEwMS43NTEgOC4zMjI5MiAxMDEuOTA1IDguNDg2OTggMTAyLjAwNCA4LjY4NzVDMTAyLjEwMyA4Ljg4ODAyIDEwMi4xNTIgOS4xMDQxNyAxMDIuMTUyIDkuMzM1OTRDMTAyLjE1MiA5LjcwMDUyIDEwMi4wNzMgMTAuMDA2NSAxMDEuOTE0IDEwLjI1MzlDMTAxLjc1OCAxMC41MDEzIDEwMS41MjkgMTAuNjg3NSAxMDEuMjI3IDEwLjgxMjVDMTAwLjkyNyAxMC45Mzc1IDEwMC41NjQgMTEgMTAwLjEzNyAxMVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPC9zdmc+Cg==", - "description": "Displays the latest values of the attributes or time-series data in a radar chart. Supports numeric values only.", + "description": "Displays the latest values of the attributes or time series data in a radar chart. Supports numeric values only.", "descriptor": { "type": "latest", "sizeX": 5, diff --git a/application/src/main/data/json/system/widget_types/range_chart.json b/application/src/main/data/json/system/widget_types/range_chart.json index 72cbeeacbb..6aad7e0283 100644 --- a/application/src/main/data/json/system/widget_types/range_chart.json +++ b/application/src/main/data/json/system/widget_types/range_chart.json @@ -3,7 +3,7 @@ "name": "Range chart", "deprecated": false, "image": "tb-image:cmFuZ2VfY2hhcnRfc3lzdGVtX3dpZGdldF9pbWFnZS5wbmc=:IlJhbmdlIGNoYXJ0IiBzeXN0ZW0gd2lkZ2V0IGltYWdl;data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAC/VBMVEX////////8/PwAAAD///////+bsfP+wEzu7u73lkzz8/P1eWTT09PN1emRpeOcsvSxsbGQpeLc3NyXl5epqanjXHPLy8tti+OgoKD/wU26urr29vbts0fnkUf39/fIrYP4l02oqKjJroPl5eWUrPPDw8PCwsLJroSQqfKLpfKDn/HU1NSnu/X/tzD/pwNyku//vD//sB3m5ub2jj98mfB2lfD2hTD/rRPu8v3/3qD1fCD3+f7K1vnnjEf1eh30awSWrvOBnfFkhOLld1n1dRX0cxH/9N/1cVvld1jmjEf2izn/qQry9f2Yr/P/+u/+9u+RpuP82r//yGDdM1D3kEPzWD32gSixwfBWeN//2JDzY0v+vkf/siT+5+RBaNr7x6DbYWXxTC96mPD+7N+VqN35tID2emX3oWD3k0fm6/zc5PvV3vq2xveWrfL+8/Hd3d09ZNr/6b/6vZCPj4/2h3TfQl3bKkjaIUD/ujnxQCL0bwrC0PiWqN5Kbt3/79D6zMqGhob/0n/iUWn0aVH+vUPZGjr/tSuwwvZpiOLR0dH+2I97e3v1dWDkcV3fPVnxSCr/qw+Jo/F/m/GLoum3xOj64uZef+H41NoyW9hdfNbJycnxqLT7x5/4no/nb4P/zXDiVm3hS2TlcV3zXEPyUzj+uTTwRCbkbRB+mOaareN5k+FyjuBHbNw4YNn7xr3/47D70LD5t6zumqjngJH/2I+Ojo7lYnf4oWC+mF7utEj2iDTmfSrxRyrunw/kZwLL1vWWrfS/zPORqfP98fKjt/GGofHv7+/+7erK0umHnuKDm+Lf39/t5d7829bm2dT949Dzt8HqwLFse6r4raDsjJz3lIPopHzGpnT4q3DZVmXSNkvnj0LnjD7mgjbjWzHiSzHwRyrunQXv8/3+9POquOWot+Tu4s3u3b3rzrfUxrHu166boa6coK76uKzTvJvoppv6vpDuyYfdboDmkXH1fGffR2H3oV/kbVnnjkHtrTbOIDTiUiLhUCD/rhTuoROM0RHwAAAABXRSTlMg77MAvxFwlo8AAA5CSURBVHja1JtJaBNRGMfj8scXk8MwSYZxoiFhaC9FEQoRA7YHE6K9iMZ4KIoWcTkpqK3oRVH05HISwX1Db66guF48eHHFBfcFQUUQ933BL6PxZZyZRE2+of4aMhPyWvKb73vzf3nQQL9A/z743+lLGoE+Qlea0JLShVA0mFHFwP9Hn0CgL7QwdERgKnGlmY7QBYgNGwY2lleHBjLSNwAIpdlAFPRIQERBsIjcvDGQEUEimqLq0FFysUR4WLKiG5yQiKGiJGIqIJYCKQEGDhV6wAmJCD3VBDWqCxCmrjeBg+7tK06DkQD8Yd3C2ML1YMM/kY2FWM9GsOGfyLLuWOEg2PBPZMW22JotYMM3kYs9sdjbw2DDN5Eta2KxbQWw4ZtIYRmJrAAbfomsXxgjFq4DF36JXCmQB2eQ+CWybHuMKPAFiU8i61bESqy5Ai58EtnYY4l08wWJTyKHuy2R7d3gwieRnmWkwRok/ogsoc6y4FzI84tYsU4wBwm/iBXrP+i5CC74RcqxTnAu5C0RMypgppQ4lCaIlN6CxnKwUBZZcwhsWN/ZE4CeUA3TiCIiEImjoVCs/6T7MNggkXAk1YIo4lGoCaEDiQQIRRGN4fynaWVu3BRskIjRBJ0sbPtaDRR5fm/1jtWrV+/YsXrH+3uCDRJpDpNI5Me+VlwnMYFG8qYz9IvdZ8AFicQjioKmlC4A0JFeNJSuvBRZuxdcBEAIlGcGkUBDWdIVkuy6DCb4c+TqzpmhED0sipvBBL9IMU8WZToXgAd+kSknQhUs7wQP/CKXd1WK5E88BAv8IvnllSKruO6/7CJzFq0KVbJ2ClhgF9nbFbLRdQ4ssIss6LSLFJ+BBXaRYinWZ9LPT3YyBQmviIx19iCpS2QOanK1GCJ4g6R+kQVLUIvO5SE7+SLqgkWkmEcNptwP/c4i1AeDyIGu3VNqDSn+DyL5zp3VZ66M9Zm9WeTcfVpwVJvvtliXInNQBwwiFHU1UkHGOvcaRYqYKoSiAc0mEs2KwB8wZRFF3apdVa8vuRIzfRRZaiAiVMPUDOiq+KN9rQO7an/jo1h3cIJTxCgJIB5FWMR1wBSoTdGax/ku75LIWJ/pU0WEoRr4y32tS1/GjB4zevSYu9eEJ6/vjh5DY8pPNJ6ePl8SPARIIBqZr0ZKFQHiEUATtUXGHh87dtSosaPu7DkrvDh+ZxQNGVt6EHQsne15IngIgKCKGIaigaCDjppMXxz8SW6G55j2oAu58eDBEkGCZExYmCZqc6Qt+JOOHDzYRGOcZPeDgX/OkczEYLJ8iafCnWQ6SNA4G21T8Y9wiGzKyk/WkYE77bN/OPRmkcyx2s2yn2R7u8h4muqybTpa4casScEKkr1SZFbaPn/Hw4VMa9CNzAz8Exwi09sn2q71MbeSXKCquTGpF4lMbfutXxa7lORFeZAcm+xtInTvtZN2KUlrR4VE76zI1Jw9G4h2R0lkrCftKpNmwZ2qic8i0pp2XOtJrQ7brDSoHJ5MH/H6s+NQBQYRucySzHaUJFlhaxPyFMnMgycsIu9ss9irYXJyHtmF0h6tNa69FZ5wiIyjj+hWkun2hs86FX5WJAlXxufavXuLQ2Rqm3s8XIdExrq9raqsAzAvk/XuLQ6RTIcVCcnfS5IdB4kj1muLJI+lPXuLQ4SWWbUTm2I9+bci6dbZXr3FIkLLLHdm58ZV9Elb0FMk7T71KHfq6i1LZG6LQEJrAehovZA4cm62vffleZssiYx15xJlYsZjrlsLhDoo/9dbSjU0s8mAoqlV9rWut5UFHCoTqSQy1r1wisgaUm/VJ6LGof/Y12pW4zoQ9hJxLLNsRtlfJdmU/WsRus3V2VsBEIZm29fyZFMu6E0HlUTG+l+KpDvKi896RDQDKO9r6YDqua91+/iQKuwpb9btue096NYp4capD9Z7Z+va1zJ1VY0rYSoLoWtzI/AQefRgsIOj8vTjSWHx9KRzkDx7IFx4/ONXvr2sT0TTNBEPt8AirCXgwYSVA4hBA0rIM8mIkSixdfKAKgyDC/u+Wu8NHQ4XGp4j32kvYxWFgSiKVsOkVlnyVrARbJYJM1XID6QJWKQT/Qm7VAFBLLbKH1hZ2/iBq6s4WW/MDle8oCHeIeT47ry85MlW96pKyvy8zlntWcNA9rtfb5nMwXoDSOZ0W4Ik211hs7zoxhS5guQKZatbTRdgvQHEVfp/mSZp8yJ60QXSLC82kS0GZF30Acj9u7K9pF0gdaE1kS0SRIyWW0AQA+Mm8OtzkGOqNZEtDgSiT6qoFWhz63OQrXeAlM5v2HBh1dK1AtmDENkiQdwWk0SoE6SxmsgWB5KlQAAnLMg80ZrIFgdiDlw9EOSI/TD1NpmtOg8EqQstDAaudhlcfLHytnDZsqdAkNIE3St6ISBy8Gu5bM2TDEBCxiwuZfIMxNiz+VK21imAhIxZLBOAwF5n+9Z+FQiysp0DCfMkWW169jqbLVN9B4FkTeBtC5yBjyAL11oqTLbyRIeBWMO2KRSClDv9YrYy1wKZDM6f6HKcdIxZ0Hx7qiD35Pn/ua8iprpafLZK40GieBaf39PH0fBjOIUxC6YsXrtSPShZepfrW27rQT4HaqhmIxVHavoF67oKIFz/NY8gdeNn/tBs4evM6Q4SqdHlOAzovcKOKQACl6eytXB/QeLLMR7/kGoFK27DQDQXixzKTgVljxswKo6NwVBsCDgH5wMM/QbfNt18QU495Zz8RdkfyLEsLXT30OveSy+99Bs6ltWVlfHKMnoklhTNyPM0T5ITEtx8uNh7tYbt841vJyL0h4ep2qLfAj7/J/Lm7Y1Mxnv869a18bvWz3O4CBFRtJCIojAKQyyxjkVb7fqlTYTG2IVX7A/bZgc0wzq2n6O5icVza412yhvvc/4zn4Izuv+dBQrX7+aqlDw0kS9PV/xK4yO+OOftZ5xjo6tiwVWdKztdtl0vHY98buK+HUXZ8M7h6XEKjx/36PZ79BxZZRWTAEZBesaxFIGB24KOU2dlYMHWbB4SdLkbJXK3kfewRu1D5NNpYNR8H1ggSrO5dCKSVO7Bu1iK5GKivjOKWFg1su43y4y5EFnn03IBY927JCDzSbzq3KKtQ3YwUtpGCKNEkmU/CphIhHrAJZFGSDtpqCv52hJSbAyRHhkinY0mhAJ8clKZRH5lQwOARVvbhmWrPi/hQiSJjbmSF2dIB5KRJiDC0IBxbaUxO/XytSqYA5FtQe7hvXtlZO8kQ5naosfBMe1pZuNCRMSvSgd0050hECLVctiRaEsHbsoTYkLEkhAfgNG4JHISxMGureSBsf4iaXZAiVA5GsGAKvxOeOPUvs3IGFRbdG43697SH8/IqvCJWGvQbBcC8VVd4vw131hY5lYvkn3SOXybWRNCFzjVip0INVkqPMjrbsAEtLbo3NZyD2/656nEzJaQyvvJBEhiRmHVVir3KL1IyqymRKgTjdV/HwbHzwe1VSa7rnOzV0skZ5QI3bAnhEfU502tLsrBvRcky1QdQ0dKxJ4Qd4CfF32Wp09+epH8q+56WqSG4Wj98yCHlQ1JiCGBQhk6KONpQBD04pxH8DZ7moN4WfbgwF5d8Kh4c0XWb+Dfo2cRVARvfgdP+iH8pdlprKGtioudRztN8vq68zYv/ZPD9OKF1Ej+S4dUvbj9j68jsd4zqmhNs7V3uT7C5SshNVSMRgouHTOqBOe05ZKFB6qOR0KiThDbjWylNxpxkFx7VGeZjLAJrFjPaxUMogwPVPGovbeNrcZI8IeR206ylV7XfHdtN67Y62hJKAZZleKw+pP4n8BASrN16UPkbtwK/+1ohCuAjxpG8nCX34u/vW1Mm1IyZqvlNHp5dz1xEXvEiurXwiRCeaxpe/3LlzA/4xcqERqzNjdpJaZiq6Jv99TxZ2B9MWkKCgJRtTBUaLlZ0YF/+Zj9hMcviKlnmF48Di3rb+DHiFRqyykuQHBcWRD2vn8799/x/Ond27dp8bh79+mnJkctX2M9w4AxP7h/vsbOrMkdnT9/NI/1QRvBbCcaWS2a3MGTJ9Ha0I0slrWPJwdo4nDn4LCuDN3IfFln69UCTbxbrt7WlaEbidmarXZ/NXm0ipXBG6mz9epZwv08+gdvpM7W692EO3wfy4M3ss7WzrOUercXy8M3sliFDnmbUnNEDN8IZSs996YYvpGQreUC3dgAIz5bs94O2QAjPluvDtGDDTBC2Zod7aIHm2BksaJzbx82wch8+XoPfdgEI5jN0IuNMLJYoB/BCJNghpcYG+RGFQ6Dwhz9ODZSSIwFJLh1xTTMa93Z+m1o3cWWJyR98KBZ90a0Xs9r5fTxx/j4uYt9cELSq3mznnHFbDJBlx5y8MgAYe29CUXLgMAFhE72yjF4ZPBQcMcvdGSqMHmgcm/ojqAPR5RgDV1OznNrJlTUI6BEA3csMOGFI6kvpVJq1O1SoZRulzI+SqRdp1/B3wC2sN7dwwmsQM6Q534hWK6hRZVEte/8dsowBVglHVN9jK3SS8tKGtRuLUUuVYdUAO1SmSORdhkJ40VURsy+s4IZWzizfqWo1utdlOa0LTTXtI+K0ikXJFWGWTEqtGxKuVDtUlsY1ioV+7xMpL9rRAluBZ+CT41TiEYs84dkXPi/5YzjWkTpFh95KTPHUh6lYJapdinLmWmVagvjEunvGgGXgjNYIZSORuwEBMWclE6CGZgiSicY8Upa3hNcoJgKE6Vjy/dHHVLIVqkoyVkqzU6hFYrW0dgb4XBvxMgKRVtWH3LrnlJUo1VIioNkGPP4bbhvAFNUEUxyYh66WloRrdKy4JN2qeEGqTQ7i3+EPGnoZLvJvItlSMnTWbb57wJH9RLtM9lpbDpOnc3O/ACb/i1NA1ABvwAAAABJRU5ErkJggg==", - "description": "Displays changes to time-series data over time visualized with color ranges — for example, temperature or humidity readings.", + "description": "Displays changes to time series data over time visualized with color ranges — for example, temperature or humidity readings.", "descriptor": { "type": "timeseries", "sizeX": 8, diff --git a/application/src/main/data/json/system/widget_types/scada_symbol.json b/application/src/main/data/json/system/widget_types/scada_symbol.json new file mode 100644 index 0000000000..15c12e1b81 --- /dev/null +++ b/application/src/main/data/json/system/widget_types/scada_symbol.json @@ -0,0 +1,24 @@ +{ + "fqn": "scada_symbol", + "name": "SCADA symbol", + "deprecated": false, + "scada": true, + "image": null, + "description": "", + "descriptor": { + "type": "rpc", + "sizeX": 3, + "sizeY": 3, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.actionWidget.onInit();\n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '300px',\n previewHeight: '320px',\n embedTitlePanel: true,\n targetDeviceOptional: true,\n displayRpcMessageToast: false\n };\n};\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-scada-symbol-widget-settings", + "hasBasicMode": true, + "basicModeDirective": "tb-scada-symbol-basic-config", + "defaultConfig": "{\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"background\":{\"type\":\"color\",\"imageUrl\":null,\"color\":\"rgb(255, 255, 255)\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"padding\":\"12px\",\"scadaSymbolUrl\":\"\",\"scadaSymbolObjectSettings\":{\"behavior\":{},\"properties\":{}}},\"title\":\"SCADA symbol\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"actions\":{},\"widgetCss\":\"\",\"noDataDisplayMessage\":\"\",\"titleFont\":{\"size\":16,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"500\",\"style\":null,\"lineHeight\":\"1.6\"},\"showTitleIcon\":false,\"titleTooltip\":\"\",\"titleStyle\":null,\"pageSize\":1024,\"titleIcon\":\"mdi:lightbulb-outline\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"configMode\":\"basic\",\"targetDevice\":null,\"titleColor\":null,\"borderRadius\":\"0px\",\"margin\":\"0px\"}" + }, + "tags": null +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/single_switch.json b/application/src/main/data/json/system/widget_types/single_switch.json index 8faea8ba93..4507f531e3 100644 --- a/application/src/main/data/json/system/widget_types/single_switch.json +++ b/application/src/main/data/json/system/widget_types/single_switch.json @@ -3,7 +3,7 @@ "name": "Single Switch", "deprecated": false, "image": "tb-image:c2luZ2xlLXN3aXRjaC5zdmc=:IlNpbmdsZSBTd2l0Y2giIHN5c3RlbSB3aWRnZXQgaW1hZ2U=;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTYwIiByeD0iNCIgZmlsbD0id2hpdGUiLz4KPHJlY3QgeD0iMzEuNTc3MSIgeT0iNjUuMDc0MiIgd2lkdGg9IjU2Ljk5MDEiIGhlaWdodD0iMjkuODUyIiByeD0iMTQuOTI2IiBmaWxsPSIjNTQ2OUZGIi8+CjxjaXJjbGUgY3g9IjczLjY0MDkiIGN5PSI4MC4wMDAzIiByPSIxMi4yMTIyIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMTA5LjI0MSA4My40NzE3QzEwOS4yNDEgODMuMTQ5NCAxMDkuMTkxIDgyLjg2MyAxMDkuMDkxIDgyLjYxMjNDMTA4Ljk5OCA4Mi4zNjE3IDEwOC44MjkgODIuMTMyNSAxMDguNTg2IDgxLjkyNDhDMTA4LjM0MiA4MS43MTcxIDEwNy45OTkgODEuNTE2NiAxMDcuNTU1IDgxLjMyMzJDMTA3LjExOCA4MS4xMjI3IDEwNi41NTkgODAuOTE4NiAxMDUuODc5IDgwLjcxMDlDMTA1LjEzNCA4MC40ODE4IDEwNC40NDcgODAuMjI3NSAxMDMuODE2IDc5Ljk0ODJDMTAzLjE5MyA3OS42NjE4IDEwMi42NDkgNzkuMzMyNCAxMDIuMTg0IDc4Ljk2QzEwMS43MTggNzguNTgwNCAxMDEuMzU2IDc4LjE0NzEgMTAxLjA5OSA3Ny42NjAyQzEwMC44NDEgNzcuMTY2IDEwMC43MTIgNzYuNTk2NyAxMDAuNzEyIDc1Ljk1MjFDMTAwLjcxMiA3NS4zMTQ4IDEwMC44NDQgNzQuNzM0NyAxMDEuMTA5IDc0LjIxMTlDMTAxLjM4MiA3My42ODkxIDEwMS43NjUgNzMuMjM4IDEwMi4yNTkgNzIuODU4NEMxMDIuNzYgNzIuNDcxNyAxMDMuMzUxIDcyLjE3NDUgMTA0LjAzMSA3MS45NjY4QzEwNC43MTIgNzEuNzUyIDEwNS40NjQgNzEuNjQ0NSAxMDYuMjg3IDcxLjY0NDVDMTA3LjQ0NyA3MS42NDQ1IDEwOC40NDYgNzEuODU5NCAxMDkuMjg0IDcyLjI4OTFDMTEwLjEyOSA3Mi43MTg4IDExMC43NzcgNzMuMjk1MiAxMTEuMjI5IDc0LjAxODZDMTExLjY4NyA3NC43NDE5IDExMS45MTYgNzUuNTQwNCAxMTEuOTE2IDc2LjQxNDFIMTA5LjI0MUMxMDkuMjQxIDc1Ljg5ODQgMTA5LjEzIDc1LjQ0MzcgMTA4LjkwOCA3NS4wNDk4QzEwOC42OTMgNzQuNjQ4OCAxMDguMzY0IDc0LjMzMzcgMTA3LjkyIDc0LjEwNDVDMTA3LjQ4MyA3My44NzUzIDEwNi45MjggNzMuNzYwNyAxMDYuMjU1IDczLjc2MDdDMTA1LjYxOCA3My43NjA3IDEwNS4wODggNzMuODU3NCAxMDQuNjY1IDc0LjA1MDhDMTA0LjI0MyA3NC4yNDQxIDEwMy45MjcgNzQuNTA1NSAxMDMuNzIgNzQuODM1QzEwMy41MTIgNzUuMTY0NCAxMDMuNDA4IDc1LjUzNjggMTAzLjQwOCA3NS45NTIxQzEwMy40MDggNzYuMjQ1OCAxMDMuNDc2IDc2LjUxNDMgMTAzLjYxMiA3Ni43NTc4QzEwMy43NDggNzYuOTk0MSAxMDMuOTU2IDc3LjIxNjEgMTA0LjIzNSA3Ny40MjM4QzEwNC41MTUgNzcuNjI0MyAxMDQuODY2IDc3LjgxNDEgMTA1LjI4OCA3Ny45OTMyQzEwNS43MTEgNzguMTcyMiAxMDYuMjA4IDc4LjM0NDEgMTA2Ljc4MSA3OC41MDg4QzEwNy42NDggNzguNzY2NiAxMDguNDAzIDc5LjA1MzEgMTA5LjA0OCA3OS4zNjgyQzEwOS42OTIgNzkuNjc2MSAxMTAuMjI5IDgwLjAyNyAxMTAuNjU5IDgwLjQyMDlDMTExLjA4OSA4MC44MTQ4IDExMS40MTEgODEuMjYyNCAxMTEuNjI2IDgxLjc2MzdDMTExLjg0MSA4Mi4yNTc4IDExMS45NDggODIuODIgMTExLjk0OCA4My40NTAyQzExMS45NDggODQuMTA5IDExMS44MTYgODQuNzAzNSAxMTEuNTUxIDg1LjIzMzRDMTExLjI4NiA4NS43NTYyIDExMC45MDYgODYuMjAzOCAxMTAuNDEyIDg2LjU3NjJDMTA5LjkyNSA4Ni45NDE0IDEwOS4zMzggODcuMjI0MyAxMDguNjUgODcuNDI0OEMxMDcuOTcgODcuNjE4MiAxMDcuMjExIDg3LjcxNDggMTA2LjM3MyA4Ny43MTQ4QzEwNS42MjEgODcuNzE0OCAxMDQuODggODcuNjE0NiAxMDQuMTQ5IDg3LjQxNDFDMTAzLjQyNiA4Ny4yMTM1IDEwMi43NjcgODYuOTA5MiAxMDIuMTczIDg2LjUwMUMxMDEuNTc4IDg2LjA4NTYgMTAxLjEwNiA4NS41NyAxMDAuNzU1IDg0Ljk1NDFDMTAwLjQwNCA4NC4zMzExIDEwMC4yMjkgODMuNjA0MiAxMDAuMjI5IDgyLjc3MzRIMTAyLjkyNUMxMDIuOTI1IDgzLjI4MTkgMTAzLjAxMSA4My43MTUyIDEwMy4xODMgODQuMDczMkMxMDMuMzYyIDg0LjQzMTMgMTAzLjYwOSA4NC43MjQ5IDEwMy45MjQgODQuOTU0MUMxMDQuMjM5IDg1LjE3NjEgMTA0LjYwNCA4NS4zNDA4IDEwNS4wMiA4NS40NDgyQzEwNS40NDIgODUuNTU1NyAxMDUuODkzIDg1LjYwOTQgMTA2LjM3MyA4NS42MDk0QzEwNy4wMDMgODUuNjA5NCAxMDcuNTMgODUuNTE5OSAxMDcuOTUyIDg1LjM0MDhDMTA4LjM4MiA4NS4xNjE4IDEwOC43MDQgODQuOTExMSAxMDguOTE5IDg0LjU4ODlDMTA5LjEzNCA4NC4yNjY2IDEwOS4yNDEgODMuODk0MiAxMDkuMjQxIDgzLjQ3MTdaTTExNy41NzcgODQuOTIxOUwxMjAuMjYzIDc1Ljg3N0gxMjEuOTE3TDEyMS40NjYgNzguNTg0TDExOC43NTkgODcuNUgxMTcuMjc2TDExNy41NzcgODQuOTIxOVpNMTE1Ljk5OCA3NS44NzdMMTE4LjA5MyA4NC45NjQ4TDExOC4yNjUgODcuNUgxMTYuNjFMMTEzLjQ2MyA3NS44NzdIMTE1Ljk5OFpNMTI0LjQzMSA4NC44NTc0TDEyNi40NjEgNzUuODc3SDEyOC45ODVMMTI1Ljg0OSA4Ny41SDEyNC4xOTRMMTI0LjQzMSA4NC44NTc0Wk0xMjIuMTk2IDc1Ljg3N0wxMjQuODUgODQuODE0NUwxMjUuMTgzIDg3LjVIMTIzLjdMMTIwLjk2MSA3OC41NzMyTDEyMC41MSA3NS44NzdIMTIyLjE5NlpNMTMzLjg2MiA3NS44NzdWODcuNUgxMzEuMjYzVjc1Ljg3N0gxMzMuODYyWk0xMzEuMDkxIDcyLjgyNjJDMTMxLjA5MSA3Mi40MzIzIDEzMS4yMiA3Mi4xMDY0IDEzMS40NzggNzEuODQ4NkMxMzEuNzQzIDcxLjU4MzcgMTMyLjEwOCA3MS40NTEyIDEzMi41NzMgNzEuNDUxMkMxMzMuMDMyIDcxLjQ1MTIgMTMzLjM5MyA3MS41ODM3IDEzMy42NTggNzEuODQ4NkMxMzMuOTIzIDcyLjEwNjQgMTM0LjA1NiA3Mi40MzIzIDEzNC4wNTYgNzIuODI2MkMxMzQuMDU2IDczLjIxMjkgMTMzLjkyMyA3My41MzUyIDEzMy42NTggNzMuNzkzQzEzMy4zOTMgNzQuMDUwOCAxMzMuMDMyIDc0LjE3OTcgMTMyLjU3MyA3NC4xNzk3QzEzMi4xMDggNzQuMTc5NyAxMzEuNzQzIDc0LjA1MDggMTMxLjQ3OCA3My43OTNDMTMxLjIyIDczLjUzNTIgMTMxLjA5MSA3My4yMTI5IDEzMS4wOTEgNzIuODI2MlpNMTQyLjM3IDc1Ljg3N1Y3Ny43Njc2SDEzNS44MTdWNzUuODc3SDE0Mi4zN1pNMTM3LjcwOCA3My4wMzAzSDE0MC4yOTdWODQuMjg4MUMxNDAuMjk3IDg0LjY0NjIgMTQwLjM0NyA4NC45MjE5IDE0MC40NDcgODUuMTE1MkMxNDAuNTU1IDg1LjMwMTQgMTQwLjcwMSA4NS40MjY4IDE0MC44ODggODUuNDkxMkMxNDEuMDc0IDg1LjU1NTcgMTQxLjI5MiA4NS41ODc5IDE0MS41NDMgODUuNTg3OUMxNDEuNzIyIDg1LjU4NzkgMTQxLjg5NCA4NS41NzcxIDE0Mi4wNTkgODUuNTU1N0MxNDIuMjIzIDg1LjUzNDIgMTQyLjM1NiA4NS41MTI3IDE0Mi40NTYgODUuNDkxMkwxNDIuNDY3IDg3LjQ2NzhDMTQyLjI1MiA4Ny41MzIyIDE0Mi4wMDEgODcuNTg5NSAxNDEuNzE1IDg3LjYzOTZDMTQxLjQzNiA4Ny42ODk4IDE0MS4xMTMgODcuNzE0OCAxNDAuNzQ4IDg3LjcxNDhDMTQwLjE1NCA4Ny43MTQ4IDEzOS42MjcgODcuNjExIDEzOS4xNjkgODcuNDAzM0MxMzguNzExIDg3LjE4ODUgMTM4LjM1MyA4Ni44NDExIDEzOC4wOTUgODYuMzYxM0MxMzcuODM3IDg1Ljg4MTUgMTM3LjcwOCA4NS4yNDQxIDEzNy43MDggODQuNDQ5MlY3My4wMzAzWk0xNDkuNDYgODUuNjUyM0MxNDkuODgyIDg1LjY1MjMgMTUwLjI2MiA4NS41NyAxNTAuNTk5IDg1LjQwNTNDMTUwLjk0MiA4NS4yMzM0IDE1MS4yMTggODQuOTk3MSAxNTEuNDI2IDg0LjY5NjNDMTUxLjY0MSA4NC4zOTU1IDE1MS43NTkgODQuMDQ4MiAxNTEuNzggODMuNjU0M0gxNTQuMjE5QzE1NC4yMDQgODQuNDA2MiAxNTMuOTgyIDg1LjA5MDIgMTUzLjU1MyA4NS43MDYxQzE1My4xMjMgODYuMzIxOSAxNTIuNTU0IDg2LjgxMjUgMTUxLjg0NSA4Ny4xNzc3QzE1MS4xMzYgODcuNTM1OCAxNTAuMzUyIDg3LjcxNDggMTQ5LjQ5MiA4Ny43MTQ4QzE0OC42MDQgODcuNzE0OCAxNDcuODMxIDg3LjU2NDUgMTQ3LjE3MiA4Ny4yNjM3QzE0Ni41MTMgODYuOTU1NyAxNDUuOTY1IDg2LjUzMzIgMTQ1LjUyOCA4NS45OTYxQzE0NS4wOTEgODUuNDU5IDE0NC43NjIgODQuODM5NSAxNDQuNTQgODQuMTM3N0MxNDQuMzI1IDgzLjQzNTkgMTQ0LjIxOCA4Mi42ODM5IDE0NC4yMTggODEuODgxOFY4MS41MDU5QzE0NC4yMTggODAuNzAzOCAxNDQuMzI1IDc5Ljk1MTggMTQ0LjU0IDc5LjI1QzE0NC43NjIgNzguNTQxIDE0NS4wOTEgNzcuOTE4IDE0NS41MjggNzcuMzgwOUMxNDUuOTY1IDc2Ljg0MzggMTQ2LjUxMyA3Ni40MjQ4IDE0Ny4xNzIgNzYuMTI0QzE0Ny44MzEgNzUuODE2MSAxNDguNjAxIDc1LjY2MjEgMTQ5LjQ4MSA3NS42NjIxQzE1MC40MTIgNzUuNjYyMSAxNTEuMjI5IDc1Ljg0ODMgMTUxLjkzMSA3Ni4yMjA3QzE1Mi42MzIgNzYuNTg1OSAxNTMuMTg0IDc3LjA5OCAxNTMuNTg1IDc3Ljc1NjhDMTUzLjk5MyA3OC40MDg1IDE1NC4yMDQgNzkuMTY3NiAxNTQuMjE5IDgwLjAzNDJIMTUxLjc4QzE1MS43NTkgNzkuNjA0NSAxNTEuNjUxIDc5LjIxNzggMTUxLjQ1OCA3OC44NzRDMTUxLjI3MiA3OC41MjMxIDE1MS4wMDcgNzguMjQzOCAxNTAuNjYzIDc4LjAzNjFDMTUwLjMyNiA3Ny44Mjg1IDE0OS45MjIgNzcuNzI0NiAxNDkuNDQ5IDc3LjcyNDZDMTQ4LjkyNiA3Ny43MjQ2IDE0OC40OTMgNzcuODMyIDE0OC4xNDkgNzguMDQ2OUMxNDcuODA2IDc4LjI1NDYgMTQ3LjUzNyA3OC41NDEgMTQ3LjM0NCA3OC45MDYyQzE0Ny4xNSA3OS4yNjQzIDE0Ny4wMTEgNzkuNjY4OSAxNDYuOTI1IDgwLjEyMDFDMTQ2Ljg0NiA4MC41NjQxIDE0Ni44MDcgODEuMDI2IDE0Ni44MDcgODEuNTA1OVY4MS44ODE4QzE0Ni44MDcgODIuMzYxNyAxNDYuODQ2IDgyLjgyNzEgMTQ2LjkyNSA4My4yNzgzQzE0Ny4wMDQgODMuNzI5NSAxNDcuMTQgODQuMTM0MSAxNDcuMzMzIDg0LjQ5MjJDMTQ3LjUzNCA4NC44NDMxIDE0Ny44MDYgODUuMTI2IDE0OC4xNDkgODUuMzQwOEMxNDguNDkzIDg1LjU0ODUgMTQ4LjkzIDg1LjY1MjMgMTQ5LjQ2IDg1LjY1MjNaTTE1OS4xMDYgNzFWODcuNUgxNTYuNTI4VjcxSDE1OS4xMDZaTTE1OC42NTUgODEuMjU4OEwxNTcuODE3IDgxLjI0OEMxNTcuODI1IDgwLjQ0NiAxNTcuOTM2IDc5LjcwNDggMTU4LjE1IDc5LjAyNDRDMTU4LjM3MiA3OC4zNDQxIDE1OC42OCA3Ny43NTMzIDE1OS4wNzQgNzcuMjUyQzE1OS40NzUgNzYuNzQzNSAxNTkuOTU1IDc2LjM1MzIgMTYwLjUxNCA3Ni4wODExQzE2MS4wNzIgNzUuODAxOCAxNjEuNjkyIDc1LjY2MjEgMTYyLjM3MiA3NS42NjIxQzE2Mi45NDUgNzUuNjYyMSAxNjMuNDYxIDc1Ljc0MDkgMTYzLjkxOSA3NS44OTg0QzE2NC4zODQgNzYuMDU2IDE2NC43ODUgNzYuMzEwMiAxNjUuMTIyIDc2LjY2MTFDMTY1LjQ1OSA3Ny4wMDQ5IDE2NS43MTMgNzcuNDU2MSAxNjUuODg1IDc4LjAxNDZDMTY2LjA2NCA3OC41NjYxIDE2Ni4xNTMgNzkuMjM5MyAxNjYuMTUzIDgwLjAzNDJWODcuNUgxNjMuNTU0VjgwLjAxMjdDMTYzLjU1NCA3OS40NTQxIDE2My40NzEgNzkuMDEwMSAxNjMuMzA3IDc4LjY4MDdDMTYzLjE0OSA3OC4zNTEyIDE2Mi45MTYgNzguMTE0OSAxNjIuNjA4IDc3Ljk3MTdDMTYyLjMgNzcuODIxMyAxNjEuOTI0IDc3Ljc0NjEgMTYxLjQ4IDc3Ljc0NjFDMTYxLjAxNSA3Ny43NDYxIDE2MC42MDMgNzcuODM5MiAxNjAuMjQ1IDc4LjAyNTRDMTU5Ljg5NCA3OC4yMTE2IDE1OS42MDEgNzguNDY1OCAxNTkuMzY0IDc4Ljc4ODFDMTU5LjEyOCA3OS4xMTA0IDE1OC45NDkgNzkuNDgyNyAxNTguODI3IDc5LjkwNTNDMTU4LjcxMyA4MC4zMjc4IDE1OC42NTUgODAuNzc5IDE1OC42NTUgODEuMjU4OFoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPC9zdmc+Cg==", - "description": "Allows users to toggle a slider to send commands to devices or update attributes/time-series data. Configurable settings let users define how to retrieve the initial state and specify actions for the on/off toggle.", + "description": "Allows users to toggle a slider to send commands to devices or update attributes/time series data. Configurable settings let users define how to retrieve the initial state and specify actions for the on/off toggle.", "descriptor": { "type": "rpc", "sizeX": 3.5, diff --git a/application/src/main/data/json/system/widget_types/slider.json b/application/src/main/data/json/system/widget_types/slider.json index 8cbf5326c3..a5b01cb0f4 100644 --- a/application/src/main/data/json/system/widget_types/slider.json +++ b/application/src/main/data/json/system/widget_types/slider.json @@ -3,7 +3,7 @@ "name": "Slider", "deprecated": false, "image": "tb-image:SG9yaXpvbnRhbCBzbGlkZXIuc3Zn:IlNsaWRlciIgc3lzdGVtIHdpZGdldCBpbWFnZQ==;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik05MC41MDY0IDYwLjkxOFY2My4xNjhINzguMjAxN0w3OC4xMDggNjEuNDY4OEw4NS40NjczIDQ5LjkzNzVIODcuNzI5MUw4NS4yNzk4IDU0LjEzMjhMODEuMDQ5NCA2MC45MThIOTAuNTA2NFpNODguMzczNiA0OS45Mzc1VjY3SDg1LjU0OTRWNDkuOTM3NUg4OC4zNzM2Wk0xMDMuNjA5IDYyLjM0NzdDMTAzLjYwOSA2My40MTAyIDEwMy4zNjMgNjQuMzA0NyAxMDIuODcxIDY1LjAzMTJDMTAyLjM3OSA2NS43NTc4IDEwMS43MDcgNjYuMzA4NiAxMDAuODU1IDY2LjY4MzZDMTAwLjAxMiA2Ny4wNTA4IDk5LjA1ODYgNjcuMjM0NCA5Ny45OTYxIDY3LjIzNDRDOTYuOTMzNiA2Ny4yMzQ0IDk1Ljk3NjYgNjcuMDUwOCA5NS4xMjUgNjYuNjgzNkM5NC4yNzM0IDY2LjMwODYgOTMuNjAxNiA2NS43NTc4IDkzLjEwOTQgNjUuMDMxMkM5Mi42MTcyIDY0LjMwNDcgOTIuMzcxMSA2My40MTAyIDkyLjM3MTEgNjIuMzQ3N0M5Mi4zNzExIDYxLjY0NDUgOTIuNTA3OCA2MS4wMDc4IDkyLjc4MTMgNjAuNDM3NUM5My4wNTQ3IDU5Ljg1OTQgOTMuNDQxNCA1OS4zNjMzIDkzLjk0MTQgNTguOTQ5MkM5NC40NDkyIDU4LjUyNzMgOTUuMDQzIDU4LjIwMzEgOTUuNzIyNyA1Ny45NzY2Qzk2LjQxMDIgNTcuNzUgOTcuMTYwMiA1Ny42MzY3IDk3Ljk3MjcgNTcuNjM2N0M5OS4wNTA4IDU3LjYzNjcgMTAwLjAxNiA1Ny44MzU5IDEwMC44NjcgNTguMjM0NEMxMDEuNzE5IDU4LjYzMjggMTAyLjM4NyA1OS4xODM2IDEwMi44NzEgNTkuODg2N0MxMDMuMzYzIDYwLjU4OTggMTAzLjYwOSA2MS40MTAyIDEwMy42MDkgNjIuMzQ3N1pNMTAwLjc3MyA2Mi4yMDdDMTAwLjc3MyA2MS42MzY3IDEwMC42NTYgNjEuMTM2NyAxMDAuNDIyIDYwLjcwN0MxMDAuMTg4IDYwLjI3NzMgOTkuODU5NCA1OS45NDUzIDk5LjQzNzUgNTkuNzEwOUM5OS4wMTU2IDU5LjQ3NjYgOTguNTI3MyA1OS4zNTk0IDk3Ljk3MjcgNTkuMzU5NEM5Ny40MTAyIDU5LjM1OTQgOTYuOTIxOSA1OS40NzY2IDk2LjUwNzggNTkuNzEwOUM5Ni4wOTM4IDU5Ljk0NTMgOTUuNzY5NSA2MC4yNzczIDk1LjUzNTIgNjAuNzA3Qzk1LjMwODYgNjEuMTM2NyA5NS4xOTUzIDYxLjYzNjcgOTUuMTk1MyA2Mi4yMDdDOTUuMTk1MyA2Mi43ODUyIDk1LjMwODYgNjMuMjg1MiA5NS41MzUyIDYzLjcwN0M5NS43NjE3IDY0LjEyMTEgOTYuMDg1OSA2NC40Mzc1IDk2LjUwNzggNjQuNjU2MkM5Ni45Mjk3IDY0Ljg3NSA5Ny40MjU4IDY0Ljk4NDQgOTcuOTk2MSA2NC45ODQ0Qzk4LjU2NjQgNjQuOTg0NCA5OS4wNTg2IDY0Ljg3NSA5OS40NzI3IDY0LjY1NjJDOTkuODg2NyA2NC40Mzc1IDEwMC4yMDcgNjQuMTIxMSAxMDAuNDM0IDYzLjcwN0MxMDAuNjYgNjMuMjg1MiAxMDAuNzczIDYyLjc4NTIgMTAwLjc3MyA2Mi4yMDdaTTEwMy4yMjMgNTQuNDI1OEMxMDMuMjIzIDU1LjI3NzMgMTAyLjk5NiA1Ni4wMzUyIDEwMi41NDMgNTYuNjk5MkMxMDIuMDk4IDU3LjM2MzMgMTAxLjQ4IDU3Ljg4NjcgMTAwLjY5MSA1OC4yNjk1Qzk5LjkwMjMgNTguNjQ0NSA5OS4wMDM5IDU4LjgzMiA5Ny45OTYxIDU4LjgzMkM5Ni45ODA1IDU4LjgzMiA5Ni4wNzQyIDU4LjY0NDUgOTUuMjc3MyA1OC4yNjk1Qzk0LjQ4ODMgNTcuODg2NyA5My44NjcyIDU3LjM2MzMgOTMuNDE0MSA1Ni42OTkyQzkyLjk2ODggNTYuMDM1MiA5Mi43NDYxIDU1LjI3NzMgOTIuNzQ2MSA1NC40MjU4QzkyLjc0NjEgNTMuNDEwMiA5Mi45Njg4IDUyLjU1NDcgOTMuNDE0MSA1MS44NTk0QzkzLjg2NzIgNTEuMTU2MiA5NC40ODgzIDUwLjYyMTEgOTUuMjc3MyA1MC4yNTM5Qzk2LjA2NjQgNDkuODg2NyA5Ni45Njg4IDQ5LjcwMzEgOTcuOTg0NCA0OS43MDMxQzk5IDQ5LjcwMzEgOTkuOTAyMyA0OS44ODY3IDEwMC42OTEgNTAuMjUzOUMxMDEuNDggNTAuNjIxMSAxMDIuMDk4IDUxLjE1NjIgMTAyLjU0MyA1MS44NTk0QzEwMi45OTYgNTIuNTU0NyAxMDMuMjIzIDUzLjQxMDIgMTAzLjIyMyA1NC40MjU4Wk0xMDAuMzk4IDU0LjUxOTVDMTAwLjM5OCA1NC4wMTE3IDEwMC4yOTcgNTMuNTY2NCAxMDAuMDk0IDUzLjE4MzZDOTkuODk4NCA1Mi43OTMgOTkuNjIxMSA1Mi40ODgzIDk5LjI2MTcgNTIuMjY5NUM5OC45MDIzIDUyLjA1MDggOTguNDc2NiA1MS45NDE0IDk3Ljk4NDQgNTEuOTQxNEM5Ny40OTIyIDUxLjk0MTQgOTcuMDY2NCA1Mi4wNDY5IDk2LjcwNyA1Mi4yNTc4Qzk2LjM0NzcgNTIuNDY4OCA5Ni4wNzAzIDUyLjc2NTYgOTUuODc1IDUzLjE0ODRDOTUuNjc5NyA1My41MzEyIDk1LjU4MiA1My45ODgzIDk1LjU4MiA1NC41MTk1Qzk1LjU4MiA1NS4wNDMgOTUuNjc5NyA1NS41IDk1Ljg3NSA1NS44OTA2Qzk2LjA3MDMgNTYuMjczNCA5Ni4zNDc3IDU2LjU3NDIgOTYuNzA3IDU2Ljc5M0M5Ny4wNzQyIDU3LjAxMTcgOTcuNTAzOSA1Ny4xMjExIDk3Ljk5NjEgNTcuMTIxMUM5OC40ODgzIDU3LjEyMTEgOTguOTE0MSA1Ny4wMTE3IDk5LjI3MzQgNTYuNzkzQzk5LjYzMjggNTYuNTc0MiA5OS45MTAyIDU2LjI3MzQgMTAwLjEwNSA1NS44OTA2QzEwMC4zMDEgNTUuNSAxMDAuMzk4IDU1LjA0MyAxMDAuMzk4IDU0LjUxOTVaTTEwNi4wMzcgNTQuMTIxMVY1My4yMTg4QzEwNi4wMzcgNTIuNTcwMyAxMDYuMTc3IDUxLjk4MDUgMTA2LjQ1OCA1MS40NDkyQzEwNi43NCA1MC45MTggMTA3LjE1IDUwLjQ5MjIgMTA3LjY4OSA1MC4xNzE5QzEwOC4yMjggNDkuODUxNiAxMDguODc2IDQ5LjY5MTQgMTA5LjYzNCA0OS42OTE0QzExMC40MTUgNDkuNjkxNCAxMTEuMDcyIDQ5Ljg1MTYgMTExLjYwMyA1MC4xNzE5QzExMi4xNDIgNTAuNDkyMiAxMTIuNTUyIDUwLjkxOCAxMTIuODMzIDUxLjQ0OTJDMTEzLjExNSA1MS45ODA1IDExMy4yNTUgNTIuNTcwMyAxMTMuMjU1IDUzLjIxODhWNTQuMTIxMUMxMTMuMjU1IDU0Ljc1MzkgMTEzLjExNSA1NS4zMzU5IDExMi44MzMgNTUuODY3MkMxMTIuNTYgNTYuMzk4NCAxMTIuMTU0IDU2LjgyNDIgMTExLjYxNSA1Ny4xNDQ1QzExMS4wODMgNTcuNDY0OCAxMTAuNDMxIDU3LjYyNSAxMDkuNjU4IDU3LjYyNUMxMDguODkyIDU3LjYyNSAxMDguMjM2IDU3LjQ2NDggMTA3LjY4OSA1Ny4xNDQ1QzEwNy4xNSA1Ni44MjQyIDEwNi43NCA1Ni4zOTg0IDEwNi40NTggNTUuODY3MkMxMDYuMTc3IDU1LjMzNTkgMTA2LjAzNyA1NC43NTM5IDEwNi4wMzcgNTQuMTIxMVpNMTA3Ljk5NCA1My4yMTg4VjU0LjEyMTFDMTA3Ljk5NCA1NC40MzM2IDEwOC4wNTIgNTQuNzMwNSAxMDguMTY5IDU1LjAxMTdDMTA4LjI5NCA1NS4yOTMgMTA4LjQ4MiA1NS41MTk1IDEwOC43MzIgNTUuNjkxNEMxMDguOTgyIDU1Ljg2MzMgMTA5LjI5IDU1Ljk0OTIgMTA5LjY1OCA1NS45NDkyQzExMC4wMzMgNTUuOTQ5MiAxMTAuMzM3IDU1Ljg2MzMgMTEwLjU3MiA1NS42OTE0QzExMC44MTQgNTUuNTE5NSAxMTAuOTk0IDU1LjI5MyAxMTEuMTExIDU1LjAxMTdDMTExLjIyOCA1NC43MzA1IDExMS4yODcgNTQuNDMzNiAxMTEuMjg3IDU0LjEyMTFWNTMuMjE4OEMxMTEuMjg3IDUyLjg5ODQgMTExLjIyNCA1Mi41OTc3IDExMS4wOTkgNTIuMzE2NEMxMTAuOTgyIDUyLjAyNzMgMTEwLjgwMiA1MS43OTY5IDExMC41NiA1MS42MjVDMTEwLjMxOCA1MS40NTMxIDExMC4wMDkgNTEuMzY3MiAxMDkuNjM0IDUxLjM2NzJDMTA5LjI3NSA1MS4zNjcyIDEwOC45NyA1MS40NTMxIDEwOC43MiA1MS42MjVDMTA4LjQ3OCA1MS43OTY5IDEwOC4yOTQgNTIuMDI3MyAxMDguMTY5IDUyLjMxNjRDMTA4LjA1MiA1Mi41OTc3IDEwNy45OTQgNTIuODk4NCAxMDcuOTk0IDUzLjIxODhaTTExNC4yNjMgNjMuNzMwNVY2Mi44MTY0QzExNC4yNjMgNjIuMTc1OCAxMTQuNDA0IDYxLjU4OTggMTE0LjY4NSA2MS4wNTg2QzExNC45NzQgNjAuNTI3MyAxMTUuMzg4IDYwLjEwMTYgMTE1LjkyNyA1OS43ODEyQzExNi40NjYgNTkuNDYwOSAxMTcuMTE1IDU5LjMwMDggMTE3Ljg3MyA1OS4zMDA4QzExOC42NTQgNTkuMzAwOCAxMTkuMzEgNTkuNDYwOSAxMTkuODQxIDU5Ljc4MTJDMTIwLjM4IDYwLjEwMTYgMTIwLjc4NyA2MC41MjczIDEyMS4wNiA2MS4wNTg2QzEyMS4zNDEgNjEuNTg5OCAxMjEuNDgyIDYyLjE3NTggMTIxLjQ4MiA2Mi44MTY0VjYzLjczMDVDMTIxLjQ4MiA2NC4zNzExIDEyMS4zNDEgNjQuOTU3IDEyMS4wNiA2NS40ODgzQzEyMC43ODcgNjYuMDE5NSAxMjAuMzg0IDY2LjQ0NTMgMTE5Ljg1MyA2Ni43NjU2QzExOS4zMjIgNjcuMDg1OSAxMTguNjczIDY3LjI0NjEgMTE3LjkwOCA2Ny4yNDYxQzExNy4xMzQgNjcuMjQ2MSAxMTYuNDc0IDY3LjA4NTkgMTE1LjkyNyA2Ni43NjU2QzExNS4zODggNjYuNDQ1MyAxMTQuOTc0IDY2LjAxOTUgMTE0LjY4NSA2NS40ODgzQzExNC40MDQgNjQuOTU3IDExNC4yNjMgNjQuMzcxMSAxMTQuMjYzIDYzLjczMDVaTTExNi4yMzIgNjIuODE2NFY2My43MzA1QzExNi4yMzIgNjQuMDQzIDExNi4yOTQgNjQuMzM5OCAxMTYuNDE5IDY0LjYyMTFDMTE2LjU1MiA2NC45MDIzIDExNi43NDQgNjUuMTMyOCAxMTYuOTk0IDY1LjMxMjVDMTE3LjI0NCA2NS40ODQ0IDExNy41NDQgNjUuNTcwMyAxMTcuODk2IDY1LjU3MDNDMTE4LjI5NCA2NS41NzAzIDExOC42MTUgNjUuNDg0NCAxMTguODU3IDY1LjMxMjVDMTE5LjA5OSA2NS4xMzI4IDExOS4yNzEgNjQuOTA2MiAxMTkuMzczIDY0LjYzMjhDMTE5LjQ4MiA2NC4zNTE2IDExOS41MzcgNjQuMDUwOCAxMTkuNTM3IDYzLjczMDVWNjIuODE2NEMxMTkuNTM3IDYyLjQ5NjEgMTE5LjQ3NCA2Mi4xOTUzIDExOS4zNDkgNjEuOTE0MUMxMTkuMjMyIDYxLjYzMjggMTE5LjA0OCA2MS40MDYyIDExOC43OTggNjEuMjM0NEMxMTguNTU2IDYxLjA2MjUgMTE4LjI0OCA2MC45NzY2IDExNy44NzMgNjAuOTc2NkMxMTcuNTA1IDYwLjk3NjYgMTE3LjIwMSA2MS4wNjI1IDExNi45NTggNjEuMjM0NEMxMTYuNzE2IDYxLjQwNjIgMTE2LjUzMyA2MS42MzI4IDExNi40MDggNjEuOTE0MUMxMTYuMjkgNjIuMTk1MyAxMTYuMjMyIDYyLjQ5NjEgMTE2LjIzMiA2Mi44MTY0Wk0xMTguNTc2IDUyLjM3NUwxMTAuMjQ0IDY1LjcxMDlMMTA4LjgwMiA2NC44Nzg5TDExNy4xMzQgNTEuNTQzTDExOC41NzYgNTIuMzc1WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cmVjdCB5PSI3OSIgd2lkdGg9IjIwMCIgaGVpZ2h0PSI4IiByeD0iMi43MDE3NCIgZmlsbD0iIzU0NjlGRiIgZmlsbC1vcGFjaXR5PSIwLjMiLz4KPGNpcmNsZSBjeD0iMi45MTk0NiIgY3k9IjgzIiByPSIwLjU2ODg3OCIgZmlsbD0iIzU0NjlGRiIgZmlsbC1vcGFjaXR5PSIwLjgyIi8+CjxjaXJjbGUgY3g9IjE4LjI1MjUiIGN5PSI4MyIgcj0iMC41Njg4NzgiIGZpbGw9IiM1NDY5RkYiIGZpbGwtb3BhY2l0eT0iMC44MiIvPgo8Y2lyY2xlIGN4PSIzMy41ODQ1IiBjeT0iODMiIHI9IjAuNTY4ODc4IiBmaWxsPSIjNTQ2OUZGIiBmaWxsLW9wYWNpdHk9IjAuODIiLz4KPGNpcmNsZSBjeD0iNDguOTE2NSIgY3k9IjgzIiByPSIwLjU2ODg3OCIgZmlsbD0iIzU0NjlGRiIgZmlsbC1vcGFjaXR5PSIwLjgyIi8+CjxjaXJjbGUgY3g9IjY0LjI0ODYiIGN5PSI4MyIgcj0iMC41Njg4NzgiIGZpbGw9IiM1NDY5RkYiIGZpbGwtb3BhY2l0eT0iMC44MiIvPgo8Y2lyY2xlIGN4PSI3OS41ODA2IiBjeT0iODMiIHI9IjAuNTY4ODc4IiBmaWxsPSIjNTQ2OUZGIiBmaWxsLW9wYWNpdHk9IjAuODIiLz4KPGNpcmNsZSBjeD0iOTQuOTEzNiIgY3k9IjgzIiByPSIwLjU2ODg3OCIgZmlsbD0iIzU0NjlGRiIgZmlsbC1vcGFjaXR5PSIwLjgyIi8+CjxjaXJjbGUgY3g9IjExMC42NzciIGN5PSI4MyIgcj0iMSIgZmlsbD0iIzU0NjlGRiIgZmlsbC1vcGFjaXR5PSIwLjgyIi8+CjxjaXJjbGUgY3g9IjEyNi44NzEiIGN5PSI4MyIgcj0iMSIgZmlsbD0iIzU0NjlGRiIgZmlsbC1vcGFjaXR5PSIwLjgyIi8+CjxjaXJjbGUgY3g9IjE0My4wNjUiIGN5PSI4MyIgcj0iMSIgZmlsbD0iIzU0NjlGRiIgZmlsbC1vcGFjaXR5PSIwLjgyIi8+CjxjaXJjbGUgY3g9IjE1OS4yNiIgY3k9IjgzIiByPSIxIiBmaWxsPSIjNTQ2OUZGIiBmaWxsLW9wYWNpdHk9IjAuODIiLz4KPGNpcmNsZSBjeD0iMTc1LjQ1NSIgY3k9IjgzIiByPSIxIiBmaWxsPSIjNTQ2OUZGIiBmaWxsLW9wYWNpdHk9IjAuODIiLz4KPGNpcmNsZSBjeD0iMTkxLjY0OSIgY3k9IjgzIiByPSIxIiBmaWxsPSIjNTQ2OUZGIiBmaWxsLW9wYWNpdHk9IjAuODIiLz4KPHJlY3QgeT0iNzkiIHdpZHRoPSIxMDEiIGhlaWdodD0iOCIgcng9IjIuNzAxNzQiIGZpbGw9IiM1NDY5RkYiLz4KPHBhdGggZD0iTTUuNTU5MDggOTUuNDY2OFY5Ni42NTkyQzUuNTU5MDggOTcuMzAwMSA1LjUwMTc5IDk3Ljg0MDggNS4zODcyMSA5OC4yODEyQzUuMjcyNjIgOTguNzIxNyA1LjEwNzkxIDk5LjA3NjIgNC44OTMwNyA5OS4zNDQ3QzQuNjc4MjIgOTkuNjEzMyA0LjQxODYyIDk5LjgwODQgNC4xMTQyNiA5OS45MzAyQzMuODEzNDggMTAwLjA0OCAzLjQ3MzMxIDEwMC4xMDcgMy4wOTM3NSAxMDAuMTA3QzIuNzkyOTcgMTAwLjEwNyAyLjUxNTQ2IDEwMC4wNyAyLjI2MTIzIDk5Ljk5NDZDMi4wMDcgOTkuOTE5NCAxLjc3NzgzIDk5Ljc5OTUgMS41NzM3MyA5OS42MzQ4QzEuMzczMjEgOTkuNDY2NSAxLjIwMTMzIDk5LjI0OCAxLjA1ODExIDk4Ljk3OTVDMC45MTQ4NzYgOTguNzEwOSAwLjgwNTY2NCA5OC4zODUxIDAuNzMwNDY5IDk4LjAwMkMwLjY1NTI3MyA5Ny42MTg4IDAuNjE3Njc2IDk3LjE3MTIgMC42MTc2NzYgOTYuNjU5MlY5NS40NjY4QzAuNjE3Njc2IDk0LjgyNTggMC42NzQ5NjcgOTQuMjg4NyAwLjc4OTU1MSA5My44NTU1QzAuOTA3NzE1IDkzLjQyMjIgMS4wNzQyMiA5My4wNzQ5IDEuMjg5MDYgOTIuODEzNUMxLjUwMzkxIDkyLjU0ODUgMS43NjE3MiA5Mi4zNTg3IDIuMDYyNSA5Mi4yNDQxQzIuMzY2ODYgOTIuMTI5NiAyLjcwNzAzIDkyLjA3MjMgMy4wODMwMSA5Mi4wNzIzQzMuMzg3MzcgOTIuMDcyMyAzLjY2NjY3IDkyLjEwOTkgMy45MjA5IDkyLjE4NTFDNC4xNzg3MSA5Mi4yNTY3IDQuNDA3ODggOTIuMzczIDQuNjA4NCA5Mi41MzQyQzQuODA4OTIgOTIuNjkxNyA0Ljk3OSA5Mi45MDMgNS4xMTg2NSA5My4xNjhDNS4yNjE4OCA5My40Mjk0IDUuMzcxMDkgOTMuNzQ5OCA1LjQ0NjI5IDk0LjEyOTRDNS41MjE0OCA5NC41MDkgNS41NTkwOCA5NC45NTQ4IDUuNTU5MDggOTUuNDY2OFpNNC41NjAwNiA5Ni44MjAzVjk1LjMwMDNDNC41NjAwNiA5NC45NDk0IDQuNTM4NTcgOTQuNjQxNCA0LjQ5NTYxIDk0LjM3NjVDNC40NTYyMiA5NC4xMDc5IDQuMzk3MTQgOTMuODc4NyA0LjMxODM2IDkzLjY4OUM0LjIzOTU4IDkzLjQ5OTIgNC4xMzkzMiA5My4zNDUyIDQuMDE3NTggOTMuMjI3MUMzLjg5OTQxIDkzLjEwODkgMy43NjE1NiA5My4wMjI5IDMuNjA0IDkyLjk2OTJDMy40NTAwMyA5Mi45MTE5IDMuMjc2MzcgOTIuODgzMyAzLjA4MzAxIDkyLjg4MzNDMi44NDY2OCA5Mi44ODMzIDIuNjM3MjEgOTIuOTI4MSAyLjQ1NDU5IDkzLjAxNzZDMi4yNzE5NyA5My4xMDM1IDIuMTE4IDkzLjI0MTQgMS45OTI2OCA5My40MzEyQzEuODcwOTMgOTMuNjIwOSAxLjc3NzgzIDkzLjg2OTggMS43MTMzOCA5NC4xNzc3QzEuNjQ4OTMgOTQuNDg1NyAxLjYxNjcgOTQuODU5OSAxLjYxNjcgOTUuMzAwM1Y5Ni44MjAzQzEuNjE2NyA5Ny4xNzEyIDEuNjM2MzkgOTcuNDgxIDEuNjc1NzggOTcuNzQ5NUMxLjcxODc1IDk4LjAxODEgMS43ODE0MSA5OC4yNTA4IDEuODYzNzcgOTguNDQ3OEMxLjk0NjEzIDk4LjY0MTEgMi4wNDYzOSA5OC44MDA1IDIuMTY0NTUgOTguOTI1OEMyLjI4MjcxIDk5LjA1MTEgMi40MTg3OCA5OS4xNDQyIDIuNTcyNzUgOTkuMjA1MUMyLjczMDMxIDk5LjI2MjQgMi45MDM5NyA5OS4yOTEgMy4wOTM3NSA5OS4yOTFDMy4zMzcyNCA5OS4yOTEgMy41NTAyOSA5OS4yNDQ1IDMuNzMyOTEgOTkuMTUxNEMzLjkxNTUzIDk5LjA1ODMgNC4wNjc3MSA5OC45MTMyIDQuMTg5NDUgOTguNzE2M0M0LjMxNDc4IDk4LjUxNTggNC40MDc4OCA5OC4yNTk4IDQuNDY4NzUgOTcuOTQ4MkM0LjUyOTYyIDk3LjYzMzEgNC41NjAwNiA5Ny4yNTcyIDQuNTYwMDYgOTYuODIwM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPHBhdGggZD0iTTE4NC44NTMgOTIuMTM2N1YxMDBIMTgzLjg1OVY5My4zNzc0TDE4MS44NTYgOTQuMTA3OVY5My4yMTA5TDE4NC42OTcgOTIuMTM2N0gxODQuODUzWk0xOTIuOTM0IDk1LjQ2NjhWOTYuNjU5MkMxOTIuOTM0IDk3LjMwMDEgMTkyLjg3NyA5Ny44NDA4IDE5Mi43NjIgOTguMjgxMkMxOTIuNjQ4IDk4LjcyMTcgMTkyLjQ4MyA5OS4wNzYyIDE5Mi4yNjggOTkuMzQ0N0MxOTIuMDUzIDk5LjYxMzMgMTkxLjc5NCA5OS44MDg0IDE5MS40ODkgOTkuOTMwMkMxOTEuMTg4IDEwMC4wNDggMTkwLjg0OCAxMDAuMTA3IDE5MC40NjkgMTAwLjEwN0MxOTAuMTY4IDEwMC4xMDcgMTg5Ljg5IDEwMC4wNyAxODkuNjM2IDk5Ljk5NDZDMTg5LjM4MiA5OS45MTk0IDE4OS4xNTMgOTkuNzk5NSAxODguOTQ5IDk5LjYzNDhDMTg4Ljc0OCA5OS40NjY1IDE4OC41NzYgOTkuMjQ4IDE4OC40MzMgOTguOTc5NUMxODguMjkgOTguNzEwOSAxODguMTgxIDk4LjM4NTEgMTg4LjEwNSA5OC4wMDJDMTg4LjAzIDk3LjYxODggMTg3Ljk5MyA5Ny4xNzEyIDE4Ny45OTMgOTYuNjU5MlY5NS40NjY4QzE4Ny45OTMgOTQuODI1OCAxODguMDUgOTQuMjg4NyAxODguMTY1IDkzLjg1NTVDMTg4LjI4MyA5My40MjIyIDE4OC40NDkgOTMuMDc0OSAxODguNjY0IDkyLjgxMzVDMTg4Ljg3OSA5Mi41NDg1IDE4OS4xMzcgOTIuMzU4NyAxODkuNDM4IDkyLjI0NDFDMTg5Ljc0MiA5Mi4xMjk2IDE5MC4wODIgOTIuMDcyMyAxOTAuNDU4IDkyLjA3MjNDMTkwLjc2MiA5Mi4wNzIzIDE5MS4wNDIgOTIuMTA5OSAxOTEuMjk2IDkyLjE4NTFDMTkxLjU1NCA5Mi4yNTY3IDE5MS43ODMgOTIuMzczIDE5MS45ODMgOTIuNTM0MkMxOTIuMTg0IDkyLjY5MTcgMTkyLjM1NCA5Mi45MDMgMTkyLjQ5NCA5My4xNjhDMTkyLjYzNyA5My40Mjk0IDE5Mi43NDYgOTMuNzQ5OCAxOTIuODIxIDk0LjEyOTRDMTkyLjg5NiA5NC41MDkgMTkyLjkzNCA5NC45NTQ4IDE5Mi45MzQgOTUuNDY2OFpNMTkxLjkzNSA5Ni44MjAzVjk1LjMwMDNDMTkxLjkzNSA5NC45NDk0IDE5MS45MTQgOTQuNjQxNCAxOTEuODcxIDk0LjM3NjVDMTkxLjgzMSA5NC4xMDc5IDE5MS43NzIgOTMuODc4NyAxOTEuNjkzIDkzLjY4OUMxOTEuNjE1IDkzLjQ5OTIgMTkxLjUxNCA5My4zNDUyIDE5MS4zOTMgOTMuMjI3MUMxOTEuMjc0IDkzLjEwODkgMTkxLjEzNyA5My4wMjI5IDE5MC45NzkgOTIuOTY5MkMxOTAuODI1IDkyLjkxMTkgMTkwLjY1MSA5Mi44ODMzIDE5MC40NTggOTIuODgzM0MxOTAuMjIyIDkyLjg4MzMgMTkwLjAxMiA5Mi45MjgxIDE4OS44MyA5My4wMTc2QzE4OS42NDcgOTMuMTAzNSAxODkuNDkzIDkzLjI0MTQgMTg5LjM2OCA5My40MzEyQzE4OS4yNDYgOTMuNjIwOSAxODkuMTUzIDkzLjg2OTggMTg5LjA4OCA5NC4xNzc3QzE4OS4wMjQgOTQuNDg1NyAxODguOTkyIDk0Ljg1OTkgMTg4Ljk5MiA5NS4zMDAzVjk2LjgyMDNDMTg4Ljk5MiA5Ny4xNzEyIDE4OS4wMTEgOTcuNDgxIDE4OS4wNTEgOTcuNzQ5NUMxODkuMDk0IDk4LjAxODEgMTg5LjE1NiA5OC4yNTA4IDE4OS4yMzkgOTguNDQ3OEMxODkuMzIxIDk4LjY0MTEgMTg5LjQyMSA5OC44MDA1IDE4OS41NCA5OC45MjU4QzE4OS42NTggOTkuMDUxMSAxODkuNzk0IDk5LjE0NDIgMTg5Ljk0OCA5OS4yMDUxQzE5MC4xMDUgOTkuMjYyNCAxOTAuMjc5IDk5LjI5MSAxOTAuNDY5IDk5LjI5MUMxOTAuNzEyIDk5LjI5MSAxOTAuOTI1IDk5LjI0NDUgMTkxLjEwOCA5OS4xNTE0QzE5MS4yOTEgOTkuMDU4MyAxOTEuNDQzIDk4LjkxMzIgMTkxLjU2NCA5OC43MTYzQzE5MS42OSA5OC41MTU4IDE5MS43ODMgOTguMjU5OCAxOTEuODQ0IDk3Ljk0ODJDMTkxLjkwNSA5Ny42MzMxIDE5MS45MzUgOTcuMjU3MiAxOTEuOTM1IDk2LjgyMDNaTTE5OS4zNzIgOTUuNDY2OFY5Ni42NTkyQzE5OS4zNzIgOTcuMzAwMSAxOTkuMzE0IDk3Ljg0MDggMTk5LjIgOTguMjgxMkMxOTkuMDg1IDk4LjcyMTcgMTk4LjkyIDk5LjA3NjIgMTk4LjcwNiA5OS4zNDQ3QzE5OC40OTEgOTkuNjEzMyAxOTguMjMxIDk5LjgwODQgMTk3LjkyNyA5OS45MzAyQzE5Ny42MjYgMTAwLjA0OCAxOTcuMjg2IDEwMC4xMDcgMTk2LjkwNiAxMDAuMTA3QzE5Ni42MDUgMTAwLjEwNyAxOTYuMzI4IDEwMC4wNyAxOTYuMDc0IDk5Ljk5NDZDMTk1LjgxOSA5OS45MTk0IDE5NS41OSA5OS43OTk1IDE5NS4zODYgOTkuNjM0OEMxOTUuMTg2IDk5LjQ2NjUgMTk1LjAxNCA5OS4yNDggMTk0Ljg3MSA5OC45Nzk1QzE5NC43MjcgOTguNzEwOSAxOTQuNjE4IDk4LjM4NTEgMTk0LjU0MyA5OC4wMDJDMTk0LjQ2OCA5Ny42MTg4IDE5NC40MyA5Ny4xNzEyIDE5NC40MyA5Ni42NTkyVjk1LjQ2NjhDMTk0LjQzIDk0LjgyNTggMTk0LjQ4NyA5NC4yODg3IDE5NC42MDIgOTMuODU1NUMxOTQuNzIgOTMuNDIyMiAxOTQuODg3IDkzLjA3NDkgMTk1LjEwMiA5Mi44MTM1QzE5NS4zMTYgOTIuNTQ4NSAxOTUuNTc0IDkyLjM1ODcgMTk1Ljg3NSA5Mi4yNDQxQzE5Ni4xNzkgOTIuMTI5NiAxOTYuNTIgOTIuMDcyMyAxOTYuODk2IDkyLjA3MjNDMTk3LjIgOTIuMDcyMyAxOTcuNDc5IDkyLjEwOTkgMTk3LjczMyA5Mi4xODUxQzE5Ny45OTEgOTIuMjU2NyAxOTguMjIgOTIuMzczIDE5OC40MjEgOTIuNTM0MkMxOTguNjIxIDkyLjY5MTcgMTk4Ljc5MiA5Mi45MDMgMTk4LjkzMSA5My4xNjhDMTk5LjA3NCA5My40Mjk0IDE5OS4xODQgOTMuNzQ5OCAxOTkuMjU5IDk0LjEyOTRDMTk5LjMzNCA5NC41MDkgMTk5LjM3MiA5NC45NTQ4IDE5OS4zNzIgOTUuNDY2OFpNMTk4LjM3MyA5Ni44MjAzVjk1LjMwMDNDMTk4LjM3MyA5NC45NDk0IDE5OC4zNTEgOTQuNjQxNCAxOTguMzA4IDk0LjM3NjVDMTk4LjI2OSA5NC4xMDc5IDE5OC4yMSA5My44Nzg3IDE5OC4xMzEgOTMuNjg5QzE5OC4wNTIgOTMuNDk5MiAxOTcuOTUyIDkzLjM0NTIgMTk3LjgzIDkzLjIyNzFDMTk3LjcxMiA5My4xMDg5IDE5Ny41NzQgOTMuMDIyOSAxOTcuNDE3IDkyLjk2OTJDMTk3LjI2MyA5Mi45MTE5IDE5Ny4wODkgOTIuODgzMyAxOTYuODk2IDkyLjg4MzNDMTk2LjY1OSA5Mi44ODMzIDE5Ni40NSA5Mi45MjgxIDE5Ni4yNjcgOTMuMDE3NkMxOTYuMDg0IDkzLjEwMzUgMTk1LjkzMSA5My4yNDE0IDE5NS44MDUgOTMuNDMxMkMxOTUuNjgzIDkzLjYyMDkgMTk1LjU5IDkzLjg2OTggMTk1LjUyNiA5NC4xNzc3QzE5NS40NjEgOTQuNDg1NyAxOTUuNDI5IDk0Ljg1OTkgMTk1LjQyOSA5NS4zMDAzVjk2LjgyMDNDMTk1LjQyOSA5Ny4xNzEyIDE5NS40NDkgOTcuNDgxIDE5NS40ODggOTcuNzQ5NUMxOTUuNTMxIDk4LjAxODEgMTk1LjU5NCA5OC4yNTA4IDE5NS42NzYgOTguNDQ3OEMxOTUuNzU5IDk4LjY0MTEgMTk1Ljg1OSA5OC44MDA1IDE5NS45NzcgOTguOTI1OEMxOTYuMDk1IDk5LjA1MTEgMTk2LjIzMSA5OS4xNDQyIDE5Ni4zODUgOTkuMjA1MUMxOTYuNTQzIDk5LjI2MjQgMTk2LjcxNiA5OS4yOTEgMTk2LjkwNiA5OS4yOTFDMTk3LjE1IDk5LjI5MSAxOTcuMzYzIDk5LjI0NDUgMTk3LjU0NSA5OS4xNTE0QzE5Ny43MjggOTkuMDU4MyAxOTcuODggOTguOTEzMiAxOTguMDAyIDk4LjcxNjNDMTk4LjEyNyA5OC41MTU4IDE5OC4yMiA5OC4yNTk4IDE5OC4yODEgOTcuOTQ4MkMxOTguMzQyIDk3LjYzMzEgMTk4LjM3MyA5Ny4yNTcyIDE5OC4zNzMgOTYuODIwM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPGNpcmNsZSBjeD0iMTAwIiBjeT0iODMiIHI9IjgiIGZpbGw9IiM1NDY5RkYiLz4KPC9zdmc+Cg==", - "description": "Allows users to move thumb of a slider to send commands to devices or update attributes/time-series data. Configurable settings let users define how to retrieve the initial state and specify actions for the value change.", + "description": "Allows users to move thumb of a slider to send commands to devices or update attributes/time series data. Configurable settings let users define how to retrieve the initial state and specify actions for the value change.", "descriptor": { "type": "rpc", "sizeX": 5, diff --git a/application/src/main/data/json/system/widget_types/time_series_chart.json b/application/src/main/data/json/system/widget_types/time_series_chart.json index 9b2069ca6b..82de36c972 100644 --- a/application/src/main/data/json/system/widget_types/time_series_chart.json +++ b/application/src/main/data/json/system/widget_types/time_series_chart.json @@ -3,7 +3,7 @@ "name": "Time series chart", "deprecated": false, "image": "tb-image:Y2hhcnQuc3Zn:IlRpbWUgc2VyaWVzIGNoYXJ0IiBzeXN0ZW0gd2lkZ2V0IGltYWdl;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF80MDUzXzE4NTExMykiPgo8cGF0aCBkPSJNMi44NDc2NiAxLjI4MTI1VjdIMi4xMjVWMi4xODM1OUwwLjY2Nzk2OSAyLjcxNDg0VjIuMDYyNUwyLjczNDM4IDEuMjgxMjVIMi44NDc2NlpNOC42NzUxNyAzLjcwMzEyVjQuNTcwMzFDOC42NzUxNyA1LjAzNjQ2IDguNjMzNTEgNS40Mjk2OSA4LjU1MDE3IDUuNzVDOC40NjY4NCA2LjA3MDMxIDguMzQ3MDUgNi4zMjgxMiA4LjE5MDggNi41MjM0NEM4LjAzNDU1IDYuNzE4NzUgNy44NDU3NSA2Ljg2MDY4IDcuNjI0MzkgNi45NDkyMkM3LjQwNTY0IDcuMDM1MTYgNy4xNTgyNSA3LjA3ODEyIDYuODgyMiA3LjA3ODEyQzYuNjYzNDUgNy4wNzgxMiA2LjQ2MTYzIDcuMDUwNzggNi4yNzY3MyA2Ljk5NjA5QzYuMDkxODQgNi45NDE0MSA1LjkyNTE3IDYuODU0MTcgNS43NzY3MyA2LjczNDM4QzUuNjMwOSA2LjYxMTk4IDUuNTA1OSA2LjQ1MzEyIDUuNDAxNzMgNi4yNTc4MUM1LjI5NzU3IDYuMDYyNSA1LjIxODE0IDUuODI1NTIgNS4xNjM0NSA1LjU0Njg4QzUuMTA4NzcgNS4yNjgyMyA1LjA4MTQyIDQuOTQyNzEgNS4wODE0MiA0LjU3MDMxVjMuNzAzMTJDNS4wODE0MiAzLjIzNjk4IDUuMTIzMDkgMi44NDYzNSA1LjIwNjQyIDIuNTMxMjVDNS4yOTIzNiAyLjIxNjE1IDUuNDEzNDUgMS45NjM1NCA1LjU2OTcgMS43NzM0NEM1LjcyNTk1IDEuNTgwNzMgNS45MTM0NSAxLjQ0MjcxIDYuMTMyMiAxLjM1OTM4QzYuMzUzNTYgMS4yNzYwNCA2LjYwMDk1IDEuMjM0MzggNi44NzQzOSAxLjIzNDM4QzcuMDk1NzUgMS4yMzQzOCA3LjI5ODg3IDEuMjYxNzIgNy40ODM3NyAxLjMxNjQxQzcuNjcxMjcgMS4zNjg0OSA3LjgzNzkzIDEuNDUzMTIgNy45ODM3NyAxLjU3MDMxQzguMTI5NiAxLjY4NDkgOC4yNTMzIDEuODM4NTQgOC4zNTQ4NiAyLjAzMTI1QzguNDU5MDMgMi4yMjEzNSA4LjUzODQ1IDIuNDU0NDMgOC41OTMxNCAyLjczMDQ3QzguNjQ3ODMgMy4wMDY1MSA4LjY3NTE3IDMuMzMwNzMgOC42NzUxNyAzLjcwMzEyWk03Ljk0ODYxIDQuNjg3NVYzLjU4MjAzQzcuOTQ4NjEgMy4zMjY4MiA3LjkzMjk4IDMuMTAyODYgNy45MDE3MyAyLjkxMDE2QzcuODczMDkgMi43MTQ4NCA3LjgzMDEyIDIuNTQ4MTggNy43NzI4MyAyLjQxMDE2QzcuNzE1NTQgMi4yNzIxNCA3LjY0MjYyIDIuMTYwMTYgNy41NTQwOCAyLjA3NDIyQzcuNDY4MTQgMS45ODgyOCA3LjM2Nzg4IDEuOTI1NzggNy4yNTMzIDEuODg2NzJDNy4xNDEzMiAxLjg0NTA1IDcuMDE1MDIgMS44MjQyMiA2Ljg3NDM5IDEuODI0MjJDNi43MDI1MiAxLjgyNDIyIDYuNTUwMTcgMS44NTY3NyA2LjQxNzM2IDEuOTIxODhDNi4yODQ1NSAxLjk4NDM4IDYuMTcyNTcgMi4wODQ2NCA2LjA4MTQyIDIuMjIyNjZDNS45OTI4OCAyLjM2MDY4IDUuOTI1MTcgMi41NDE2NyA1Ljg3ODMgMi43NjU2MkM1LjgzMTQyIDIuOTg5NTggNS44MDc5OCAzLjI2MTcyIDUuODA3OTggMy41ODIwM1Y0LjY4NzVDNS44MDc5OCA0Ljk0MjcxIDUuODIyMzEgNS4xNjc5NyA1Ljg1MDk1IDUuMzYzMjhDNS44ODIyIDUuNTU4NTkgNS45Mjc3OCA1LjcyNzg2IDUuOTg3NjcgNS44NzEwOUM2LjA0NzU3IDYuMDExNzIgNi4xMjA0OCA2LjEyNzYgNi4yMDY0MiA2LjIxODc1QzYuMjkyMzYgNi4zMDk5IDYuMzkxMzIgNi4zNzc2IDYuNTAzMyA2LjQyMTg4QzYuNjE3ODggNi40NjM1NCA2Ljc0NDE4IDYuNDg0MzggNi44ODIyIDYuNDg0MzhDNy4wNTkyOSA2LjQ4NDM4IDcuMjE0MjMgNi40NTA1MiA3LjM0NzA1IDYuMzgyODFDNy40Nzk4NiA2LjMxNTEgNy41OTA1NCA2LjIwOTY0IDcuNjc5MDggNi4wNjY0MUM3Ljc3MDIyIDUuOTIwNTcgNy44Mzc5MyA1LjczNDM4IDcuODgyMiA1LjUwNzgxQzcuOTI2NDcgNS4yNzg2NSA3Ljk0ODYxIDUuMDA1MjEgNy45NDg2MSA0LjY4NzVaTTEzLjMwNzQgMy43MDMxMlY0LjU3MDMxQzEzLjMwNzQgNS4wMzY0NiAxMy4yNjU3IDUuNDI5NjkgMTMuMTgyNCA1Ljc1QzEzLjA5OSA2LjA3MDMxIDEyLjk3OTMgNi4zMjgxMiAxMi44MjMgNi41MjM0NEMxMi42NjY4IDYuNzE4NzUgMTIuNDc3OSA2Ljg2MDY4IDEyLjI1NjYgNi45NDkyMkMxMi4wMzc4IDcuMDM1MTYgMTEuNzkwNCA3LjA3ODEyIDExLjUxNDQgNy4wNzgxMkMxMS4yOTU3IDcuMDc4MTIgMTEuMDkzOCA3LjA1MDc4IDEwLjkwODkgNi45OTYwOUMxMC43MjQgNi45NDE0MSAxMC41NTc0IDYuODU0MTcgMTAuNDA4OSA2LjczNDM4QzEwLjI2MzEgNi42MTE5OCAxMC4xMzgxIDYuNDUzMTIgMTAuMDMzOSA2LjI1NzgxQzkuOTI5NzcgNi4wNjI1IDkuODUwMzQgNS44MjU1MiA5Ljc5NTY2IDUuNTQ2ODhDOS43NDA5NyA1LjI2ODIzIDkuNzEzNjMgNC45NDI3MSA5LjcxMzYzIDQuNTcwMzFWMy43MDMxMkM5LjcxMzYzIDMuMjM2OTggOS43NTUyOSAyLjg0NjM1IDkuODM4NjMgMi41MzEyNUM5LjkyNDU2IDIuMjE2MTUgMTAuMDQ1NyAxLjk2MzU0IDEwLjIwMTkgMS43NzM0NEMxMC4zNTgyIDEuNTgwNzMgMTAuNTQ1NyAxLjQ0MjcxIDEwLjc2NDQgMS4zNTkzOEMxMC45ODU4IDEuMjc2MDQgMTEuMjMzMiAxLjIzNDM4IDExLjUwNjYgMS4yMzQzOEMxMS43Mjc5IDEuMjM0MzggMTEuOTMxMSAxLjI2MTcyIDEyLjExNiAxLjMxNjQxQzEyLjMwMzUgMS4zNjg0OSAxMi40NzAxIDEuNDUzMTIgMTIuNjE2IDEuNTcwMzFDMTIuNzYxOCAxLjY4NDkgMTIuODg1NSAxLjgzODU0IDEyLjk4NzEgMi4wMzEyNUMxMy4wOTEyIDIuMjIxMzUgMTMuMTcwNyAyLjQ1NDQzIDEzLjIyNTMgMi43MzA0N0MxMy4yOCAzLjAwNjUxIDEzLjMwNzQgMy4zMzA3MyAxMy4zMDc0IDMuNzAzMTJaTTEyLjU4MDggNC42ODc1VjMuNTgyMDNDMTIuNTgwOCAzLjMyNjgyIDEyLjU2NTIgMy4xMDI4NiAxMi41MzM5IDIuOTEwMTZDMTIuNTA1MyAyLjcxNDg0IDEyLjQ2MjMgMi41NDgxOCAxMi40MDUgMi40MTAxNkMxMi4zNDc3IDIuMjcyMTQgMTIuMjc0OCAyLjE2MDE2IDEyLjE4NjMgMi4wNzQyMkMxMi4xMDAzIDEuOTg4MjggMTIuMDAwMSAxLjkyNTc4IDExLjg4NTUgMS44ODY3MkMxMS43NzM1IDEuODQ1MDUgMTEuNjQ3MiAxLjgyNDIyIDExLjUwNjYgMS44MjQyMkMxMS4zMzQ3IDEuODI0MjIgMTEuMTgyNCAxLjg1Njc3IDExLjA0OTYgMS45MjE4OEMxMC45MTY4IDEuOTg0MzggMTAuODA0OCAyLjA4NDY0IDEwLjcxMzYgMi4yMjI2NkMxMC42MjUxIDIuMzYwNjggMTAuNTU3NCAyLjU0MTY3IDEwLjUxMDUgMi43NjU2MkMxMC40NjM2IDIuOTg5NTggMTAuNDQwMiAzLjI2MTcyIDEwLjQ0MDIgMy41ODIwM1Y0LjY4NzVDMTAuNDQwMiA0Ljk0MjcxIDEwLjQ1NDUgNS4xNjc5NyAxMC40ODMyIDUuMzYzMjhDMTAuNTE0NCA1LjU1ODU5IDEwLjU2IDUuNzI3ODYgMTAuNjE5OSA1Ljg3MTA5QzEwLjY3OTggNi4wMTE3MiAxMC43NTI3IDYuMTI3NiAxMC44Mzg2IDYuMjE4NzVDMTAuOTI0NiA2LjMwOTkgMTEuMDIzNSA2LjM3NzYgMTEuMTM1NSA2LjQyMTg4QzExLjI1MDEgNi40NjM1NCAxMS4zNzY0IDYuNDg0MzggMTEuNTE0NCA2LjQ4NDM4QzExLjY5MTUgNi40ODQzOCAxMS44NDY0IDYuNDUwNTIgMTEuOTc5MyA2LjM4MjgxQzEyLjExMjEgNi4zMTUxIDEyLjIyMjcgNi4yMDk2NCAxMi4zMTEzIDYuMDY2NDFDMTIuNDAyNCA1LjkyMDU3IDEyLjQ3MDEgNS43MzQzOCAxMi41MTQ0IDUuNTA3ODFDMTIuNTU4NyA1LjI3ODY1IDEyLjU4MDggNS4wMDUyMSAxMi41ODA4IDQuNjg3NVpNMTQuMzA2OCAyLjcwNzAzVjIuNDA2MjVDMTQuMzA2OCAyLjE5MDEgMTQuMzUzNiAxLjk5MzQ5IDE0LjQ0NzQgMS44MTY0MUMxNC41NDExIDEuNjM5MzIgMTQuNjc1MyAxLjQ5NzQgMTQuODQ5NyAxLjM5MDYyQzE1LjAyNDIgMS4yODM4NSAxNS4yMzEyIDEuMjMwNDcgMTUuNDcwOCAxLjIzMDQ3QzE1LjcxNTYgMS4yMzA0NyAxNS45MjQgMS4yODM4NSAxNi4wOTU4IDEuMzkwNjJDMTYuMjcwMyAxLjQ5NzQgMTYuNDA0NCAxLjYzOTMyIDE2LjQ5ODIgMS44MTY0MUMxNi41OTE5IDEuOTkzNDkgMTYuNjM4OCAyLjE5MDEgMTYuNjM4OCAyLjQwNjI1VjIuNzA3MDNDMTYuNjM4OCAyLjkxNzk3IDE2LjU5MTkgMy4xMTE5OCAxNi40OTgyIDMuMjg5MDZDMTYuNDA3IDMuNDY2MTUgMTYuMjc0MiAzLjYwODA3IDE2LjA5OTcgMy43MTQ4NEMxNS45Mjc5IDMuODIxNjEgMTUuNzIwOCAzLjg3NSAxNS40Nzg2IDMuODc1QzE1LjIzNjUgMy44NzUgMTUuMDI2OCAzLjgyMTYxIDE0Ljg0OTcgMy43MTQ4NEMxNC42NzUzIDMuNjA4MDcgMTQuNTQxMSAzLjQ2NjE1IDE0LjQ0NzQgMy4yODkwNkMxNC4zNTM2IDMuMTExOTggMTQuMzA2OCAyLjkxNzk3IDE0LjMwNjggMi43MDcwM1pNMTQuODQ5NyAyLjQwNjI1VjIuNzA3MDNDMTQuODQ5NyAyLjgyNjgyIDE0Ljg3MTkgMi45NDAxIDE0LjkxNjEgMy4wNDY4OEMxNC45NjMgMy4xNTM2NSAxNS4wMzMzIDMuMjQwODkgMTUuMTI3MSAzLjMwODU5QzE1LjIyMDggMy4zNzM3IDE1LjMzOCAzLjQwNjI1IDE1LjQ3ODYgMy40MDYyNUMxNS42MTkzIDMuNDA2MjUgMTUuNzM1MiAzLjM3MzcgMTUuODI2MyAzLjMwODU5QzE1LjkxNzQgMy4yNDA4OSAxNS45ODUyIDMuMTUzNjUgMTYuMDI5NCAzLjA0Njg4QzE2LjA3MzcgMi45NDAxIDE2LjA5NTggMi44MjY4MiAxNi4wOTU4IDIuNzA3MDNWMi40MDYyNUMxNi4wOTU4IDIuMjgzODUgMTYuMDcyNCAyLjE2OTI3IDE2LjAyNTUgMi4wNjI1QzE1Ljk4MTIgMS45NTMxMiAxNS45MTIyIDEuODY1ODkgMTUuODE4NSAxLjgwMDc4QzE1LjcyNzMgMS43MzMwNyAxNS42MTE1IDEuNjk5MjIgMTUuNDcwOCAxLjY5OTIyQzE1LjMzMjggMS42OTkyMiAxNS4yMTY5IDEuNzMzMDcgMTUuMTIzMiAxLjgwMDc4QzE1LjAzMiAxLjg2NTg5IDE0Ljk2MyAxLjk1MzEyIDE0LjkxNjEgMi4wNjI1QzE0Ljg3MTkgMi4xNjkyNyAxNC44NDk3IDIuMjgzODUgMTQuODQ5NyAyLjQwNjI1Wk0xNy4wNzYzIDUuOTEwMTZWNS42MDU0N0MxNy4wNzYzIDUuMzkxOTMgMTcuMTIzMiA1LjE5NjYxIDE3LjIxNjkgNS4wMTk1M0MxNy4zMTA3IDQuODQyNDUgMTcuNDQ0OCA0LjcwMDUyIDE3LjYxOTMgNC41OTM3NUMxNy43OTM3IDQuNDg2OTggMTguMDAwOCA0LjQzMzU5IDE4LjI0MDQgNC40MzM1OUMxOC40ODUyIDQuNDMzNTkgMTguNjkzNSA0LjQ4Njk4IDE4Ljg2NTQgNC41OTM3NUMxOS4wMzk4IDQuNzAwNTIgMTkuMTc0IDQuODQyNDUgMTkuMjY3NyA1LjAxOTUzQzE5LjM2MTUgNS4xOTY2MSAxOS40MDgzIDUuMzkxOTMgMTkuNDA4MyA1LjYwNTQ3VjUuOTEwMTZDMTkuNDA4MyA2LjEyMzcgMTkuMzYxNSA2LjMxOTAxIDE5LjI2NzcgNi40OTYwOUMxOS4xNzY2IDYuNjczMTggMTkuMDQzNyA2LjgxNTEgMTguODY5MyA2LjkyMTg4QzE4LjY5NzQgNy4wMjg2NSAxOC40OTA0IDcuMDgyMDMgMTguMjQ4MiA3LjA4MjAzQzE4LjAwNiA3LjA4MjAzIDE3Ljc5NzcgNy4wMjg2NSAxNy42MjMyIDYuOTIxODhDMTcuNDQ4NyA2LjgxNTEgMTcuMzEzMyA2LjY3MzE4IDE3LjIxNjkgNi40OTYwOUMxNy4xMjMyIDYuMzE5MDEgMTcuMDc2MyA2LjEyMzcgMTcuMDc2MyA1LjkxMDE2Wk0xNy42MTkzIDUuNjA1NDdWNS45MTAxNkMxNy42MTkzIDYuMDI5OTUgMTcuNjQxNCA2LjE0NDUzIDE3LjY4NTcgNi4yNTM5MUMxNy43MzI1IDYuMzYwNjggMTcuODAyOSA2LjQ0NzkyIDE3Ljg5NjYgNi41MTU2MkMxNy45OTA0IDYuNTgwNzMgMTguMTA3NSA2LjYxMzI4IDE4LjI0ODIgNi42MTMyOEMxOC4zODg4IDYuNjEzMjggMTguNTA0NyA2LjU4MDczIDE4LjU5NTggNi41MTU2MkMxOC42ODk2IDYuNDQ3OTIgMTguNzU4NiA2LjM2MDY4IDE4LjgwMjkgNi4yNTM5MUMxOC44NDcxIDYuMTQ3MTQgMTguODY5MyA2LjAzMjU1IDE4Ljg2OTMgNS45MTAxNlY1LjYwNTQ3QzE4Ljg2OTMgNS40ODMwNyAxOC44NDU4IDUuMzY4NDkgMTguNzk5IDUuMjYxNzJDMTguNzU0NyA1LjE1NDk1IDE4LjY4NTcgNS4wNjkwMSAxOC41OTE5IDUuMDAzOTFDMTguNTAwOCA0LjkzNjIgMTguMzgzNiA0LjkwMjM0IDE4LjI0MDQgNC45MDIzNEMxOC4xMDIzIDQuOTAyMzQgMTcuOTg2NSA0LjkzNjIgMTcuODkyNyA1LjAwMzkxQzE3LjgwMTYgNS4wNjkwMSAxNy43MzI1IDUuMTU0OTUgMTcuNjg1NyA1LjI2MTcyQzE3LjY0MTQgNS4zNjg0OSAxNy42MTkzIDUuNDgzMDcgMTcuNjE5MyA1LjYwNTQ3Wk0xOC40MiAyLjEyMTA5TDE1LjY0MjcgNi41NjY0MUwxNS4yMzY1IDYuMzA4NTlMMTguMDEzOCAxLjg2MzI4TDE4LjQyIDIuMTIxMDlaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjU0Ii8+CjxwYXRoIGQ9Ik04LjA1ODU5IDMzLjY2OEM4LjA1ODU5IDM0LjAxNDMgNy45Nzc4NiAzNC4zMDg2IDcuODE2NDEgMzQuNTUwOEM3LjY1NzU1IDM0Ljc5MDQgNy40NDE0MSAzNC45NzI3IDcuMTY3OTcgMzUuMDk3N0M2Ljg5NzE0IDM1LjIyMjcgNi41OTExNSAzNS4yODUyIDYuMjUgMzUuMjg1MkM1LjkwODg1IDM1LjI4NTIgNS42MDE1NiAzNS4yMjI3IDUuMzI4MTIgMzUuMDk3N0M1LjA1NDY5IDM0Ljk3MjcgNC44Mzg1NCAzNC43OTA0IDQuNjc5NjkgMzQuNTUwOEM0LjUyMDgzIDM0LjMwODYgNC40NDE0MSAzNC4wMTQzIDQuNDQxNDEgMzMuNjY4QzQuNDQxNDEgMzMuNDQxNCA0LjQ4NDM4IDMzLjIzNDQgNC41NzAzMSAzMy4wNDY5QzQuNjU4ODUgMzIuODU2OCA0Ljc4MjU1IDMyLjY5MTQgNC45NDE0MSAzMi41NTA4QzUuMTAyODYgMzIuNDEwMiA1LjI5Mjk3IDMyLjMwMjEgNS41MTE3MiAzMi4yMjY2QzUuNzMzMDcgMzIuMTQ4NCA1Ljk3NjU2IDMyLjEwOTQgNi4yNDIxOSAzMi4xMDk0QzYuNTkxMTUgMzIuMTA5NCA2LjkwMjM0IDMyLjE3NzEgNy4xNzU3OCAzMi4zMTI1QzcuNDQ5MjIgMzIuNDQ1MyA3LjY2NDA2IDMyLjYyODkgNy44MjAzMSAzMi44NjMzQzcuOTc5MTcgMzMuMDk3NyA4LjA1ODU5IDMzLjM2NTkgOC4wNTg1OSAzMy42NjhaTTcuMzMyMDMgMzMuNjUyM0M3LjMzMjAzIDMzLjQ0MTQgNy4yODY0NiAzMy4yNTUyIDcuMTk1MzEgMzMuMDkzOEM3LjEwNDE3IDMyLjkyOTcgNi45NzY1NiAzMi44MDIxIDYuODEyNSAzMi43MTA5QzYuNjQ4NDQgMzIuNjE5OCA2LjQ1ODMzIDMyLjU3NDIgNi4yNDIxOSAzMi41NzQyQzYuMDIwODMgMzIuNTc0MiA1LjgyOTQzIDMyLjYxOTggNS42Njc5NyAzMi43MTA5QzUuNTA5MTEgMzIuODAyMSA1LjM4NTQyIDMyLjkyOTcgNS4yOTY4OCAzMy4wOTM4QzUuMjA4MzMgMzMuMjU1MiA1LjE2NDA2IDMzLjQ0MTQgNS4xNjQwNiAzMy42NTIzQzUuMTY0MDYgMzMuODcxMSA1LjIwNzAzIDM0LjA1ODYgNS4yOTI5NyAzNC4yMTQ4QzUuMzgxNTEgMzQuMzY4NSA1LjUwNjUxIDM0LjQ4NyA1LjY2Nzk3IDM0LjU3MDNDNS44MzIwMyAzNC42NTEgNi4wMjYwNCAzNC42OTE0IDYuMjUgMzQuNjkxNEM2LjQ3Mzk2IDM0LjY5MTQgNi42NjY2NyAzNC42NTEgNi44MjgxMiAzNC41NzAzQzYuOTg5NTggMzQuNDg3IDcuMTEzMjggMzQuMzY4NSA3LjE5OTIyIDM0LjIxNDhDNy4yODc3NiAzNC4wNTg2IDcuMzMyMDMgMzMuODcxMSA3LjMzMjAzIDMzLjY1MjNaTTcuOTI1NzggMzFDNy45MjU3OCAzMS4yNzYgNy44NTI4NiAzMS41MjQ3IDcuNzA3MDMgMzEuNzQ2MUM3LjU2MTIgMzEuOTY3NCA3LjM2MTk4IDMyLjE0MTkgNy4xMDkzOCAzMi4yNjk1QzYuODU2NzcgMzIuMzk3MSA2LjU3MDMxIDMyLjQ2MDkgNi4yNSAzMi40NjA5QzUuOTI0NDggMzIuNDYwOSA1LjYzNDExIDMyLjM5NzEgNS4zNzg5MSAzMi4yNjk1QzUuMTI2MyAzMi4xNDE5IDQuOTI4MzkgMzEuOTY3NCA0Ljc4NTE2IDMxLjc0NjFDNC42NDE5MyAzMS41MjQ3IDQuNTcwMzEgMzEuMjc2IDQuNTcwMzEgMzFDNC41NzAzMSAzMC42NjkzIDQuNjQxOTMgMzAuMzg4IDQuNzg1MTYgMzAuMTU2MkM0LjkzMDk5IDI5LjkyNDUgNS4xMzAyMSAyOS43NDc0IDUuMzgyODEgMjkuNjI1QzUuNjM1NDIgMjkuNTAyNiA1LjkyMzE4IDI5LjQ0MTQgNi4yNDYwOSAyOS40NDE0QzYuNTcxNjEgMjkuNDQxNCA2Ljg2MDY4IDI5LjUwMjYgNy4xMTMyOCAyOS42MjVDNy4zNjU4OSAyOS43NDc0IDcuNTYzOCAyOS45MjQ1IDcuNzA3MDMgMzAuMTU2MkM3Ljg1Mjg2IDMwLjM4OCA3LjkyNTc4IDMwLjY2OTMgNy45MjU3OCAzMVpNNy4yMDMxMiAzMS4wMTE3QzcuMjAzMTIgMzAuODIxNiA3LjE2Mjc2IDMwLjY1MzYgNy4wODIwMyAzMC41MDc4QzcuMDAxMyAzMC4zNjIgNi44ODkzMiAzMC4yNDc0IDYuNzQ2MDkgMzAuMTY0MUM2LjYwMjg2IDMwLjA3ODEgNi40MzYyIDMwLjAzNTIgNi4yNDYwOSAzMC4wMzUyQzYuMDU1OTkgMzAuMDM1MiA1Ljg4OTMyIDMwLjA3NTUgNS43NDYwOSAzMC4xNTYyQzUuNjA1NDcgMzAuMjM0NCA1LjQ5NDc5IDMwLjM0NjQgNS40MTQwNiAzMC40OTIyQzUuMzM1OTQgMzAuNjM4IDUuMjk2ODggMzAuODExMiA1LjI5Njg4IDMxLjAxMTdDNS4yOTY4OCAzMS4yMDcgNS4zMzU5NCAzMS4zNzc2IDUuNDE0MDYgMzEuNTIzNEM1LjQ5NDc5IDMxLjY2OTMgNS42MDY3NyAzMS43ODI2IDUuNzUgMzEuODYzM0M1Ljg5MzIzIDMxLjk0NCA2LjA1OTkgMzEuOTg0NCA2LjI1IDMxLjk4NDRDNi40NDAxIDMxLjk4NDQgNi42MDU0NyAzMS45NDQgNi43NDYwOSAzMS44NjMzQzYuODg5MzIgMzEuNzgyNiA3LjAwMTMgMzEuNjY5MyA3LjA4MjAzIDMxLjUyMzRDNy4xNjI3NiAzMS4zNzc2IDcuMjAzMTIgMzEuMjA3IDcuMjAzMTIgMzEuMDExN1pNMTIuNjc1MiAzMS45MTAyVjMyLjc3NzNDMTIuNjc1MiAzMy4yNDM1IDEyLjYzMzUgMzMuNjM2NyAxMi41NTAyIDMzLjk1N0MxMi40NjY4IDM0LjI3NzMgMTIuMzQ3IDM0LjUzNTIgMTIuMTkwOCAzNC43MzA1QzEyLjAzNDUgMzQuOTI1OCAxMS44NDU3IDM1LjA2NzcgMTEuNjI0NCAzNS4xNTYyQzExLjQwNTYgMzUuMjQyMiAxMS4xNTgyIDM1LjI4NTIgMTAuODgyMiAzNS4yODUyQzEwLjY2MzUgMzUuMjg1MiAxMC40NjE2IDM1LjI1NzggMTAuMjc2NyAzNS4yMDMxQzEwLjA5MTggMzUuMTQ4NCA5LjkyNTE3IDM1LjA2MTIgOS43NzY3MyAzNC45NDE0QzkuNjMwOSAzNC44MTkgOS41MDU5IDM0LjY2MDIgOS40MDE3MyAzNC40NjQ4QzkuMjk3NTcgMzQuMjY5NSA5LjIxODE0IDM0LjAzMjYgOS4xNjM0NSAzMy43NTM5QzkuMTA4NzcgMzMuNDc1MyA5LjA4MTQyIDMzLjE0OTcgOS4wODE0MiAzMi43NzczVjMxLjkxMDJDOS4wODE0MiAzMS40NDQgOS4xMjMwOSAzMS4wNTM0IDkuMjA2NDIgMzAuNzM4M0M5LjI5MjM2IDMwLjQyMzIgOS40MTM0NSAzMC4xNzA2IDkuNTY5NyAyOS45ODA1QzkuNzI1OTUgMjkuNzg3OCA5LjkxMzQ1IDI5LjY0OTcgMTAuMTMyMiAyOS41NjY0QzEwLjM1MzYgMjkuNDgzMSAxMC42MDEgMjkuNDQxNCAxMC44NzQ0IDI5LjQ0MTRDMTEuMDk1NyAyOS40NDE0IDExLjI5ODkgMjkuNDY4OCAxMS40ODM4IDI5LjUyMzRDMTEuNjcxMyAyOS41NzU1IDExLjgzNzkgMjkuNjYwMiAxMS45ODM4IDI5Ljc3NzNDMTIuMTI5NiAyOS44OTE5IDEyLjI1MzMgMzAuMDQ1NiAxMi4zNTQ5IDMwLjIzODNDMTIuNDU5IDMwLjQyODQgMTIuNTM4NSAzMC42NjE1IDEyLjU5MzEgMzAuOTM3NUMxMi42NDc4IDMxLjIxMzUgMTIuNjc1MiAzMS41Mzc4IDEyLjY3NTIgMzEuOTEwMlpNMTEuOTQ4NiAzMi44OTQ1VjMxLjc4OTFDMTEuOTQ4NiAzMS41MzM5IDExLjkzMyAzMS4zMDk5IDExLjkwMTcgMzEuMTE3MkMxMS44NzMxIDMwLjkyMTkgMTEuODMwMSAzMC43NTUyIDExLjc3MjggMzAuNjE3MkMxMS43MTU1IDMwLjQ3OTIgMTEuNjQyNiAzMC4zNjcyIDExLjU1NDEgMzAuMjgxMkMxMS40NjgxIDMwLjE5NTMgMTEuMzY3OSAzMC4xMzI4IDExLjI1MzMgMzAuMDkzOEMxMS4xNDEzIDMwLjA1MjEgMTEuMDE1IDMwLjAzMTIgMTAuODc0NCAzMC4wMzEyQzEwLjcwMjUgMzAuMDMxMiAxMC41NTAyIDMwLjA2MzggMTAuNDE3NCAzMC4xMjg5QzEwLjI4NDUgMzAuMTkxNCAxMC4xNzI2IDMwLjI5MTcgMTAuMDgxNCAzMC40Mjk3QzkuOTkyODggMzAuNTY3NyA5LjkyNTE3IDMwLjc0ODcgOS44NzgzIDMwLjk3MjdDOS44MzE0MiAzMS4xOTY2IDkuODA3OTggMzEuNDY4OCA5LjgwNzk4IDMxLjc4OTFWMzIuODk0NUM5LjgwNzk4IDMzLjE0OTcgOS44MjIzMSAzMy4zNzUgOS44NTA5NSAzMy41NzAzQzkuODgyMiAzMy43NjU2IDkuOTI3NzggMzMuOTM0OSA5Ljk4NzY3IDM0LjA3ODFDMTAuMDQ3NiAzNC4yMTg4IDEwLjEyMDUgMzQuMzM0NiAxMC4yMDY0IDM0LjQyNThDMTAuMjkyNCAzNC41MTY5IDEwLjM5MTMgMzQuNTg0NiAxMC41MDMzIDM0LjYyODlDMTAuNjE3OSAzNC42NzA2IDEwLjc0NDIgMzQuNjkxNCAxMC44ODIyIDM0LjY5MTRDMTEuMDU5MyAzNC42OTE0IDExLjIxNDIgMzQuNjU3NiAxMS4zNDcgMzQuNTg5OEMxMS40Nzk5IDM0LjUyMjEgMTEuNTkwNSAzNC40MTY3IDExLjY3OTEgMzQuMjczNEMxMS43NzAyIDM0LjEyNzYgMTEuODM3OSAzMy45NDE0IDExLjg4MjIgMzMuNzE0OEMxMS45MjY1IDMzLjQ4NTcgMTEuOTQ4NiAzMy4yMTIyIDExLjk0ODYgMzIuODk0NVpNMTMuNjc0NiAzMC45MTQxVjMwLjYxMzNDMTMuNjc0NiAzMC4zOTcxIDEzLjcyMTQgMzAuMjAwNSAxMy44MTUyIDMwLjAyMzRDMTMuOTA4OSAyOS44NDY0IDE0LjA0MzEgMjkuNzA0NCAxNC4yMTc1IDI5LjU5NzdDMTQuMzkyIDI5LjQ5MDkgMTQuNTk5IDI5LjQzNzUgMTQuODM4NiAyOS40Mzc1QzE1LjA4MzQgMjkuNDM3NSAxNS4yOTE4IDI5LjQ5MDkgMTUuNDYzNiAyOS41OTc3QzE1LjYzODEgMjkuNzA0NCAxNS43NzIyIDI5Ljg0NjQgMTUuODY2IDMwLjAyMzRDMTUuOTU5NyAzMC4yMDA1IDE2LjAwNjYgMzAuMzk3MSAxNi4wMDY2IDMwLjYxMzNWMzAuOTE0MUMxNi4wMDY2IDMxLjEyNSAxNS45NTk3IDMxLjMxOSAxNS44NjYgMzEuNDk2MUMxNS43NzQ4IDMxLjY3MzIgMTUuNjQyIDMxLjgxNTEgMTUuNDY3NSAzMS45MjE5QzE1LjI5NTcgMzIuMDI4NiAxNS4wODg2IDMyLjA4MiAxNC44NDY0IDMyLjA4MkMxNC42MDQzIDMyLjA4MiAxNC4zOTQ2IDMyLjAyODYgMTQuMjE3NSAzMS45MjE5QzE0LjA0MzEgMzEuODE1MSAxMy45MDg5IDMxLjY3MzIgMTMuODE1MiAzMS40OTYxQzEzLjcyMTQgMzEuMzE5IDEzLjY3NDYgMzEuMTI1IDEzLjY3NDYgMzAuOTE0MVpNMTQuMjE3NSAzMC42MTMzVjMwLjkxNDFDMTQuMjE3NSAzMS4wMzM5IDE0LjIzOTcgMzEuMTQ3MSAxNC4yODM5IDMxLjI1MzlDMTQuMzMwOCAzMS4zNjA3IDE0LjQwMTEgMzEuNDQ3OSAxNC40OTQ5IDMxLjUxNTZDMTQuNTg4NiAzMS41ODA3IDE0LjcwNTggMzEuNjEzMyAxNC44NDY0IDMxLjYxMzNDMTQuOTg3MSAzMS42MTMzIDE1LjEwMjkgMzEuNTgwNyAxNS4xOTQxIDMxLjUxNTZDMTUuMjg1MiAzMS40NDc5IDE1LjM1MjkgMzEuMzYwNyAxNS4zOTcyIDMxLjI1MzlDMTUuNDQxNSAzMS4xNDcxIDE1LjQ2MzYgMzEuMDMzOSAxNS40NjM2IDMwLjkxNDFWMzAuNjEzM0MxNS40NjM2IDMwLjQ5MDkgMTUuNDQwMiAzMC4zNzYzIDE1LjM5MzMgMzAuMjY5NUMxNS4zNDkgMzAuMTYwMiAxNS4yOCAzMC4wNzI5IDE1LjE4NjMgMzAuMDA3OEMxNS4wOTUxIDI5Ljk0MDEgMTQuOTc5MyAyOS45MDYyIDE0LjgzODYgMjkuOTA2MkMxNC43MDA2IDI5LjkwNjIgMTQuNTg0NyAyOS45NDAxIDE0LjQ5MSAzMC4wMDc4QzE0LjM5OTggMzAuMDcyOSAxNC4zMzA4IDMwLjE2MDIgMTQuMjgzOSAzMC4yNjk1QzE0LjIzOTcgMzAuMzc2MyAxNC4yMTc1IDMwLjQ5MDkgMTQuMjE3NSAzMC42MTMzWk0xNi40NDQxIDM0LjExNzJWMzMuODEyNUMxNi40NDQxIDMzLjU5OSAxNi40OTEgMzMuNDAzNiAxNi41ODQ3IDMzLjIyNjZDMTYuNjc4NSAzMy4wNDk1IDE2LjgxMjYgMzIuOTA3NiAxNi45ODcxIDMyLjgwMDhDMTcuMTYxNSAzMi42OTQgMTcuMzY4NiAzMi42NDA2IDE3LjYwODIgMzIuNjQwNkMxNy44NTI5IDMyLjY0MDYgMTguMDYxMyAzMi42OTQgMTguMjMzMiAzMi44MDA4QzE4LjQwNzYgMzIuOTA3NiAxOC41NDE4IDMzLjA0OTUgMTguNjM1NSAzMy4yMjY2QzE4LjcyOTMgMzMuNDAzNiAxOC43NzYxIDMzLjU5OSAxOC43NzYxIDMzLjgxMjVWMzQuMTE3MkMxOC43NzYxIDM0LjMzMDcgMTguNzI5MyAzNC41MjYgMTguNjM1NSAzNC43MDMxQzE4LjU0NDQgMzQuODgwMiAxOC40MTE1IDM1LjAyMjEgMTguMjM3MSAzNS4xMjg5QzE4LjA2NTIgMzUuMjM1NyAxNy44NTgyIDM1LjI4OTEgMTcuNjE2IDM1LjI4OTFDMTcuMzczOCAzNS4yODkxIDE3LjE2NTQgMzUuMjM1NyAxNi45OTEgMzUuMTI4OUMxNi44MTY1IDM1LjAyMjEgMTYuNjgxMSAzNC44ODAyIDE2LjU4NDcgMzQuNzAzMUMxNi40OTEgMzQuNTI2IDE2LjQ0NDEgMzQuMzMwNyAxNi40NDQxIDM0LjExNzJaTTE2Ljk4NzEgMzMuODEyNVYzNC4xMTcyQzE2Ljk4NzEgMzQuMjM3IDE3LjAwOTIgMzQuMzUxNiAxNy4wNTM1IDM0LjQ2MDlDMTcuMTAwMyAzNC41Njc3IDE3LjE3MDcgMzQuNjU0OSAxNy4yNjQ0IDM0LjcyMjdDMTcuMzU4MiAzNC43ODc4IDE3LjQ3NTMgMzQuODIwMyAxNy42MTYgMzQuODIwM0MxNy43NTY2IDM0LjgyMDMgMTcuODcyNSAzNC43ODc4IDE3Ljk2MzYgMzQuNzIyN0MxOC4wNTc0IDM0LjY1NDkgMTguMTI2NCAzNC41Njc3IDE4LjE3MDcgMzQuNDYwOUMxOC4yMTQ5IDM0LjM1NDIgMTguMjM3MSAzNC4yMzk2IDE4LjIzNzEgMzQuMTE3MlYzMy44MTI1QzE4LjIzNzEgMzMuNjkwMSAxOC4yMTM2IDMzLjU3NTUgMTguMTY2OCAzMy40Njg4QzE4LjEyMjUgMzMuMzYyIDE4LjA1MzUgMzMuMjc2IDE3Ljk1OTcgMzMuMjEwOUMxNy44Njg2IDMzLjE0MzIgMTcuNzUxNCAzMy4xMDk0IDE3LjYwODIgMzMuMTA5NEMxNy40NzAxIDMzLjEwOTQgMTcuMzU0MyAzMy4xNDMyIDE3LjI2MDUgMzMuMjEwOUMxNy4xNjk0IDMzLjI3NiAxNy4xMDAzIDMzLjM2MiAxNy4wNTM1IDMzLjQ2ODhDMTcuMDA5MiAzMy41NzU1IDE2Ljk4NzEgMzMuNjkwMSAxNi45ODcxIDMzLjgxMjVaTTE3Ljc4NzggMzAuMzI4MUwxNS4wMTA1IDM0Ljc3MzRMMTQuNjA0MyAzNC41MTU2TDE3LjM4MTYgMzAuMDcwM0wxNy43ODc4IDMwLjMyODFaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjU0Ii8+CjxwYXRoIGQ9Ik03LjI0NjA5IDU3LjcxNzhINy4zMDg1OVY1OC4zMzExSDcuMjQ2MDlDNi44NjMyOCA1OC4zMzExIDYuNTQyOTcgNTguMzkzNiA2LjI4NTE2IDU4LjUxODZDNi4wMjczNCA1OC42NDEgNS44MjI5MiA1OC44MDYzIDUuNjcxODggNTkuMDE0NkM1LjUyMDgzIDU5LjIyMDQgNS40MTE0NiA1OS40NTIxIDUuMzQzNzUgNTkuNzFDNS4yNzg2NSA1OS45Njc4IDUuMjQ2MDkgNjAuMjI5NSA1LjI0NjA5IDYwLjQ5NTFWNjEuMzMxMUM1LjI0NjA5IDYxLjU4MzcgNS4yNzYwNCA2MS44MDc2IDUuMzM1OTQgNjIuMDAyOUM1LjM5NTgzIDYyLjE5NTYgNS40Nzc4NiA2Mi4zNTg0IDUuNTgyMDMgNjIuNDkxMkM1LjY4NjIgNjIuNjI0IDUuODAzMzkgNjIuNzI0MyA1LjkzMzU5IDYyLjc5MkM2LjA2NjQxIDYyLjg1OTcgNi4yMDQ0MyA2Mi44OTM2IDYuMzQ3NjYgNjIuODkzNkM2LjUxNDMyIDYyLjg5MzYgNi42NjI3NiA2Mi44NjIzIDYuNzkyOTcgNjIuNzk5OEM2LjkyMzE4IDYyLjczNDcgNy4wMzI1NSA2Mi42NDQ5IDcuMTIxMDkgNjIuNTMwM0M3LjIxMjI0IDYyLjQxMzEgNy4yODEyNSA2Mi4yNzUxIDcuMzI4MTIgNjIuMTE2MkM3LjM3NSA2MS45NTc0IDcuMzk4NDQgNjEuNzgyOSA3LjM5ODQ0IDYxLjU5MjhDNy4zOTg0NCA2MS40MjM1IDcuMzc3NiA2MS4yNjA3IDcuMzM1OTQgNjEuMTA0NUM3LjI5NDI3IDYwLjk0NTYgNy4yMzA0NyA2MC44MDUgNy4xNDQ1MyA2MC42ODI2QzcuMDU4NTkgNjAuNTU3NiA2Ljk1MDUyIDYwLjQ2IDYuODIwMzEgNjAuMzg5NkM2LjY5MjcxIDYwLjMxNjcgNi41NDAzNiA2MC4yODAzIDYuMzYzMjggNjAuMjgwM0M2LjE2Mjc2IDYwLjI4MDMgNS45NzUyNiA2MC4zMjk4IDUuODAwNzggNjAuNDI4N0M1LjYyODkxIDYwLjUyNTEgNS40ODY5OCA2MC42NTI3IDUuMzc1IDYwLjgxMTVDNS4yNjU2MiA2MC45Njc4IDUuMjAzMTIgNjEuMTM4MyA1LjE4NzUgNjEuMzIzMkw0LjgwNDY5IDYxLjMxOTNDNC44NDExNSA2MS4wMjc3IDQuOTA4ODUgNjAuNzc5IDUuMDA3ODEgNjAuNTczMkM1LjEwOTM4IDYwLjM2NDkgNS4yMzQzOCA2MC4xOTU2IDUuMzgyODEgNjAuMDY1NEM1LjUzMzg1IDU5LjkzMjYgNS43MDE4MiA1OS44MzYzIDUuODg2NzIgNTkuNzc2NEM2LjA3NDIyIDU5LjcxMzkgNi4yNzIxNCA1OS42ODI2IDYuNDgwNDcgNTkuNjgyNkM2Ljc2NDMyIDU5LjY4MjYgNy4wMDkxMSA1OS43MzYgNy4yMTQ4NCA1OS44NDI4QzcuNDIwNTcgNTkuOTQ5NSA3LjU4OTg0IDYwLjA5MjggNy43MjI2NiA2MC4yNzI1QzcuODU1NDcgNjAuNDQ5NSA3Ljk1MzEyIDYwLjY1MDEgOC4wMTU2MiA2MC44NzRDOC4wODA3MyA2MS4wOTU0IDguMTEzMjggNjEuMzIzMiA4LjExMzI4IDYxLjU1NzZDOC4xMTMyOCA2MS44MjU4IDguMDc1NTIgNjIuMDc3MSA4IDYyLjMxMTVDNy45MjQ0OCA2Mi41NDU5IDcuODExMiA2Mi43NTE2IDcuNjYwMTYgNjIuOTI4N0M3LjUxMTcyIDYzLjEwNTggNy4zMjgxMiA2My4yNDM4IDcuMTA5MzggNjMuMzQyOEM2Ljg5MDYyIDYzLjQ0MTcgNi42MzY3MiA2My40OTEyIDYuMzQ3NjYgNjMuNDkxMkM2LjA0MDM2IDYzLjQ5MTIgNS43NzIxNCA2My40Mjg3IDUuNTQyOTcgNjMuMzAzN0M1LjMxMzggNjMuMTc2MSA1LjEyMzcgNjMuMDA2OCA0Ljk3MjY2IDYyLjc5NTlDNC44MjE2MSA2Mi41ODUgNC43MDgzMyA2Mi4zNTA2IDQuNjMyODEgNjIuMDkyOEM0LjU1NzI5IDYxLjgzNSA0LjUxOTUzIDYxLjU3MzIgNC41MTk1MyA2MS4zMDc2VjYwLjk2NzhDNC41MTk1MyA2MC41NjY3IDQuNTU5OSA2MC4xNzM1IDQuNjQwNjIgNTkuNzg4MUM0LjcyMTM1IDU5LjQwMjcgNC44NjA2OCA1OS4wNTM3IDUuMDU4NTkgNTguNzQxMkM1LjI1OTExIDU4LjQyODcgNS41MzY0NiA1OC4xOCA1Ljg5MDYyIDU3Ljk5NTFDNi4yNDQ3OSA1Ny44MTAyIDYuNjk2NjEgNTcuNzE3OCA3LjI0NjA5IDU3LjcxNzhaTTEyLjY3NTIgNjAuMTE2MlY2MC45ODM0QzEyLjY3NTIgNjEuNDQ5NSAxMi42MzM1IDYxLjg0MjggMTIuNTUwMiA2Mi4xNjMxQzEyLjQ2NjggNjIuNDgzNCAxMi4zNDcgNjIuNzQxMiAxMi4xOTA4IDYyLjkzNjVDMTIuMDM0NSA2My4xMzE4IDExLjg0NTcgNjMuMjczOCAxMS42MjQ0IDYzLjM2MjNDMTEuNDA1NiA2My40NDgyIDExLjE1ODIgNjMuNDkxMiAxMC44ODIyIDYzLjQ5MTJDMTAuNjYzNSA2My40OTEyIDEwLjQ2MTYgNjMuNDYzOSAxMC4yNzY3IDYzLjQwOTJDMTAuMDkxOCA2My4zNTQ1IDkuOTI1MTcgNjMuMjY3MyA5Ljc3NjczIDYzLjE0NzVDOS42MzA5IDYzLjAyNTEgOS41MDU5IDYyLjg2NjIgOS40MDE3MyA2Mi42NzA5QzkuMjk3NTcgNjIuNDc1NiA5LjIxODE0IDYyLjIzODYgOS4xNjM0NSA2MS45NkM5LjEwODc3IDYxLjY4MTMgOS4wODE0MiA2MS4zNTU4IDkuMDgxNDIgNjAuOTgzNFY2MC4xMTYyQzkuMDgxNDIgNTkuNjUwMSA5LjEyMzA5IDU5LjI1OTQgOS4yMDY0MiA1OC45NDQzQzkuMjkyMzYgNTguNjI5MiA5LjQxMzQ1IDU4LjM3NjYgOS41Njk3IDU4LjE4NjVDOS43MjU5NSA1Ny45OTM4IDkuOTEzNDUgNTcuODU1OCAxMC4xMzIyIDU3Ljc3MjVDMTAuMzUzNiA1Ny42ODkxIDEwLjYwMSA1Ny42NDc1IDEwLjg3NDQgNTcuNjQ3NUMxMS4wOTU3IDU3LjY0NzUgMTEuMjk4OSA1Ny42NzQ4IDExLjQ4MzggNTcuNzI5NUMxMS42NzEzIDU3Ljc4MTYgMTEuODM3OSA1Ny44NjYyIDExLjk4MzggNTcuOTgzNEMxMi4xMjk2IDU4LjA5OCAxMi4yNTMzIDU4LjI1MTYgMTIuMzU0OSA1OC40NDQzQzEyLjQ1OSA1OC42MzQ0IDEyLjUzODUgNTguODY3NSAxMi41OTMxIDU5LjE0MzZDMTIuNjQ3OCA1OS40MTk2IDEyLjY3NTIgNTkuNzQzOCAxMi42NzUyIDYwLjExNjJaTTExLjk0ODYgNjEuMTAwNlY1OS45OTUxQzExLjk0ODYgNTkuNzM5OSAxMS45MzMgNTkuNTE2IDExLjkwMTcgNTkuMzIzMkMxMS44NzMxIDU5LjEyNzkgMTEuODMwMSA1OC45NjEzIDExLjc3MjggNTguODIzMkMxMS43MTU1IDU4LjY4NTIgMTEuNjQyNiA1OC41NzMyIDExLjU1NDEgNTguNDg3M0MxMS40NjgxIDU4LjQwMTQgMTEuMzY3OSA1OC4zMzg5IDExLjI1MzMgNTguMjk5OEMxMS4xNDEzIDU4LjI1ODEgMTEuMDE1IDU4LjIzNzMgMTAuODc0NCA1OC4yMzczQzEwLjcwMjUgNTguMjM3MyAxMC41NTAyIDU4LjI2OTkgMTAuNDE3NCA1OC4zMzVDMTAuMjg0NSA1OC4zOTc1IDEwLjE3MjYgNTguNDk3NyAxMC4wODE0IDU4LjYzNTdDOS45OTI4OCA1OC43NzM4IDkuOTI1MTcgNTguOTU0OCA5Ljg3ODMgNTkuMTc4N0M5LjgzMTQyIDU5LjQwMjcgOS44MDc5OCA1OS42NzQ4IDkuODA3OTggNTkuOTk1MVY2MS4xMDA2QzkuODA3OTggNjEuMzU1OCA5LjgyMjMxIDYxLjU4MTEgOS44NTA5NSA2MS43NzY0QzkuODgyMiA2MS45NzE3IDkuOTI3NzggNjIuMTQxIDkuOTg3NjcgNjIuMjg0MkMxMC4wNDc2IDYyLjQyNDggMTAuMTIwNSA2Mi41NDA3IDEwLjIwNjQgNjIuNjMxOEMxMC4yOTI0IDYyLjcyMyAxMC4zOTEzIDYyLjc5MDcgMTAuNTAzMyA2Mi44MzVDMTAuNjE3OSA2Mi44NzY2IDEwLjc0NDIgNjIuODk3NSAxMC44ODIyIDYyLjg5NzVDMTEuMDU5MyA2Mi44OTc1IDExLjIxNDIgNjIuODYzNiAxMS4zNDcgNjIuNzk1OUMxMS40Nzk5IDYyLjcyODIgMTEuNTkwNSA2Mi42MjI3IDExLjY3OTEgNjIuNDc5NUMxMS43NzAyIDYyLjMzMzcgMTEuODM3OSA2Mi4xNDc1IDExLjg4MjIgNjEuOTIwOUMxMS45MjY1IDYxLjY5MTcgMTEuOTQ4NiA2MS40MTgzIDExLjk0ODYgNjEuMTAwNlpNMTMuNjc0NiA1OS4xMjAxVjU4LjgxOTNDMTMuNjc0NiA1OC42MDMyIDEzLjcyMTQgNTguNDA2NiAxMy44MTUyIDU4LjIyOTVDMTMuOTA4OSA1OC4wNTI0IDE0LjA0MzEgNTcuOTEwNSAxNC4yMTc1IDU3LjgwMzdDMTQuMzkyIDU3LjY5NjkgMTQuNTk5IDU3LjY0MzYgMTQuODM4NiA1Ny42NDM2QzE1LjA4MzQgNTcuNjQzNiAxNS4yOTE4IDU3LjY5NjkgMTUuNDYzNiA1Ny44MDM3QzE1LjYzODEgNTcuOTEwNSAxNS43NzIyIDU4LjA1MjQgMTUuODY2IDU4LjIyOTVDMTUuOTU5NyA1OC40MDY2IDE2LjAwNjYgNTguNjAzMiAxNi4wMDY2IDU4LjgxOTNWNTkuMTIwMUMxNi4wMDY2IDU5LjMzMTEgMTUuOTU5NyA1OS41MjUxIDE1Ljg2NiA1OS43MDIxQzE1Ljc3NDggNTkuODc5MiAxNS42NDIgNjAuMDIxMiAxNS40Njc1IDYwLjEyNzlDMTUuMjk1NyA2MC4yMzQ3IDE1LjA4ODYgNjAuMjg4MSAxNC44NDY0IDYwLjI4ODFDMTQuNjA0MyA2MC4yODgxIDE0LjM5NDYgNjAuMjM0NyAxNC4yMTc1IDYwLjEyNzlDMTQuMDQzMSA2MC4wMjEyIDEzLjkwODkgNTkuODc5MiAxMy44MTUyIDU5LjcwMjFDMTMuNzIxNCA1OS41MjUxIDEzLjY3NDYgNTkuMzMxMSAxMy42NzQ2IDU5LjEyMDFaTTE0LjIxNzUgNTguODE5M1Y1OS4xMjAxQzE0LjIxNzUgNTkuMjM5OSAxNC4yMzk3IDU5LjM1MzIgMTQuMjgzOSA1OS40NkMxNC4zMzA4IDU5LjU2NjcgMTQuNDAxMSA1OS42NTQgMTQuNDk0OSA1OS43MjE3QzE0LjU4ODYgNTkuNzg2OCAxNC43MDU4IDU5LjgxOTMgMTQuODQ2NCA1OS44MTkzQzE0Ljk4NzEgNTkuODE5MyAxNS4xMDI5IDU5Ljc4NjggMTUuMTk0MSA1OS43MjE3QzE1LjI4NTIgNTkuNjU0IDE1LjM1MjkgNTkuNTY2NyAxNS4zOTcyIDU5LjQ2QzE1LjQ0MTUgNTkuMzUzMiAxNS40NjM2IDU5LjIzOTkgMTUuNDYzNiA1OS4xMjAxVjU4LjgxOTNDMTUuNDYzNiA1OC42OTY5IDE1LjQ0MDIgNTguNTgyNCAxNS4zOTMzIDU4LjQ3NTZDMTUuMzQ5IDU4LjM2NjIgMTUuMjggNTguMjc5IDE1LjE4NjMgNTguMjEzOUMxNS4wOTUxIDU4LjE0NjIgMTQuOTc5MyA1OC4xMTIzIDE0LjgzODYgNTguMTEyM0MxNC43MDA2IDU4LjExMjMgMTQuNTg0NyA1OC4xNDYyIDE0LjQ5MSA1OC4yMTM5QzE0LjM5OTggNTguMjc5IDE0LjMzMDggNTguMzY2MiAxNC4yODM5IDU4LjQ3NTZDMTQuMjM5NyA1OC41ODI0IDE0LjIxNzUgNTguNjk2OSAxNC4yMTc1IDU4LjgxOTNaTTE2LjQ0NDEgNjIuMzIzMlY2Mi4wMTg2QzE2LjQ0NDEgNjEuODA1IDE2LjQ5MSA2MS42MDk3IDE2LjU4NDcgNjEuNDMyNkMxNi42Nzg1IDYxLjI1NTUgMTYuODEyNiA2MS4xMTM2IDE2Ljk4NzEgNjEuMDA2OEMxNy4xNjE1IDYwLjkwMDEgMTcuMzY4NiA2MC44NDY3IDE3LjYwODIgNjAuODQ2N0MxNy44NTI5IDYwLjg0NjcgMTguMDYxMyA2MC45MDAxIDE4LjIzMzIgNjEuMDA2OEMxOC40MDc2IDYxLjExMzYgMTguNTQxOCA2MS4yNTU1IDE4LjYzNTUgNjEuNDMyNkMxOC43MjkzIDYxLjYwOTcgMTguNzc2MSA2MS44MDUgMTguNzc2MSA2Mi4wMTg2VjYyLjMyMzJDMTguNzc2MSA2Mi41MzY4IDE4LjcyOTMgNjIuNzMyMSAxOC42MzU1IDYyLjkwOTJDMTguNTQ0NCA2My4wODYzIDE4LjQxMTUgNjMuMjI4MiAxOC4yMzcxIDYzLjMzNUMxOC4wNjUyIDYzLjQ0MTcgMTcuODU4MiA2My40OTUxIDE3LjYxNiA2My40OTUxQzE3LjM3MzggNjMuNDk1MSAxNy4xNjU0IDYzLjQ0MTcgMTYuOTkxIDYzLjMzNUMxNi44MTY1IDYzLjIyODIgMTYuNjgxMSA2My4wODYzIDE2LjU4NDcgNjIuOTA5MkMxNi40OTEgNjIuNzMyMSAxNi40NDQxIDYyLjUzNjggMTYuNDQ0MSA2Mi4zMjMyWk0xNi45ODcxIDYyLjAxODZWNjIuMzIzMkMxNi45ODcxIDYyLjQ0MyAxNy4wMDkyIDYyLjU1NzYgMTcuMDUzNSA2Mi42NjdDMTcuMTAwMyA2Mi43NzM4IDE3LjE3MDcgNjIuODYxIDE3LjI2NDQgNjIuOTI4N0MxNy4zNTgyIDYyLjk5MzggMTcuNDc1MyA2My4wMjY0IDE3LjYxNiA2My4wMjY0QzE3Ljc1NjYgNjMuMDI2NCAxNy44NzI1IDYyLjk5MzggMTcuOTYzNiA2Mi45Mjg3QzE4LjA1NzQgNjIuODYxIDE4LjEyNjQgNjIuNzczOCAxOC4xNzA3IDYyLjY2N0MxOC4yMTQ5IDYyLjU2MDIgMTguMjM3MSA2Mi40NDU2IDE4LjIzNzEgNjIuMzIzMlY2Mi4wMTg2QzE4LjIzNzEgNjEuODk2MiAxOC4yMTM2IDYxLjc4MTYgMTguMTY2OCA2MS42NzQ4QzE4LjEyMjUgNjEuNTY4IDE4LjA1MzUgNjEuNDgyMSAxNy45NTk3IDYxLjQxN0MxNy44Njg2IDYxLjM0OTMgMTcuNzUxNCA2MS4zMTU0IDE3LjYwODIgNjEuMzE1NEMxNy40NzAxIDYxLjMxNTQgMTcuMzU0MyA2MS4zNDkzIDE3LjI2MDUgNjEuNDE3QzE3LjE2OTQgNjEuNDgyMSAxNy4xMDAzIDYxLjU2OCAxNy4wNTM1IDYxLjY3NDhDMTcuMDA5MiA2MS43ODE2IDE2Ljk4NzEgNjEuODk2MiAxNi45ODcxIDYyLjAxODZaTTE3Ljc4NzggNTguNTM0MkwxNS4wMTA1IDYyLjk3OTVMMTQuNjA0MyA2Mi43MjE3TDE3LjM4MTYgNTguMjc2NEwxNy43ODc4IDU4LjUzNDJaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjU0Ii8+CjxwYXRoIGQ9Ik04LjMxNjQxIDg5LjcwNjFWOTAuMjk5OEg0LjIwNzAzVjg5Ljg3NEw2Ljc1MzkxIDg1LjkzMjZINy4zNDM3NUw2LjcxMDk0IDg3LjA3MzJMNS4wMjczNCA4OS43MDYxSDguMzE2NDFaTTcuNTIzNDQgODUuOTMyNlY5MS42MjAxSDYuODAwNzhWODUuOTMyNkg3LjUyMzQ0Wk0xMi42NzUyIDg4LjMyMzJWODkuMTkwNEMxMi42NzUyIDg5LjY1NjYgMTIuNjMzNSA5MC4wNDk4IDEyLjU1MDIgOTAuMzcwMUMxMi40NjY4IDkwLjY5MDQgMTIuMzQ3IDkwLjk0ODIgMTIuMTkwOCA5MS4xNDM2QzEyLjAzNDUgOTEuMzM4OSAxMS44NDU3IDkxLjQ4MDggMTEuNjI0NCA5MS41NjkzQzExLjQwNTYgOTEuNjU1MyAxMS4xNTgyIDkxLjY5ODIgMTAuODgyMiA5MS42OTgyQzEwLjY2MzUgOTEuNjk4MiAxMC40NjE2IDkxLjY3MDkgMTAuMjc2NyA5MS42MTYyQzEwLjA5MTggOTEuNTYxNSA5LjkyNTE3IDkxLjQ3NDMgOS43NzY3MyA5MS4zNTQ1QzkuNjMwOSA5MS4yMzIxIDkuNTA1OSA5MS4wNzMyIDkuNDAxNzMgOTAuODc3OUM5LjI5NzU3IDkwLjY4MjYgOS4yMTgxNCA5MC40NDU2IDkuMTYzNDUgOTAuMTY3QzkuMTA4NzcgODkuODg4MyA5LjA4MTQyIDg5LjU2MjggOS4wODE0MiA4OS4xOTA0Vjg4LjMyMzJDOS4wODE0MiA4Ny44NTcxIDkuMTIzMDkgODcuNDY2NSA5LjIwNjQyIDg3LjE1MTRDOS4yOTIzNiA4Ni44MzYzIDkuNDEzNDUgODYuNTgzNyA5LjU2OTcgODYuMzkzNkM5LjcyNTk1IDg2LjIwMDggOS45MTM0NSA4Ni4wNjI4IDEwLjEzMjIgODUuOTc5NUMxMC4zNTM2IDg1Ljg5NjIgMTAuNjAxIDg1Ljg1NDUgMTAuODc0NCA4NS44NTQ1QzExLjA5NTcgODUuODU0NSAxMS4yOTg5IDg1Ljg4MTggMTEuNDgzOCA4NS45MzY1QzExLjY3MTMgODUuOTg4NiAxMS44Mzc5IDg2LjA3MzIgMTEuOTgzOCA4Ni4xOTA0QzEyLjEyOTYgODYuMzA1IDEyLjI1MzMgODYuNDU4NyAxMi4zNTQ5IDg2LjY1MTRDMTIuNDU5IDg2Ljg0MTUgMTIuNTM4NSA4Ny4wNzQ1IDEyLjU5MzEgODcuMzUwNkMxMi42NDc4IDg3LjYyNjYgMTIuNjc1MiA4Ny45NTA4IDEyLjY3NTIgODguMzIzMlpNMTEuOTQ4NiA4OS4zMDc2Vjg4LjIwMjFDMTEuOTQ4NiA4Ny45NDY5IDExLjkzMyA4Ny43MjMgMTEuOTAxNyA4Ny41MzAzQzExLjg3MzEgODcuMzM1IDExLjgzMDEgODcuMTY4MyAxMS43NzI4IDg3LjAzMDNDMTEuNzE1NSA4Ni44OTIzIDExLjY0MjYgODYuNzgwMyAxMS41NTQxIDg2LjY5NDNDMTEuNDY4MSA4Ni42MDg0IDExLjM2NzkgODYuNTQ1OSAxMS4yNTMzIDg2LjUwNjhDMTEuMTQxMyA4Ni40NjUyIDExLjAxNSA4Ni40NDQzIDEwLjg3NDQgODYuNDQ0M0MxMC43MDI1IDg2LjQ0NDMgMTAuNTUwMiA4Ni40NzY5IDEwLjQxNzQgODYuNTQyQzEwLjI4NDUgODYuNjA0NSAxMC4xNzI2IDg2LjcwNDggMTAuMDgxNCA4Ni44NDI4QzkuOTkyODggODYuOTgwOCA5LjkyNTE3IDg3LjE2MTggOS44NzgzIDg3LjM4NTdDOS44MzE0MiA4Ny42MDk3IDkuODA3OTggODcuODgxOCA5LjgwNzk4IDg4LjIwMjFWODkuMzA3NkM5LjgwNzk4IDg5LjU2MjggOS44MjIzMSA4OS43ODgxIDkuODUwOTUgODkuOTgzNEM5Ljg4MjIgOTAuMTc4NyA5LjkyNzc4IDkwLjM0OCA5Ljk4NzY3IDkwLjQ5MTJDMTAuMDQ3NiA5MC42MzE4IDEwLjEyMDUgOTAuNzQ3NyAxMC4yMDY0IDkwLjgzODlDMTAuMjkyNCA5MC45MyAxMC4zOTEzIDkwLjk5NzcgMTAuNTAzMyA5MS4wNDJDMTAuNjE3OSA5MS4wODM3IDEwLjc0NDIgOTEuMTA0NSAxMC44ODIyIDkxLjEwNDVDMTEuMDU5MyA5MS4xMDQ1IDExLjIxNDIgOTEuMDcwNiAxMS4zNDcgOTEuMDAyOUMxMS40Nzk5IDkwLjkzNTIgMTEuNTkwNSA5MC44Mjk4IDExLjY3OTEgOTAuNjg2NUMxMS43NzAyIDkwLjU0MDcgMTEuODM3OSA5MC4zNTQ1IDExLjg4MjIgOTAuMTI3OUMxMS45MjY1IDg5Ljg5ODggMTEuOTQ4NiA4OS42MjUzIDExLjk0ODYgODkuMzA3NlpNMTMuNjc0NiA4Ny4zMjcxVjg3LjAyNjRDMTMuNjc0NiA4Ni44MTAyIDEzLjcyMTQgODYuNjEzNiAxMy44MTUyIDg2LjQzNjVDMTMuOTA4OSA4Ni4yNTk0IDE0LjA0MzEgODYuMTE3NSAxNC4yMTc1IDg2LjAxMDdDMTQuMzkyIDg1LjkwNCAxNC41OTkgODUuODUwNiAxNC44Mzg2IDg1Ljg1MDZDMTUuMDgzNCA4NS44NTA2IDE1LjI5MTggODUuOTA0IDE1LjQ2MzYgODYuMDEwN0MxNS42MzgxIDg2LjExNzUgMTUuNzcyMiA4Ni4yNTk0IDE1Ljg2NiA4Ni40MzY1QzE1Ljk1OTcgODYuNjEzNiAxNi4wMDY2IDg2LjgxMDIgMTYuMDA2NiA4Ny4wMjY0Vjg3LjMyNzFDMTYuMDA2NiA4Ny41MzgxIDE1Ljk1OTcgODcuNzMyMSAxNS44NjYgODcuOTA5MkMxNS43NzQ4IDg4LjA4NjMgMTUuNjQyIDg4LjIyODIgMTUuNDY3NSA4OC4zMzVDMTUuMjk1NyA4OC40NDE3IDE1LjA4ODYgODguNDk1MSAxNC44NDY0IDg4LjQ5NTFDMTQuNjA0MyA4OC40OTUxIDE0LjM5NDYgODguNDQxNyAxNC4yMTc1IDg4LjMzNUMxNC4wNDMxIDg4LjIyODIgMTMuOTA4OSA4OC4wODYzIDEzLjgxNTIgODcuOTA5MkMxMy43MjE0IDg3LjczMjEgMTMuNjc0NiA4Ny41MzgxIDEzLjY3NDYgODcuMzI3MVpNMTQuMjE3NSA4Ny4wMjY0Vjg3LjMyNzFDMTQuMjE3NSA4Ny40NDY5IDE0LjIzOTcgODcuNTYwMiAxNC4yODM5IDg3LjY2N0MxNC4zMzA4IDg3Ljc3MzggMTQuNDAxMSA4Ny44NjEgMTQuNDk0OSA4Ny45Mjg3QzE0LjU4ODYgODcuOTkzOCAxNC43MDU4IDg4LjAyNjQgMTQuODQ2NCA4OC4wMjY0QzE0Ljk4NzEgODguMDI2NCAxNS4xMDI5IDg3Ljk5MzggMTUuMTk0MSA4Ny45Mjg3QzE1LjI4NTIgODcuODYxIDE1LjM1MjkgODcuNzczOCAxNS4zOTcyIDg3LjY2N0MxNS40NDE1IDg3LjU2MDIgMTUuNDYzNiA4Ny40NDY5IDE1LjQ2MzYgODcuMzI3MVY4Ny4wMjY0QzE1LjQ2MzYgODYuOTA0IDE1LjQ0MDIgODYuNzg5NCAxNS4zOTMzIDg2LjY4MjZDMTUuMzQ5IDg2LjU3MzIgMTUuMjggODYuNDg2IDE1LjE4NjMgODYuNDIwOUMxNS4wOTUxIDg2LjM1MzIgMTQuOTc5MyA4Ni4zMTkzIDE0LjgzODYgODYuMzE5M0MxNC43MDA2IDg2LjMxOTMgMTQuNTg0NyA4Ni4zNTMyIDE0LjQ5MSA4Ni40MjA5QzE0LjM5OTggODYuNDg2IDE0LjMzMDggODYuNTczMiAxNC4yODM5IDg2LjY4MjZDMTQuMjM5NyA4Ni43ODk0IDE0LjIxNzUgODYuOTA0IDE0LjIxNzUgODcuMDI2NFpNMTYuNDQ0MSA5MC41MzAzVjkwLjIyNTZDMTYuNDQ0MSA5MC4wMTIgMTYuNDkxIDg5LjgxNjcgMTYuNTg0NyA4OS42Mzk2QzE2LjY3ODUgODkuNDYyNiAxNi44MTI2IDg5LjMyMDYgMTYuOTg3MSA4OS4yMTM5QzE3LjE2MTUgODkuMTA3MSAxNy4zNjg2IDg5LjA1MzcgMTcuNjA4MiA4OS4wNTM3QzE3Ljg1MjkgODkuMDUzNyAxOC4wNjEzIDg5LjEwNzEgMTguMjMzMiA4OS4yMTM5QzE4LjQwNzYgODkuMzIwNiAxOC41NDE4IDg5LjQ2MjYgMTguNjM1NSA4OS42Mzk2QzE4LjcyOTMgODkuODE2NyAxOC43NzYxIDkwLjAxMiAxOC43NzYxIDkwLjIyNTZWOTAuNTMwM0MxOC43NzYxIDkwLjc0MzggMTguNzI5MyA5MC45MzkxIDE4LjYzNTUgOTEuMTE2MkMxOC41NDQ0IDkxLjI5MzMgMTguNDExNSA5MS40MzUyIDE4LjIzNzEgOTEuNTQyQzE4LjA2NTIgOTEuNjQ4OCAxNy44NTgyIDkxLjcwMjEgMTcuNjE2IDkxLjcwMjFDMTcuMzczOCA5MS43MDIxIDE3LjE2NTQgOTEuNjQ4OCAxNi45OTEgOTEuNTQyQzE2LjgxNjUgOTEuNDM1MiAxNi42ODExIDkxLjI5MzMgMTYuNTg0NyA5MS4xMTYyQzE2LjQ5MSA5MC45MzkxIDE2LjQ0NDEgOTAuNzQzOCAxNi40NDQxIDkwLjUzMDNaTTE2Ljk4NzEgOTAuMjI1NlY5MC41MzAzQzE2Ljk4NzEgOTAuNjUwMSAxNy4wMDkyIDkwLjc2NDYgMTcuMDUzNSA5MC44NzRDMTcuMTAwMyA5MC45ODA4IDE3LjE3MDcgOTEuMDY4IDE3LjI2NDQgOTEuMTM1N0MxNy4zNTgyIDkxLjIwMDggMTcuNDc1MyA5MS4yMzM0IDE3LjYxNiA5MS4yMzM0QzE3Ljc1NjYgOTEuMjMzNCAxNy44NzI1IDkxLjIwMDggMTcuOTYzNiA5MS4xMzU3QzE4LjA1NzQgOTEuMDY4IDE4LjEyNjQgOTAuOTgwOCAxOC4xNzA3IDkwLjg3NEMxOC4yMTQ5IDkwLjc2NzMgMTguMjM3MSA5MC42NTI3IDE4LjIzNzEgOTAuNTMwM1Y5MC4yMjU2QzE4LjIzNzEgOTAuMTAzMiAxOC4yMTM2IDg5Ljk4ODYgMTguMTY2OCA4OS44ODE4QzE4LjEyMjUgODkuNzc1MSAxOC4wNTM1IDg5LjY4OTEgMTcuOTU5NyA4OS42MjRDMTcuODY4NiA4OS41NTYzIDE3Ljc1MTQgODkuNTIyNSAxNy42MDgyIDg5LjUyMjVDMTcuNDcwMSA4OS41MjI1IDE3LjM1NDMgODkuNTU2MyAxNy4yNjA1IDg5LjYyNEMxNy4xNjk0IDg5LjY4OTEgMTcuMTAwMyA4OS43NzUxIDE3LjA1MzUgODkuODgxOEMxNy4wMDkyIDg5Ljk4ODYgMTYuOTg3MSA5MC4xMDMyIDE2Ljk4NzEgOTAuMjI1NlpNMTcuNzg3OCA4Ni43NDEyTDE1LjAxMDUgOTEuMTg2NUwxNC42MDQzIDkwLjkyODdMMTcuMzgxNiA4Ni40ODM0TDE3Ljc4NzggODYuNzQxMloiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPHBhdGggZD0iTTguMTk5MjIgMTE5LjIzM1YxMTkuODI3SDQuNDc2NTZWMTE5LjMwOEw2LjMzOTg0IDExNy4yMzNDNi41NjkwMSAxMTYuOTc4IDYuNzQ2MDkgMTE2Ljc2MiA2Ljg3MTA5IDExNi41ODVDNi45OTg3IDExNi40MDUgNy4wODcyNCAxMTYuMjQ1IDcuMTM2NzIgMTE2LjEwNEM3LjE4ODggMTE1Ljk2MSA3LjIxNDg0IDExNS44MTUgNy4yMTQ4NCAxMTUuNjY3QzcuMjE0ODQgMTE1LjQ3OSA3LjE3NTc4IDExNS4zMSA3LjA5NzY2IDExNS4xNTlDNy4wMjIxNCAxMTUuMDA2IDYuOTEwMTYgMTE0Ljg4MyA2Ljc2MTcyIDExNC43OTJDNi42MTMyOCAxMTQuNzAxIDYuNDMzNTkgMTE0LjY1NSA2LjIyMjY2IDExNC42NTVDNS45NzAwNSAxMTQuNjU1IDUuNzU5MTEgMTE0LjcwNSA1LjU4OTg0IDExNC44MDRDNS40MjMxOCAxMTQuOSA1LjI5ODE4IDExNS4wMzUgNS4yMTQ4NCAxMTUuMjFDNS4xMzE1MSAxMTUuMzg0IDUuMDg5ODQgMTE1LjU4NSA1LjA4OTg0IDExNS44MTJINC4zNjcxOUM0LjM2NzE5IDExNS40OTEgNC40Mzc1IDExNS4xOTggNC41NzgxMiAxMTQuOTMzQzQuNzE4NzUgMTE0LjY2NyA0LjkyNzA4IDExNC40NTYgNS4yMDMxMiAxMTQuM0M1LjQ3OTE3IDExNC4xNDEgNS44MTkwMSAxMTQuMDYyIDYuMjIyNjYgMTE0LjA2MkM2LjU4MjAzIDExNC4wNjIgNi44ODkzMiAxMTQuMTI1IDcuMTQ0NTMgMTE0LjI1M0M3LjM5OTc0IDExNC4zNzggNy41OTUwNSAxMTQuNTU1IDcuNzMwNDcgMTE0Ljc4NEM3Ljg2ODQ5IDExNS4wMTEgNy45Mzc1IDExNS4yNzYgNy45Mzc1IDExNS41ODFDNy45Mzc1IDExNS43NDggNy45MDg4NSAxMTUuOTE3IDcuODUxNTYgMTE2LjA4OUM3Ljc5Njg4IDExNi4yNTggNy43MjAwNSAxMTYuNDI3IDcuNjIxMDkgMTE2LjU5N0M3LjUyNDc0IDExNi43NjYgNy40MTE0NiAxMTYuOTMzIDcuMjgxMjUgMTE3LjA5N0M3LjE1MzY1IDExNy4yNjEgNy4wMTY5MyAxMTcuNDIyIDYuODcxMDkgMTE3LjU4MUw1LjM0NzY2IDExOS4yMzNIOC4xOTkyMlpNMTIuNjc1MiAxMTYuNTNWMTE3LjM5N0MxMi42NzUyIDExNy44NjQgMTIuNjMzNSAxMTguMjU3IDEyLjU1MDIgMTE4LjU3N0MxMi40NjY4IDExOC44OTcgMTIuMzQ3IDExOS4xNTUgMTIuMTkwOCAxMTkuMzUxQzEyLjAzNDUgMTE5LjU0NiAxMS44NDU3IDExOS42ODggMTEuNjI0NCAxMTkuNzc2QzExLjQwNTYgMTE5Ljg2MiAxMS4xNTgyIDExOS45MDUgMTAuODgyMiAxMTkuOTA1QzEwLjY2MzUgMTE5LjkwNSAxMC40NjE2IDExOS44NzggMTAuMjc2NyAxMTkuODIzQzEwLjA5MTggMTE5Ljc2OSA5LjkyNTE3IDExOS42ODEgOS43NzY3MyAxMTkuNTYyQzkuNjMwOSAxMTkuNDM5IDkuNTA1OSAxMTkuMjggOS40MDE3MyAxMTkuMDg1QzkuMjk3NTcgMTE4Ljg5IDkuMjE4MTQgMTE4LjY1MyA5LjE2MzQ1IDExOC4zNzRDOS4xMDg3NyAxMTguMDk1IDkuMDgxNDIgMTE3Ljc3IDkuMDgxNDIgMTE3LjM5N1YxMTYuNTNDOS4wODE0MiAxMTYuMDY0IDkuMTIzMDkgMTE1LjY3NCA5LjIwNjQyIDExNS4zNThDOS4yOTIzNiAxMTUuMDQzIDkuNDEzNDUgMTE0Ljc5MSA5LjU2OTcgMTE0LjYwMUM5LjcyNTk1IDExNC40MDggOS45MTM0NSAxMTQuMjcgMTAuMTMyMiAxMTQuMTg3QzEwLjM1MzYgMTE0LjEwMyAxMC42MDEgMTE0LjA2MiAxMC44NzQ0IDExNC4wNjJDMTEuMDk1NyAxMTQuMDYyIDExLjI5ODkgMTE0LjA4OSAxMS40ODM4IDExNC4xNDRDMTEuNjcxMyAxMTQuMTk2IDExLjgzNzkgMTE0LjI4IDExLjk4MzggMTE0LjM5N0MxMi4xMjk2IDExNC41MTIgMTIuMjUzMyAxMTQuNjY2IDEyLjM1NDkgMTE0Ljg1OEMxMi40NTkgMTE1LjA0OSAxMi41Mzg1IDExNS4yODIgMTIuNTkzMSAxMTUuNTU4QzEyLjY0NzggMTE1LjgzNCAxMi42NzUyIDExNi4xNTggMTIuNjc1MiAxMTYuNTNaTTExLjk0ODYgMTE3LjUxNVYxMTYuNDA5QzExLjk0ODYgMTE2LjE1NCAxMS45MzMgMTE1LjkzIDExLjkwMTcgMTE1LjczN0MxMS44NzMxIDExNS41NDIgMTEuODMwMSAxMTUuMzc1IDExLjc3MjggMTE1LjIzN0MxMS43MTU1IDExNS4wOTkgMTEuNjQyNiAxMTQuOTg3IDExLjU1NDEgMTE0LjkwMUMxMS40NjgxIDExNC44MTUgMTEuMzY3OSAxMTQuNzUzIDExLjI1MzMgMTE0LjcxNEMxMS4xNDEzIDExNC42NzIgMTEuMDE1IDExNC42NTEgMTAuODc0NCAxMTQuNjUxQzEwLjcwMjUgMTE0LjY1MSAxMC41NTAyIDExNC42ODQgMTAuNDE3NCAxMTQuNzQ5QzEwLjI4NDUgMTE0LjgxMiAxMC4xNzI2IDExNC45MTIgMTAuMDgxNCAxMTUuMDVDOS45OTI4OCAxMTUuMTg4IDkuOTI1MTcgMTE1LjM2OSA5Ljg3ODMgMTE1LjU5M0M5LjgzMTQyIDExNS44MTcgOS44MDc5OCAxMTYuMDg5IDkuODA3OTggMTE2LjQwOVYxMTcuNTE1QzkuODA3OTggMTE3Ljc3IDkuODIyMzEgMTE3Ljk5NSA5Ljg1MDk1IDExOC4xOUM5Ljg4MjIgMTE4LjM4NiA5LjkyNzc4IDExOC41NTUgOS45ODc2NyAxMTguNjk4QzEwLjA0NzYgMTE4LjgzOSAxMC4xMjA1IDExOC45NTUgMTAuMjA2NCAxMTkuMDQ2QzEwLjI5MjQgMTE5LjEzNyAxMC4zOTEzIDExOS4yMDUgMTAuNTAzMyAxMTkuMjQ5QzEwLjYxNzkgMTE5LjI5MSAxMC43NDQyIDExOS4zMTIgMTAuODgyMiAxMTkuMzEyQzExLjA1OTMgMTE5LjMxMiAxMS4yMTQyIDExOS4yNzggMTEuMzQ3IDExOS4yMUMxMS40Nzk5IDExOS4xNDIgMTEuNTkwNSAxMTkuMDM3IDExLjY3OTEgMTE4Ljg5NEMxMS43NzAyIDExOC43NDggMTEuODM3OSAxMTguNTYyIDExLjg4MjIgMTE4LjMzNUMxMS45MjY1IDExOC4xMDYgMTEuOTQ4NiAxMTcuODMyIDExLjk0ODYgMTE3LjUxNVpNMTMuNjc0NiAxMTUuNTM0VjExNS4yMzNDMTMuNjc0NiAxMTUuMDE3IDEzLjcyMTQgMTE0LjgyMSAxMy44MTUyIDExNC42NDRDMTMuOTA4OSAxMTQuNDY2IDE0LjA0MzEgMTE0LjMyNSAxNC4yMTc1IDExNC4yMThDMTQuMzkyIDExNC4xMTEgMTQuNTk5IDExNC4wNTggMTQuODM4NiAxMTQuMDU4QzE1LjA4MzQgMTE0LjA1OCAxNS4yOTE4IDExNC4xMTEgMTUuNDYzNiAxMTQuMjE4QzE1LjYzODEgMTE0LjMyNSAxNS43NzIyIDExNC40NjYgMTUuODY2IDExNC42NDRDMTUuOTU5NyAxMTQuODIxIDE2LjAwNjYgMTE1LjAxNyAxNi4wMDY2IDExNS4yMzNWMTE1LjUzNEMxNi4wMDY2IDExNS43NDUgMTUuOTU5NyAxMTUuOTM5IDE1Ljg2NiAxMTYuMTE2QzE1Ljc3NDggMTE2LjI5MyAxNS42NDIgMTE2LjQzNSAxNS40Njc1IDExNi41NDJDMTUuMjk1NyAxMTYuNjQ5IDE1LjA4ODYgMTE2LjcwMiAxNC44NDY0IDExNi43MDJDMTQuNjA0MyAxMTYuNzAyIDE0LjM5NDYgMTE2LjY0OSAxNC4yMTc1IDExNi41NDJDMTQuMDQzMSAxMTYuNDM1IDEzLjkwODkgMTE2LjI5MyAxMy44MTUyIDExNi4xMTZDMTMuNzIxNCAxMTUuOTM5IDEzLjY3NDYgMTE1Ljc0NSAxMy42NzQ2IDExNS41MzRaTTE0LjIxNzUgMTE1LjIzM1YxMTUuNTM0QzE0LjIxNzUgMTE1LjY1NCAxNC4yMzk3IDExNS43NjcgMTQuMjgzOSAxMTUuODc0QzE0LjMzMDggMTE1Ljk4MSAxNC40MDExIDExNi4wNjggMTQuNDk0OSAxMTYuMTM2QzE0LjU4ODYgMTE2LjIwMSAxNC43MDU4IDExNi4yMzMgMTQuODQ2NCAxMTYuMjMzQzE0Ljk4NzEgMTE2LjIzMyAxNS4xMDI5IDExNi4yMDEgMTUuMTk0MSAxMTYuMTM2QzE1LjI4NTIgMTE2LjA2OCAxNS4zNTI5IDExNS45ODEgMTUuMzk3MiAxMTUuODc0QzE1LjQ0MTUgMTE1Ljc2NyAxNS40NjM2IDExNS42NTQgMTUuNDYzNiAxMTUuNTM0VjExNS4yMzNDMTUuNDYzNiAxMTUuMTExIDE1LjQ0MDIgMTE0Ljk5NiAxNS4zOTMzIDExNC44OUMxNS4zNDkgMTE0Ljc4IDE1LjI4IDExNC42OTMgMTUuMTg2MyAxMTQuNjI4QzE1LjA5NTEgMTE0LjU2IDE0Ljk3OTMgMTE0LjUyNiAxNC44Mzg2IDExNC41MjZDMTQuNzAwNiAxMTQuNTI2IDE0LjU4NDcgMTE0LjU2IDE0LjQ5MSAxMTQuNjI4QzE0LjM5OTggMTE0LjY5MyAxNC4zMzA4IDExNC43OCAxNC4yODM5IDExNC44OUMxNC4yMzk3IDExNC45OTYgMTQuMjE3NSAxMTUuMTExIDE0LjIxNzUgMTE1LjIzM1pNMTYuNDQ0MSAxMTguNzM3VjExOC40MzNDMTYuNDQ0MSAxMTguMjE5IDE2LjQ5MSAxMTguMDI0IDE2LjU4NDcgMTE3Ljg0N0MxNi42Nzg1IDExNy42NyAxNi44MTI2IDExNy41MjggMTYuOTg3MSAxMTcuNDIxQzE3LjE2MTUgMTE3LjMxNCAxNy4zNjg2IDExNy4yNjEgMTcuNjA4MiAxMTcuMjYxQzE3Ljg1MjkgMTE3LjI2MSAxOC4wNjEzIDExNy4zMTQgMTguMjMzMiAxMTcuNDIxQzE4LjQwNzYgMTE3LjUyOCAxOC41NDE4IDExNy42NyAxOC42MzU1IDExNy44NDdDMTguNzI5MyAxMTguMDI0IDE4Ljc3NjEgMTE4LjIxOSAxOC43NzYxIDExOC40MzNWMTE4LjczN0MxOC43NzYxIDExOC45NTEgMTguNzI5MyAxMTkuMTQ2IDE4LjYzNTUgMTE5LjMyM0MxOC41NDQ0IDExOS41IDE4LjQxMTUgMTE5LjY0MiAxOC4yMzcxIDExOS43NDlDMTguMDY1MiAxMTkuODU2IDE3Ljg1ODIgMTE5LjkwOSAxNy42MTYgMTE5LjkwOUMxNy4zNzM4IDExOS45MDkgMTcuMTY1NCAxMTkuODU2IDE2Ljk5MSAxMTkuNzQ5QzE2LjgxNjUgMTE5LjY0MiAxNi42ODExIDExOS41IDE2LjU4NDcgMTE5LjMyM0MxNi40OTEgMTE5LjE0NiAxNi40NDQxIDExOC45NTEgMTYuNDQ0MSAxMTguNzM3Wk0xNi45ODcxIDExOC40MzNWMTE4LjczN0MxNi45ODcxIDExOC44NTcgMTcuMDA5MiAxMTguOTcyIDE3LjA1MzUgMTE5LjA4MUMxNy4xMDAzIDExOS4xODggMTcuMTcwNyAxMTkuMjc1IDE3LjI2NDQgMTE5LjM0M0MxNy4zNTgyIDExOS40MDggMTcuNDc1MyAxMTkuNDQgMTcuNjE2IDExOS40NEMxNy43NTY2IDExOS40NCAxNy44NzI1IDExOS40MDggMTcuOTYzNiAxMTkuMzQzQzE4LjA1NzQgMTE5LjI3NSAxOC4xMjY0IDExOS4xODggMTguMTcwNyAxMTkuMDgxQzE4LjIxNDkgMTE4Ljk3NCAxOC4yMzcxIDExOC44NiAxOC4yMzcxIDExOC43MzdWMTE4LjQzM0MxOC4yMzcxIDExOC4zMSAxOC4yMTM2IDExOC4xOTYgMTguMTY2OCAxMTguMDg5QzE4LjEyMjUgMTE3Ljk4MiAxOC4wNTM1IDExNy44OTYgMTcuOTU5NyAxMTcuODMxQzE3Ljg2ODYgMTE3Ljc2MyAxNy43NTE0IDExNy43MjkgMTcuNjA4MiAxMTcuNzI5QzE3LjQ3MDEgMTE3LjcyOSAxNy4zNTQzIDExNy43NjMgMTcuMjYwNSAxMTcuODMxQzE3LjE2OTQgMTE3Ljg5NiAxNy4xMDAzIDExNy45ODIgMTcuMDUzNSAxMTguMDg5QzE3LjAwOTIgMTE4LjE5NiAxNi45ODcxIDExOC4zMSAxNi45ODcxIDExOC40MzNaTTE3Ljc4NzggMTE0Ljk0OEwxNS4wMTA1IDExOS4zOTRMMTQuNjA0MyAxMTkuMTM2TDE3LjM4MTYgMTE0LjY5TDE3Ljc4NzggMTE0Ljk0OFoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNTQiLz4KPHBhdGggZD0iTTEzLjA0MyAxNDQuNzM3VjE0NS42MDRDMTMuMDQzIDE0Ni4wNzEgMTMuMDAxMyAxNDYuNDY0IDEyLjkxOCAxNDYuNzg0QzEyLjgzNDYgMTQ3LjEwNCAxMi43MTQ4IDE0Ny4zNjIgMTIuNTU4NiAxNDcuNTU4QzEyLjQwMjMgMTQ3Ljc1MyAxMi4yMTM1IDE0Ny44OTUgMTEuOTkyMiAxNDcuOTgzQzExLjc3MzQgMTQ4LjA2OSAxMS41MjYgMTQ4LjExMiAxMS4yNSAxNDguMTEyQzExLjAzMTIgMTQ4LjExMiAxMC44Mjk0IDE0OC4wODUgMTAuNjQ0NSAxNDguMDNDMTAuNDU5NiAxNDcuOTc2IDEwLjI5MyAxNDcuODg4IDEwLjE0NDUgMTQ3Ljc2OUM5Ljk5ODcgMTQ3LjY0NiA5Ljg3MzcgMTQ3LjQ4NyA5Ljc2OTUzIDE0Ny4yOTJDOS42NjUzNiAxNDcuMDk3IDkuNTg1OTQgMTQ2Ljg2IDkuNTMxMjUgMTQ2LjU4MUM5LjQ3NjU2IDE0Ni4zMDIgOS40NDkyMiAxNDUuOTc3IDkuNDQ5MjIgMTQ1LjYwNFYxNDQuNzM3QzkuNDQ5MjIgMTQ0LjI3MSA5LjQ5MDg5IDE0My44ODEgOS41NzQyMiAxNDMuNTY1QzkuNjYwMTYgMTQzLjI1IDkuNzgxMjUgMTQyLjk5OCA5LjkzNzUgMTQyLjgwOEMxMC4wOTM4IDE0Mi42MTUgMTAuMjgxMiAxNDIuNDc3IDEwLjUgMTQyLjM5NEMxMC43MjE0IDE0Mi4zMSAxMC45Njg4IDE0Mi4yNjkgMTEuMjQyMiAxNDIuMjY5QzExLjQ2MzUgMTQyLjI2OSAxMS42NjY3IDE0Mi4yOTYgMTEuODUxNiAxNDIuMzUxQzEyLjAzOTEgMTQyLjQwMyAxMi4yMDU3IDE0Mi40ODcgMTIuMzUxNiAxNDIuNjA0QzEyLjQ5NzQgMTQyLjcxOSAxMi42MjExIDE0Mi44NzMgMTIuNzIyNyAxNDMuMDY1QzEyLjgyNjggMTQzLjI1NiAxMi45MDYyIDE0My40ODkgMTIuOTYwOSAxNDMuNzY1QzEzLjAxNTYgMTQ0LjA0MSAxMy4wNDMgMTQ0LjM2NSAxMy4wNDMgMTQ0LjczN1pNMTIuMzE2NCAxNDUuNzIyVjE0NC42MTZDMTIuMzE2NCAxNDQuMzYxIDEyLjMwMDggMTQ0LjEzNyAxMi4yNjk1IDE0My45NDRDMTIuMjQwOSAxNDMuNzQ5IDEyLjE5NzkgMTQzLjU4MiAxMi4xNDA2IDE0My40NDRDMTIuMDgzMyAxNDMuMzA2IDEyLjAxMDQgMTQzLjE5NCAxMS45MjE5IDE0My4xMDhDMTEuODM1OSAxNDMuMDIyIDExLjczNTcgMTQyLjk2IDExLjYyMTEgMTQyLjkyMUMxMS41MDkxIDE0Mi44NzkgMTEuMzgyOCAxNDIuODU4IDExLjI0MjIgMTQyLjg1OEMxMS4wNzAzIDE0Mi44NTggMTAuOTE4IDE0Mi44OTEgMTAuNzg1MiAxNDIuOTU2QzEwLjY1MjMgMTQzLjAxOSAxMC41NDA0IDE0My4xMTkgMTAuNDQ5MiAxNDMuMjU3QzEwLjM2MDcgMTQzLjM5NSAxMC4yOTMgMTQzLjU3NiAxMC4yNDYxIDE0My44QzEwLjE5OTIgMTQ0LjAyNCAxMC4xNzU4IDE0NC4yOTYgMTAuMTc1OCAxNDQuNjE2VjE0NS43MjJDMTAuMTc1OCAxNDUuOTc3IDEwLjE5MDEgMTQ2LjIwMiAxMC4yMTg4IDE0Ni4zOTdDMTAuMjUgMTQ2LjU5MyAxMC4yOTU2IDE0Ni43NjIgMTAuMzU1NSAxNDYuOTA1QzEwLjQxNTQgMTQ3LjA0NiAxMC40ODgzIDE0Ny4xNjIgMTAuNTc0MiAxNDcuMjUzQzEwLjY2MDIgMTQ3LjM0NCAxMC43NTkxIDE0Ny40MTIgMTAuODcxMSAxNDcuNDU2QzEwLjk4NTcgMTQ3LjQ5OCAxMS4xMTIgMTQ3LjUxOSAxMS4yNSAxNDcuNTE5QzExLjQyNzEgMTQ3LjUxOSAxMS41ODIgMTQ3LjQ4NSAxMS43MTQ4IDE0Ny40MTdDMTEuODQ3NyAxNDcuMzQ5IDExLjk1ODMgMTQ3LjI0NCAxMi4wNDY5IDE0Ny4xMDFDMTIuMTM4IDE0Ni45NTUgMTIuMjA1NyAxNDYuNzY5IDEyLjI1IDE0Ni41NDJDMTIuMjk0MyAxNDYuMzEzIDEyLjMxNjQgMTQ2LjAzOSAxMi4zMTY0IDE0NS43MjJaTTE0LjA0MjQgMTQzLjc0MVYxNDMuNDRDMTQuMDQyNCAxNDMuMjI0IDE0LjA4OTIgMTQzLjAyOCAxNC4xODMgMTQyLjg1MUMxNC4yNzY3IDE0Mi42NzQgMTQuNDEwOCAxNDIuNTMyIDE0LjU4NTMgMTQyLjQyNUMxNC43NTk4IDE0Mi4zMTggMTQuOTY2OCAxNDIuMjY1IDE1LjIwNjQgMTQyLjI2NUMxNS40NTEyIDE0Mi4yNjUgMTUuNjU5NSAxNDIuMzE4IDE1LjgzMTQgMTQyLjQyNUMxNi4wMDU5IDE0Mi41MzIgMTYuMTQgMTQyLjY3NCAxNi4yMzM4IDE0Mi44NTFDMTYuMzI3NSAxNDMuMDI4IDE2LjM3NDQgMTQzLjIyNCAxNi4zNzQ0IDE0My40NFYxNDMuNzQxQzE2LjM3NDQgMTQzLjk1MiAxNi4zMjc1IDE0NC4xNDYgMTYuMjMzOCAxNDQuMzIzQzE2LjE0MjYgMTQ0LjUgMTYuMDA5OCAxNDQuNjQyIDE1LjgzNTMgMTQ0Ljc0OUMxNS42NjM1IDE0NC44NTYgMTUuNDU2NCAxNDQuOTA5IDE1LjIxNDIgMTQ0LjkwOUMxNC45NzIgMTQ0LjkwOSAxNC43NjI0IDE0NC44NTYgMTQuNTg1MyAxNDQuNzQ5QzE0LjQxMDggMTQ0LjY0MiAxNC4yNzY3IDE0NC41IDE0LjE4MyAxNDQuMzIzQzE0LjA4OTIgMTQ0LjE0NiAxNC4wNDI0IDE0My45NTIgMTQuMDQyNCAxNDMuNzQxWk0xNC41ODUzIDE0My40NFYxNDMuNzQxQzE0LjU4NTMgMTQzLjg2MSAxNC42MDc1IDE0My45NzQgMTQuNjUxNyAxNDQuMDgxQzE0LjY5ODYgMTQ0LjE4OCAxNC43Njg5IDE0NC4yNzUgMTQuODYyNyAxNDQuMzQzQzE0Ljk1NjQgMTQ0LjQwOCAxNS4wNzM2IDE0NC40NCAxNS4yMTQyIDE0NC40NEMxNS4zNTQ5IDE0NC40NCAxNS40NzA3IDE0NC40MDggMTUuNTYxOSAxNDQuMzQzQzE1LjY1MyAxNDQuMjc1IDE1LjcyMDcgMTQ0LjE4OCAxNS43NjUgMTQ0LjA4MUMxNS44MDkzIDE0My45NzQgMTUuODMxNCAxNDMuODYxIDE1LjgzMTQgMTQzLjc0MVYxNDMuNDRDMTUuODMxNCAxNDMuMzE4IDE1LjgwOCAxNDMuMjAzIDE1Ljc2MTEgMTQzLjA5N0MxNS43MTY4IDE0Mi45ODcgMTUuNjQ3OCAxNDIuOSAxNS41NTQxIDE0Mi44MzVDMTUuNDYyOSAxNDIuNzY3IDE1LjM0NyAxNDIuNzMzIDE1LjIwNjQgMTQyLjczM0MxNS4wNjg0IDE0Mi43MzMgMTQuOTUyNSAxNDIuNzY3IDE0Ljg1ODggMTQyLjgzNUMxNC43Njc2IDE0Mi45IDE0LjY5ODYgMTQyLjk4NyAxNC42NTE3IDE0My4wOTdDMTQuNjA3NSAxNDMuMjAzIDE0LjU4NTMgMTQzLjMxOCAxNC41ODUzIDE0My40NFpNMTYuODExOSAxNDYuOTQ0VjE0Ni42NEMxNi44MTE5IDE0Ni40MjYgMTYuODU4OCAxNDYuMjMxIDE2Ljk1MjUgMTQ2LjA1NEMxNy4wNDYzIDE0NS44NzcgMTcuMTgwNCAxNDUuNzM1IDE3LjM1NDkgMTQ1LjYyOEMxNy41MjkzIDE0NS41MjEgMTcuNzM2NCAxNDUuNDY4IDE3Ljk3NiAxNDUuNDY4QzE4LjIyMDcgMTQ1LjQ2OCAxOC40MjkxIDE0NS41MjEgMTguNjAxIDE0NS42MjhDMTguNzc1NCAxNDUuNzM1IDE4LjkwOTUgMTQ1Ljg3NyAxOS4wMDMzIDE0Ni4wNTRDMTkuMDk3IDE0Ni4yMzEgMTkuMTQzOSAxNDYuNDI2IDE5LjE0MzkgMTQ2LjY0VjE0Ni45NDRDMTkuMTQzOSAxNDcuMTU4IDE5LjA5NyAxNDcuMzUzIDE5LjAwMzMgMTQ3LjUzQzE4LjkxMjIgMTQ3LjcwNyAxOC43NzkzIDE0Ny44NDkgMTguNjA0OSAxNDcuOTU2QzE4LjQzMyAxNDguMDYzIDE4LjIyNiAxNDguMTE2IDE3Ljk4MzggMTQ4LjExNkMxNy43NDE2IDE0OC4xMTYgMTcuNTMzMiAxNDguMDYzIDE3LjM1ODggMTQ3Ljk1NkMxNy4xODQzIDE0Ny44NDkgMTcuMDQ4OSAxNDcuNzA3IDE2Ljk1MjUgMTQ3LjUzQzE2Ljg1ODggMTQ3LjM1MyAxNi44MTE5IDE0Ny4xNTggMTYuODExOSAxNDYuOTQ0Wk0xNy4zNTQ5IDE0Ni42NFYxNDYuOTQ0QzE3LjM1NDkgMTQ3LjA2NCAxNy4zNzcgMTQ3LjE3OSAxNy40MjEzIDE0Ny4yODhDMTcuNDY4MSAxNDcuMzk1IDE3LjUzODUgMTQ3LjQ4MiAxNy42MzIyIDE0Ny41NUMxNy43MjYgMTQ3LjYxNSAxNy44NDMxIDE0Ny42NDcgMTcuOTgzOCAxNDcuNjQ3QzE4LjEyNDQgMTQ3LjY0NyAxOC4yNDAzIDE0Ny42MTUgMTguMzMxNCAxNDcuNTVDMTguNDI1MiAxNDcuNDgyIDE4LjQ5NDIgMTQ3LjM5NSAxOC41Mzg1IDE0Ny4yODhDMTguNTgyNyAxNDcuMTgxIDE4LjYwNDkgMTQ3LjA2NyAxOC42MDQ5IDE0Ni45NDRWMTQ2LjY0QzE4LjYwNDkgMTQ2LjUxNyAxOC41ODE0IDE0Ni40MDMgMTguNTM0NSAxNDYuMjk2QzE4LjQ5MDMgMTQ2LjE4OSAxOC40MjEzIDE0Ni4xMDMgMTguMzI3NSAxNDYuMDM4QzE4LjIzNjQgMTQ1Ljk3IDE4LjExOTIgMTQ1LjkzNyAxNy45NzYgMTQ1LjkzN0MxNy44Mzc5IDE0NS45MzcgMTcuNzIyIDE0NS45NyAxNy42MjgzIDE0Ni4wMzhDMTcuNTM3MiAxNDYuMTAzIDE3LjQ2ODEgMTQ2LjE4OSAxNy40MjEzIDE0Ni4yOTZDMTcuMzc3IDE0Ni40MDMgMTcuMzU0OSAxNDYuNTE3IDE3LjM1NDkgMTQ2LjY0Wk0xOC4xNTU2IDE0My4xNTVMMTUuMzc4MyAxNDcuNjAxTDE0Ljk3MiAxNDcuMzQzTDE3Ljc0OTQgMTQyLjg5N0wxOC4xNTU2IDE0My4xNTVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjU0Ii8+CjxwYXRoIGQ9Ik0xMzggNThDMTM4IDU2Ljg5NTQgMTM4Ljg5NSA1NiAxNDAgNTZIMTU0QzE1NS4xMDUgNTYgMTU2IDU2Ljg5NTQgMTU2IDU4VjE0NkgxMzhWNThaIiBmaWxsPSIjM0ZBNzFBIi8+CjxwYXRoIGQ9Ik0yNSA0LjE2MTEzTDIwMCA0LjE2MTE2IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC4xMiIvPgo8cGF0aCBkPSJNMjUgMzMuMTYxMUwyMDAgMzMuMTYxMiIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiLz4KPHBhdGggZD0iTTI1IDYxLjE2MTFMMjAwIDYxLjE2MTIiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjEyIi8+CjxwYXRoIGQ9Ik0yNSA4OS4xNjExTDIwMCA4OS4xNjEyIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC4xMiIvPgo8cGF0aCBkPSJNMjUgMTE4LjE2MUwyMDAgMTE4LjE2MSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiLz4KPGxpbmUgeDE9IjIzLjIiIHkxPSIxNDUuOTYxIiB4Mj0iMjAyLjgiIHkyPSIxNDUuOTYxIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC43IiBzdHJva2Utd2lkdGg9IjAuNCIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8bGluZSB4MT0iNDAuNDUwMiIgeTE9IjE0OC4wNzIiIHgyPSI0MC40NTAyIiB5Mj0iMTQ3LjI1IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8cGF0aCBkPSJNMzIuNTA5MSAxNTIuMDI1VjE1Mi44OTNDMzIuNTA5MSAxNTMuMzU5IDMyLjQ2NzQgMTUzLjc1MiAzMi4zODQxIDE1NC4wNzJDMzIuMzAwNyAxNTQuMzkzIDMyLjE4MDkgMTU0LjY1IDMyLjAyNDcgMTU0Ljg0NkMzMS44Njg0IDE1NS4wNDEgMzEuNjc5NiAxNTUuMTgzIDMxLjQ1ODMgMTU1LjI3MUMzMS4yMzk1IDE1NS4zNTcgMzAuOTkyMSAxNTUuNCAzMC43MTYxIDE1NS40QzMwLjQ5NzMgMTU1LjQgMzAuMjk1NSAxNTUuMzczIDMwLjExMDYgMTU1LjMxOEMyOS45MjU3IDE1NS4yNjQgMjkuNzU5MSAxNTUuMTc2IDI5LjYxMDYgMTU1LjA1N0MyOS40NjQ4IDE1NC45MzQgMjkuMzM5OCAxNTQuNzc1IDI5LjIzNTYgMTU0LjU4QzI5LjEzMTUgMTU0LjM4NSAyOS4wNTIgMTU0LjE0OCAyOC45OTczIDE1My44NjlDMjguOTQyNyAxNTMuNTkgMjguOTE1MyAxNTMuMjY1IDI4LjkxNTMgMTUyLjg5M1YxNTIuMDI1QzI4LjkxNTMgMTUxLjU1OSAyOC45NTcgMTUxLjE2OSAyOS4wNDAzIDE1MC44NTRDMjkuMTI2MiAxNTAuNTM4IDI5LjI0NzMgMTUwLjI4NiAyOS40MDM2IDE1MC4wOTZDMjkuNTU5OCAxNDkuOTAzIDI5Ljc0NzMgMTQ5Ljc2NSAyOS45NjYxIDE0OS42ODJDMzAuMTg3NCAxNDkuNTk4IDMwLjQzNDggMTQ5LjU1NyAzMC43MDgzIDE0OS41NTdDMzAuOTI5NiAxNDkuNTU3IDMxLjEzMjggMTQ5LjU4NCAzMS4zMTc3IDE0OS42MzlDMzEuNTA1MiAxNDkuNjkxIDMxLjY3MTggMTQ5Ljc3NSAzMS44MTc3IDE0OS44OTNDMzEuOTYzNSAxNTAuMDA3IDMyLjA4NzIgMTUwLjE2MSAzMi4xODg3IDE1MC4zNTRDMzIuMjkyOSAxNTAuNTQ0IDMyLjM3MjMgMTUwLjc3NyAzMi40MjcgMTUxLjA1M0MzMi40ODE3IDE1MS4zMjkgMzIuNTA5MSAxNTEuNjUzIDMyLjUwOTEgMTUyLjAyNVpNMzEuNzgyNSAxNTMuMDFWMTUxLjkwNEMzMS43ODI1IDE1MS42NDkgMzEuNzY2OSAxNTEuNDI1IDMxLjczNTYgMTUxLjIzMkMzMS43MDcgMTUxLjAzNyAzMS42NjQgMTUwLjg3IDMxLjYwNjcgMTUwLjczMkMzMS41NDk0IDE1MC41OTQgMzEuNDc2NSAxNTAuNDgyIDMxLjM4OCAxNTAuMzk2QzMxLjMwMiAxNTAuMzExIDMxLjIwMTggMTUwLjI0OCAzMS4wODcyIDE1MC4yMDlDMzAuOTc1MiAxNTAuMTY3IDMwLjg0ODkgMTUwLjE0NiAzMC43MDgzIDE1MC4xNDZDMzAuNTM2NCAxNTAuMTQ2IDMwLjM4NDEgMTUwLjE3OSAzMC4yNTEyIDE1MC4yNDRDMzAuMTE4NCAxNTAuMzA3IDMwLjAwNjUgMTUwLjQwNyAyOS45MTUzIDE1MC41NDVDMjkuODI2OCAxNTAuNjgzIDI5Ljc1OTEgMTUwLjg2NCAyOS43MTIyIDE1MS4wODhDMjkuNjY1MyAxNTEuMzEyIDI5LjY0MTkgMTUxLjU4NCAyOS42NDE5IDE1MS45MDRWMTUzLjAxQzI5LjY0MTkgMTUzLjI2NSAyOS42NTYyIDE1My40OSAyOS42ODQ4IDE1My42ODZDMjkuNzE2MSAxNTMuODgxIDI5Ljc2MTcgMTU0LjA1IDI5LjgyMTYgMTU0LjE5M0MyOS44ODE1IDE1NC4zMzQgMjkuOTU0NCAxNTQuNDUgMzAuMDQwMyAxNTQuNTQxQzMwLjEyNjIgMTU0LjYzMiAzMC4yMjUyIDE1NC43IDMwLjMzNzIgMTU0Ljc0NEMzMC40NTE4IDE1NC43ODYgMzAuNTc4MSAxNTQuODA3IDMwLjcxNjEgMTU0LjgwN0MzMC44OTMyIDE1NC44MDcgMzEuMDQ4MSAxNTQuNzczIDMxLjE4MDkgMTU0LjcwNUMzMS4zMTM3IDE1NC42MzcgMzEuNDI0NCAxNTQuNTMyIDMxLjUxMyAxNTQuMzg5QzMxLjYwNDEgMTU0LjI0MyAzMS42NzE4IDE1NC4wNTcgMzEuNzE2MSAxNTMuODNDMzEuNzYwNCAxNTMuNjAxIDMxLjc4MjUgMTUzLjMyNyAzMS43ODI1IDE1My4wMVpNMzUuODk2NCAxNDkuNjA0VjE1NS4zMjJIMzUuMTczN1YxNTAuNTA2TDMzLjcxNjcgMTUxLjAzN1YxNTAuMzg1TDM1Ljc4MzEgMTQ5LjYwNEgzNS44OTY0Wk00MS4xMTI0IDE0OS42MzVWMTU1LjMyMkg0MC4zNTg1VjE0OS42MzVINDEuMTEyNFpNNDMuNDk1MiAxNTIuMTkzVjE1Mi44MTFINDAuOTQ4M1YxNTIuMTkzSDQzLjQ5NTJaTTQzLjg4MTkgMTQ5LjYzNVYxNTAuMjUySDQwLjk0ODNWMTQ5LjYzNUg0My44ODE5Wk00Ni40MjE2IDE1NS40QzQ2LjEyNzMgMTU1LjQgNDUuODYwNCAxNTUuMzUxIDQ1LjYyMDggMTU1LjI1MkM0NS4zODM4IDE1NS4xNSA0NS4xNzk0IDE1NS4wMDggNDUuMDA3NSAxNTQuODI2QzQ0LjgzODMgMTU0LjY0NCA0NC43MDgxIDE1NC40MjggNDQuNjE2OSAxNTQuMTc4QzQ0LjUyNTggMTUzLjkyOCA0NC40ODAyIDE1My42NTQgNDQuNDgwMiAxNTMuMzU3VjE1My4xOTNDNDQuNDgwMiAxNTIuODUgNDQuNTMxIDE1Mi41NDQgNDQuNjMyNSAxNTIuMjc1QzQ0LjczNDEgMTUyLjAwNSA0NC44NzIxIDE1MS43NzUgNDUuMDQ2NiAxNTEuNTg4QzQ1LjIyMTEgMTUxLjQgNDUuNDE5IDE1MS4yNTggNDUuNjQwMyAxNTEuMTYyQzQ1Ljg2MTcgMTUxLjA2NiA0Ni4wOTA5IDE1MS4wMTggNDYuMzI3OCAxNTEuMDE4QzQ2LjYyOTkgMTUxLjAxOCA0Ni44OTAzIDE1MS4wNyA0Ny4xMDkxIDE1MS4xNzRDNDcuMzMwNSAxNTEuMjc4IDQ3LjUxMTQgMTUxLjQyNCA0Ny42NTIxIDE1MS42MTFDNDcuNzkyNyAxNTEuNzk2IDQ3Ljg5NjkgMTUyLjAxNSA0Ny45NjQ2IDE1Mi4yNjhDNDguMDMyMyAxNTIuNTE4IDQ4LjA2NjEgMTUyLjc5MSA0OC4wNjYxIDE1My4wODhWMTUzLjQxMkg0NC45MDk5VjE1Mi44MjJINDcuMzQzNVYxNTIuNzY4QzQ3LjMzMzEgMTUyLjU4IDQ3LjI5NCAxNTIuMzk4IDQ3LjIyNjMgMTUyLjIyMUM0Ny4xNjEyIDE1Mi4wNDQgNDcuMDU3IDE1MS44OTggNDYuOTEzOCAxNTEuNzgzQzQ2Ljc3MDYgMTUxLjY2OSA0Ni41NzUyIDE1MS42MTEgNDYuMzI3OCAxNTEuNjExQzQ2LjE2MzggMTUxLjYxMSA0Ni4wMTI3IDE1MS42NDYgNDUuODc0NyAxNTEuNzE3QzQ1LjczNjcgMTUxLjc4NSA0NS42MTgyIDE1MS44ODYgNDUuNTE5MyAxNTIuMDIxQzQ1LjQyMDMgMTUyLjE1NyA0NS4zNDM1IDE1Mi4zMjIgNDUuMjg4OCAxNTIuNTE4QzQ1LjIzNDEgMTUyLjcxMyA0NS4yMDY4IDE1Mi45MzggNDUuMjA2OCAxNTMuMTkzVjE1My4zNTdDNDUuMjA2OCAxNTMuNTU4IDQ1LjIzNDEgMTUzLjc0NyA0NS4yODg4IDE1My45MjRDNDUuMzQ2MSAxNTQuMDk4IDQ1LjQyODEgMTU0LjI1MiA0NS41MzQ5IDE1NC4zODVDNDUuNjQ0MyAxNTQuNTE4IDQ1Ljc3NTggMTU0LjYyMiA0NS45Mjk0IDE1NC42OTdDNDYuMDg1NyAxNTQuNzczIDQ2LjI2MjcgMTU0LjgxMSA0Ni40NjA3IDE1NC44MTFDNDYuNzE1OSAxNTQuODExIDQ2LjkzMiAxNTQuNzU4IDQ3LjEwOTEgMTU0LjY1NEM0Ny4yODYyIDE1NC41NSA0Ny40NDExIDE1NC40MTEgNDcuNTczOSAxNTQuMjM2TDQ4LjAxMTQgMTU0LjU4NEM0Ny45MjAzIDE1NC43MjIgNDcuODA0NCAxNTQuODU0IDQ3LjY2MzggMTU0Ljk3OUM0Ny41MjMyIDE1NS4xMDQgNDcuMzUgMTU1LjIwNSA0Ny4xNDQzIDE1NS4yODNDNDYuOTQxMSAxNTUuMzYxIDQ2LjcwMDIgMTU1LjQgNDYuNDIxNiAxNTUuNFpNNDguOTg4NiAxNDkuMzIySDQ5LjcxNTJWMTU0LjUwMkw0OS42NTI3IDE1NS4zMjJINDguOTg4NlYxNDkuMzIyWk01Mi41NzA2IDE1My4xNzRWMTUzLjI1NkM1Mi41NzA2IDE1My41NjMgNTIuNTM0MiAxNTMuODQ4IDUyLjQ2MTMgMTU0LjExMUM1Mi4zODgzIDE1NC4zNzIgNTIuMjgxNiAxNTQuNTk4IDUyLjE0MDkgMTU0Ljc5MUM1Mi4wMDAzIDE1NC45ODQgNTEuODI4NCAxNTUuMTMzIDUxLjYyNTMgMTU1LjI0QzUxLjQyMjIgMTU1LjM0NyA1MS4xODkxIDE1NS40IDUwLjkyNjEgMTU1LjRDNTAuNjU3OSAxNTUuNCA1MC40MjIyIDE1NS4zNTUgNTAuMjE5MSAxNTUuMjY0QzUwLjAxODUgMTU1LjE3IDQ5Ljg0OTMgMTU1LjAzNiA0OS43MTEzIDE1NC44NjFDNDkuNTczMiAxNTQuNjg3IDQ5LjQ2MjYgMTU0LjQ3NiA0OS4zNzkyIDE1NC4yMjlDNDkuMjk4NSAxNTMuOTgxIDQ5LjI0MjUgMTUzLjcwMiA0OS4yMTEzIDE1My4zOTNWMTUzLjAzM0M0OS4yNDI1IDE1Mi43MjEgNDkuMjk4NSAxNTIuNDQxIDQ5LjM3OTIgMTUyLjE5M0M0OS40NjI2IDE1MS45NDYgNDkuNTczMiAxNTEuNzM1IDQ5LjcxMTMgMTUxLjU2MUM0OS44NDkzIDE1MS4zODMgNTAuMDE4NSAxNTEuMjQ5IDUwLjIxOTEgMTUxLjE1OEM1MC40MTk2IDE1MS4wNjQgNTAuNjUyNyAxNTEuMDE4IDUwLjkxODMgMTUxLjAxOEM1MS4xODM5IDE1MS4wMTggNTEuNDE5NiAxNTEuMDcgNTEuNjI1MyAxNTEuMTc0QzUxLjgzMSAxNTEuMjc1IDUyLjAwMjkgMTUxLjQyMSA1Mi4xNDA5IDE1MS42MTFDNTIuMjgxNiAxNTEuODAxIDUyLjM4ODMgMTUyLjAyOSA1Mi40NjEzIDE1Mi4yOTVDNTIuNTM0MiAxNTIuNTU4IDUyLjU3MDYgMTUyLjg1MSA1Mi41NzA2IDE1My4xNzRaTTUxLjg0NDEgMTUzLjI1NlYxNTMuMTc0QzUxLjg0NDEgMTUyLjk2MyA1MS44MjQ1IDE1Mi43NjUgNTEuNzg1NSAxNTIuNThDNTEuNzQ2NCAxNTIuMzkzIDUxLjY4MzkgMTUyLjIyOSA1MS41OTggMTUyLjA4OEM1MS41MTIgMTUxLjk0NSA1MS4zOTg4IDE1MS44MzMgNTEuMjU4MSAxNTEuNzUyQzUxLjExNzUgMTUxLjY2OSA1MC45NDQzIDE1MS42MjcgNTAuNzM4NiAxNTEuNjI3QzUwLjU1NjMgMTUxLjYyNyA1MC4zOTc1IDE1MS42NTggNTAuMjYyIDE1MS43MjFDNTAuMTI5MiAxNTEuNzgzIDUwLjAxNTkgMTUxLjg2OCA0OS45MjIyIDE1MS45NzVDNDkuODI4NCAxNTIuMDc5IDQ5Ljc1MTYgMTUyLjE5OSA0OS42OTE3IDE1Mi4zMzRDNDkuNjM0NCAxNTIuNDY3IDQ5LjU5MTUgMTUyLjYwNSA0OS41NjI4IDE1Mi43NDhWMTUzLjY4OUM0OS42MDQ1IDE1My44NzIgNDkuNjcyMiAxNTQuMDQ4IDQ5Ljc2NTkgMTU0LjIxN0M0OS44NjIzIDE1NC4zODMgNDkuOTg5OSAxNTQuNTIgNTAuMTQ4OCAxNTQuNjI3QzUwLjMxMDIgMTU0LjczNCA1MC41MDk0IDE1NC43ODcgNTAuNzQ2NCAxNTQuNzg3QzUwLjk0MTcgMTU0Ljc4NyA1MS4xMDg0IDE1NC43NDggNTEuMjQ2NCAxNTQuNjdDNTEuMzg3IDE1NC41ODkgNTEuNTAwMyAxNTQuNDc5IDUxLjU4NjMgMTU0LjMzOEM1MS42NzQ4IDE1NC4xOTcgNTEuNzM5OSAxNTQuMDM1IDUxLjc4MTYgMTUzLjg1QzUxLjgyMzIgMTUzLjY2NSA1MS44NDQxIDE1My40NjcgNTEuODQ0MSAxNTMuMjU2WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC41NCIvPgo8ZyBjbGlwLXBhdGg9InVybCgjY2xpcDFfNDA1M18xODUxMTMpIj4KPGxpbmUgeDE9Ijc1Ljg1MDEiIHkxPSIxNDcuMDcyIiB4Mj0iNzUuODUwMSIgeTI9IjE0Ni4yNSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuNSIgc3Ryb2tlLXdpZHRoPSIwLjUiIHN0cm9rZS1saW5lY2FwPSJzcXVhcmUiLz4KPHBhdGggZD0iTTY3LjkwOSAxNTIuMDI1VjE1Mi44OTNDNjcuOTA5IDE1My4zNTkgNjcuODY3MyAxNTMuNzUyIDY3Ljc4NCAxNTQuMDcyQzY3LjcwMDYgMTU0LjM5MyA2Ny41ODA4IDE1NC42NSA2Ny40MjQ2IDE1NC44NDZDNjcuMjY4MyAxNTUuMDQxIDY3LjA3OTUgMTU1LjE4MyA2Ni44NTgyIDE1NS4yNzFDNjYuNjM5NCAxNTUuMzU3IDY2LjM5MiAxNTUuNCA2Ni4xMTYgMTU1LjRDNjUuODk3MiAxNTUuNCA2NS42OTU0IDE1NS4zNzMgNjUuNTEwNSAxNTUuMzE4QzY1LjMyNTYgMTU1LjI2NCA2NS4xNTkgMTU1LjE3NiA2NS4wMTA1IDE1NS4wNTdDNjQuODY0NyAxNTQuOTM0IDY0LjczOTcgMTU0Ljc3NSA2NC42MzU1IDE1NC41OEM2NC41MzE0IDE1NC4zODUgNjQuNDUxOSAxNTQuMTQ4IDY0LjM5NzIgMTUzLjg2OUM2NC4zNDI2IDE1My41OSA2NC4zMTUyIDE1My4yNjUgNjQuMzE1MiAxNTIuODkzVjE1Mi4wMjVDNjQuMzE1MiAxNTEuNTU5IDY0LjM1NjkgMTUxLjE2OSA2NC40NDAyIDE1MC44NTRDNjQuNTI2MSAxNTAuNTM4IDY0LjY0NzIgMTUwLjI4NiA2NC44MDM1IDE1MC4wOTZDNjQuOTU5NyAxNDkuOTAzIDY1LjE0NzIgMTQ5Ljc2NSA2NS4zNjYgMTQ5LjY4MkM2NS41ODczIDE0OS41OTggNjUuODM0NyAxNDkuNTU3IDY2LjEwODIgMTQ5LjU1N0M2Ni4zMjk1IDE0OS41NTcgNjYuNTMyNyAxNDkuNTg0IDY2LjcxNzYgMTQ5LjYzOUM2Ni45MDUxIDE0OS42OTEgNjcuMDcxNyAxNDkuNzc1IDY3LjIxNzYgMTQ5Ljg5M0M2Ny4zNjM0IDE1MC4wMDcgNjcuNDg3MSAxNTAuMTYxIDY3LjU4ODYgMTUwLjM1NEM2Ny42OTI4IDE1MC41NDQgNjcuNzcyMiAxNTAuNzc3IDY3LjgyNjkgMTUxLjA1M0M2Ny44ODE2IDE1MS4zMjkgNjcuOTA5IDE1MS42NTMgNjcuOTA5IDE1Mi4wMjVaTTY3LjE4MjQgMTUzLjAxVjE1MS45MDRDNjcuMTgyNCAxNTEuNjQ5IDY3LjE2NjggMTUxLjQyNSA2Ny4xMzU1IDE1MS4yMzJDNjcuMTA2OSAxNTEuMDM3IDY3LjA2MzkgMTUwLjg3IDY3LjAwNjYgMTUwLjczMkM2Ni45NDkzIDE1MC41OTQgNjYuODc2NCAxNTAuNDgyIDY2Ljc4NzkgMTUwLjM5NkM2Ni43MDE5IDE1MC4zMTEgNjYuNjAxNyAxNTAuMjQ4IDY2LjQ4NzEgMTUwLjIwOUM2Ni4zNzUxIDE1MC4xNjcgNjYuMjQ4OCAxNTAuMTQ2IDY2LjEwODIgMTUwLjE0NkM2NS45MzYzIDE1MC4xNDYgNjUuNzg0IDE1MC4xNzkgNjUuNjUxMSAxNTAuMjQ0QzY1LjUxODMgMTUwLjMwNyA2NS40MDY0IDE1MC40MDcgNjUuMzE1MiAxNTAuNTQ1QzY1LjIyNjcgMTUwLjY4MyA2NS4xNTkgMTUwLjg2NCA2NS4xMTIxIDE1MS4wODhDNjUuMDY1MiAxNTEuMzEyIDY1LjA0MTggMTUxLjU4NCA2NS4wNDE4IDE1MS45MDRWMTUzLjAxQzY1LjA0MTggMTUzLjI2NSA2NS4wNTYxIDE1My40OSA2NS4wODQ3IDE1My42ODZDNjUuMTE2IDE1My44ODEgNjUuMTYxNiAxNTQuMDUgNjUuMjIxNSAxNTQuMTkzQzY1LjI4MTQgMTU0LjMzNCA2NS4zNTQzIDE1NC40NSA2NS40NDAyIDE1NC41NDFDNjUuNTI2MSAxNTQuNjMyIDY1LjYyNTEgMTU0LjcgNjUuNzM3MSAxNTQuNzQ0QzY1Ljg1MTcgMTU0Ljc4NiA2NS45NzggMTU0LjgwNyA2Ni4xMTYgMTU0LjgwN0M2Ni4yOTMxIDE1NC44MDcgNjYuNDQ4IDE1NC43NzMgNjYuNTgwOCAxNTQuNzA1QzY2LjcxMzYgMTU0LjYzNyA2Ni44MjQzIDE1NC41MzIgNjYuOTEyOSAxNTQuMzg5QzY3LjAwNCAxNTQuMjQzIDY3LjA3MTcgMTU0LjA1NyA2Ny4xMTYgMTUzLjgzQzY3LjE2MDMgMTUzLjYwMSA2Ny4xODI0IDE1My4zMjcgNjcuMTgyNCAxNTMuMDFaTTcyLjY0NzggMTU0LjcyOVYxNTUuMzIySDY4LjkyNTJWMTU0LjgwM0w3MC43ODg1IDE1Mi43MjlDNzEuMDE3NiAxNTIuNDczIDcxLjE5NDcgMTUyLjI1NyA3MS4zMTk3IDE1Mi4wOEM3MS40NDczIDE1MS45IDcxLjUzNTkgMTUxLjc0IDcxLjU4NTMgMTUxLjZDNzEuNjM3NCAxNTEuNDU2IDcxLjY2MzUgMTUxLjMxMSA3MS42NjM1IDE1MS4xNjJDNzEuNjYzNSAxNTAuOTc1IDcxLjYyNDQgMTUwLjgwNSA3MS41NDYzIDE1MC42NTRDNzEuNDcwOCAxNTAuNTAxIDcxLjM1ODggMTUwLjM3OCA3MS4yMTAzIDE1MC4yODdDNzEuMDYxOSAxNTAuMTk2IDcwLjg4MjIgMTUwLjE1IDcwLjY3MTMgMTUwLjE1QzcwLjQxODcgMTUwLjE1IDcwLjIwNzcgMTUwLjIgNzAuMDM4NSAxNTAuMjk5QzY5Ljg3MTggMTUwLjM5NSA2OS43NDY4IDE1MC41MzEgNjkuNjYzNSAxNTAuNzA1QzY5LjU4MDEgMTUwLjg4IDY5LjUzODUgMTUxLjA4IDY5LjUzODUgMTUxLjMwN0g2OC44MTU4QzY4LjgxNTggMTUwLjk4NiA2OC44ODYxIDE1MC42OTMgNjkuMDI2NyAxNTAuNDI4QzY5LjE2NzQgMTUwLjE2MiA2OS4zNzU3IDE0OS45NTEgNjkuNjUxNyAxNDkuNzk1QzY5LjkyNzggMTQ5LjYzNiA3MC4yNjc2IDE0OS41NTcgNzAuNjcxMyAxNDkuNTU3QzcxLjAzMDcgMTQ5LjU1NyA3MS4zMzc5IDE0OS42MiA3MS41OTMyIDE0OS43NDhDNzEuODQ4NCAxNDkuODczIDcyLjA0MzcgMTUwLjA1IDcyLjE3OTEgMTUwLjI3OUM3Mi4zMTcxIDE1MC41MDYgNzIuMzg2MSAxNTAuNzcxIDcyLjM4NjEgMTUxLjA3NkM3Mi4zODYxIDE1MS4yNDMgNzIuMzU3NSAxNTEuNDEyIDcyLjMwMDIgMTUxLjU4NEM3Mi4yNDU1IDE1MS43NTMgNzIuMTY4NyAxNTEuOTIzIDcyLjA2OTcgMTUyLjA5MkM3MS45NzM0IDE1Mi4yNjEgNzEuODYwMSAxNTIuNDI4IDcxLjcyOTkgMTUyLjU5MkM3MS42MDIzIDE1Mi43NTYgNzEuNDY1NSAxNTIuOTE3IDcxLjMxOTcgMTUzLjA3Nkw2OS43OTYzIDE1NC43MjlINzIuNjQ3OFpNNzYuNTEyMyAxNDkuNjM1VjE1NS4zMjJINzUuNzU4NFYxNDkuNjM1SDc2LjUxMjNaTTc4Ljg5NTEgMTUyLjE5M1YxNTIuODExSDc2LjM0ODJWMTUyLjE5M0g3OC44OTUxWk03OS4yODE4IDE0OS42MzVWMTUwLjI1Mkg3Ni4zNDgyVjE0OS42MzVINzkuMjgxOFpNODEuODIxNSAxNTUuNEM4MS41MjcyIDE1NS40IDgxLjI2MDMgMTU1LjM1MSA4MS4wMjA3IDE1NS4yNTJDODAuNzgzNyAxNTUuMTUgODAuNTc5MyAxNTUuMDA4IDgwLjQwNzQgMTU0LjgyNkM4MC4yMzgyIDE1NC42NDQgODAuMTA4IDE1NC40MjggODAuMDE2OCAxNTQuMTc4Qzc5LjkyNTcgMTUzLjkyOCA3OS44ODAxIDE1My42NTQgNzkuODgwMSAxNTMuMzU3VjE1My4xOTNDNzkuODgwMSAxNTIuODUgNzkuOTMwOSAxNTIuNTQ0IDgwLjAzMjQgMTUyLjI3NUM4MC4xMzQgMTUyLjAwNSA4MC4yNzIgMTUxLjc3NSA4MC40NDY1IDE1MS41ODhDODAuNjIxIDE1MS40IDgwLjgxODkgMTUxLjI1OCA4MS4wNDAyIDE1MS4xNjJDODEuMjYxNiAxNTEuMDY2IDgxLjQ5MDggMTUxLjAxOCA4MS43Mjc3IDE1MS4wMThDODIuMDI5OCAxNTEuMDE4IDgyLjI5MDIgMTUxLjA3IDgyLjUwOSAxNTEuMTc0QzgyLjczMDQgMTUxLjI3OCA4Mi45MTEzIDE1MS40MjQgODMuMDUyIDE1MS42MTFDODMuMTkyNiAxNTEuNzk2IDgzLjI5NjggMTUyLjAxNSA4My4zNjQ1IDE1Mi4yNjhDODMuNDMyMiAxNTIuNTE4IDgzLjQ2NiAxNTIuNzkxIDgzLjQ2NiAxNTMuMDg4VjE1My40MTJIODAuMzA5OFYxNTIuODIySDgyLjc0MzRWMTUyLjc2OEM4Mi43MzMgMTUyLjU4IDgyLjY5MzkgMTUyLjM5OCA4Mi42MjYyIDE1Mi4yMjFDODIuNTYxMSAxNTIuMDQ0IDgyLjQ1NjkgMTUxLjg5OCA4Mi4zMTM3IDE1MS43ODNDODIuMTcwNSAxNTEuNjY5IDgxLjk3NTEgMTUxLjYxMSA4MS43Mjc3IDE1MS42MTFDODEuNTYzNyAxNTEuNjExIDgxLjQxMjYgMTUxLjY0NiA4MS4yNzQ2IDE1MS43MTdDODEuMTM2NiAxNTEuNzg1IDgxLjAxODEgMTUxLjg4NiA4MC45MTkyIDE1Mi4wMjFDODAuODIwMiAxNTIuMTU3IDgwLjc0MzQgMTUyLjMyMiA4MC42ODg3IDE1Mi41MThDODAuNjM0IDE1Mi43MTMgODAuNjA2NyAxNTIuOTM4IDgwLjYwNjcgMTUzLjE5M1YxNTMuMzU3QzgwLjYwNjcgMTUzLjU1OCA4MC42MzQgMTUzLjc0NyA4MC42ODg3IDE1My45MjRDODAuNzQ2IDE1NC4wOTggODAuODI4IDE1NC4yNTIgODAuOTM0OCAxNTQuMzg1QzgxLjA0NDIgMTU0LjUxOCA4MS4xNzU3IDE1NC42MjIgODEuMzI5MyAxNTQuNjk3QzgxLjQ4NTYgMTU0Ljc3MyA4MS42NjI2IDE1NC44MTEgODEuODYwNiAxNTQuODExQzgyLjExNTggMTU0LjgxMSA4Mi4zMzE5IDE1NC43NTggODIuNTA5IDE1NC42NTRDODIuNjg2MSAxNTQuNTUgODIuODQxIDE1NC40MTEgODIuOTczOCAxNTQuMjM2TDgzLjQxMTMgMTU0LjU4NEM4My4zMjAyIDE1NC43MjIgODMuMjA0MyAxNTQuODU0IDgzLjA2MzcgMTU0Ljk3OUM4Mi45MjMxIDE1NS4xMDQgODIuNzQ5OSAxNTUuMjA1IDgyLjU0NDIgMTU1LjI4M0M4Mi4zNDEgMTU1LjM2MSA4Mi4xMDAxIDE1NS40IDgxLjgyMTUgMTU1LjRaTTg0LjM4ODUgMTQ5LjMyMkg4NS4xMTUxVjE1NC41MDJMODUuMDUyNiAxNTUuMzIySDg0LjM4ODVWMTQ5LjMyMlpNODcuOTcwNSAxNTMuMTc0VjE1My4yNTZDODcuOTcwNSAxNTMuNTYzIDg3LjkzNDEgMTUzLjg0OCA4Ny44NjEyIDE1NC4xMTFDODcuNzg4MiAxNTQuMzcyIDg3LjY4MTUgMTU0LjU5OCA4Ny41NDA4IDE1NC43OTFDODcuNDAwMiAxNTQuOTg0IDg3LjIyODMgMTU1LjEzMyA4Ny4wMjUyIDE1NS4yNEM4Ni44MjIxIDE1NS4zNDcgODYuNTg5IDE1NS40IDg2LjMyNiAxNTUuNEM4Ni4wNTc4IDE1NS40IDg1LjgyMjEgMTU1LjM1NSA4NS42MTkgMTU1LjI2NEM4NS40MTg1IDE1NS4xNyA4NS4yNDkyIDE1NS4wMzYgODUuMTExMiAxNTQuODYxQzg0Ljk3MzEgMTU0LjY4NyA4NC44NjI1IDE1NC40NzYgODQuNzc5MSAxNTQuMjI5Qzg0LjY5ODQgMTUzLjk4MSA4NC42NDI0IDE1My43MDIgODQuNjExMiAxNTMuMzkzVjE1My4wMzNDODQuNjQyNCAxNTIuNzIxIDg0LjY5ODQgMTUyLjQ0MSA4NC43NzkxIDE1Mi4xOTNDODQuODYyNSAxNTEuOTQ2IDg0Ljk3MzEgMTUxLjczNSA4NS4xMTEyIDE1MS41NjFDODUuMjQ5MiAxNTEuMzgzIDg1LjQxODUgMTUxLjI0OSA4NS42MTkgMTUxLjE1OEM4NS44MTk1IDE1MS4wNjQgODYuMDUyNiAxNTEuMDE4IDg2LjMxODIgMTUxLjAxOEM4Ni41ODM4IDE1MS4wMTggODYuODE5NSAxNTEuMDcgODcuMDI1MiAxNTEuMTc0Qzg3LjIzMSAxNTEuMjc1IDg3LjQwMjggMTUxLjQyMSA4Ny41NDA4IDE1MS42MTFDODcuNjgxNSAxNTEuODAxIDg3Ljc4ODIgMTUyLjAyOSA4Ny44NjEyIDE1Mi4yOTVDODcuOTM0MSAxNTIuNTU4IDg3Ljk3MDUgMTUyLjg1MSA4Ny45NzA1IDE1My4xNzRaTTg3LjI0NCAxNTMuMjU2VjE1My4xNzRDODcuMjQ0IDE1Mi45NjMgODcuMjI0NCAxNTIuNzY1IDg3LjE4NTQgMTUyLjU4Qzg3LjE0NjMgMTUyLjM5MyA4Ny4wODM4IDE1Mi4yMjkgODYuOTk3OSAxNTIuMDg4Qzg2LjkxMTkgMTUxLjk0NSA4Ni43OTg3IDE1MS44MzMgODYuNjU4IDE1MS43NTJDODYuNTE3NCAxNTEuNjY5IDg2LjM0NDIgMTUxLjYyNyA4Ni4xMzg1IDE1MS42MjdDODUuOTU2MiAxNTEuNjI3IDg1Ljc5NzQgMTUxLjY1OCA4NS42NjE5IDE1MS43MjFDODUuNTI5MSAxNTEuNzgzIDg1LjQxNTggMTUxLjg2OCA4NS4zMjIxIDE1MS45NzVDODUuMjI4MyAxNTIuMDc5IDg1LjE1MTUgMTUyLjE5OSA4NS4wOTE2IDE1Mi4zMzRDODUuMDM0MyAxNTIuNDY3IDg0Ljk5MTQgMTUyLjYwNSA4NC45NjI3IDE1Mi43NDhWMTUzLjY4OUM4NS4wMDQ0IDE1My44NzIgODUuMDcyMSAxNTQuMDQ4IDg1LjE2NTggMTU0LjIxN0M4NS4yNjIyIDE1NC4zODMgODUuMzg5OCAxNTQuNTIgODUuNTQ4NyAxNTQuNjI3Qzg1LjcxMDEgMTU0LjczNCA4NS45MDkzIDE1NC43ODcgODYuMTQ2MyAxNTQuNzg3Qzg2LjM0MTYgMTU0Ljc4NyA4Ni41MDgzIDE1NC43NDggODYuNjQ2MyAxNTQuNjdDODYuNzg2OSAxNTQuNTg5IDg2LjkwMDIgMTU0LjQ3OSA4Ni45ODYyIDE1NC4zMzhDODcuMDc0NyAxNTQuMTk3IDg3LjEzOTggMTU0LjAzNSA4Ny4xODE1IDE1My44NUM4Ny4yMjMxIDE1My42NjUgODcuMjQ0IDE1My40NjcgODcuMjQ0IDE1My4yNTZaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjU0Ii8+CjwvZz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAyXzQwNTNfMTg1MTEzKSI+CjxsaW5lIHgxPSIxMTEuMjUiIHkxPSIxNDcuMDcyIiB4Mj0iMTExLjI1IiB5Mj0iMTQ2LjI1IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8cGF0aCBkPSJNMTAzLjMwOSAxNTIuMDI1VjE1Mi44OTNDMTAzLjMwOSAxNTMuMzU5IDEwMy4yNjcgMTUzLjc1MiAxMDMuMTg0IDE1NC4wNzJDMTAzLjEwMSAxNTQuMzkzIDEwMi45ODEgMTU0LjY1IDEwMi44MjQgMTU0Ljg0NkMxMDIuNjY4IDE1NS4wNDEgMTAyLjQ3OSAxNTUuMTgzIDEwMi4yNTggMTU1LjI3MUMxMDIuMDM5IDE1NS4zNTcgMTAxLjc5MiAxNTUuNCAxMDEuNTE2IDE1NS40QzEwMS4yOTcgMTU1LjQgMTAxLjA5NSAxNTUuMzczIDEwMC45MSAxNTUuMzE4QzEwMC43MjYgMTU1LjI2NCAxMDAuNTU5IDE1NS4xNzYgMTAwLjQxIDE1NS4wNTdDMTAwLjI2NSAxNTQuOTM0IDEwMC4xNCAxNTQuNzc1IDEwMC4wMzUgMTU0LjU4Qzk5LjkzMTMgMTU0LjM4NSA5OS44NTE4IDE1NC4xNDggOTkuNzk3MSAxNTMuODY5Qzk5Ljc0MjUgMTUzLjU5IDk5LjcxNTEgMTUzLjI2NSA5OS43MTUxIDE1Mi44OTNWMTUyLjAyNUM5OS43MTUxIDE1MS41NTkgOTkuNzU2OCAxNTEuMTY5IDk5Ljg0MDEgMTUwLjg1NEM5OS45MjYxIDE1MC41MzggMTAwLjA0NyAxNTAuMjg2IDEwMC4yMDMgMTUwLjA5NkMxMDAuMzYgMTQ5LjkwMyAxMDAuNTQ3IDE0OS43NjUgMTAwLjc2NiAxNDkuNjgyQzEwMC45ODcgMTQ5LjU5OCAxMDEuMjM1IDE0OS41NTcgMTAxLjUwOCAxNDkuNTU3QzEwMS43MjkgMTQ5LjU1NyAxMDEuOTMzIDE0OS41ODQgMTAyLjExNyAxNDkuNjM5QzEwMi4zMDUgMTQ5LjY5MSAxMDIuNDcyIDE0OS43NzUgMTAyLjYxNyAxNDkuODkzQzEwMi43NjMgMTUwLjAwNyAxMDIuODg3IDE1MC4xNjEgMTAyLjk4OSAxNTAuMzU0QzEwMy4wOTMgMTUwLjU0NCAxMDMuMTcyIDE1MC43NzcgMTAzLjIyNyAxNTEuMDUzQzEwMy4yODIgMTUxLjMyOSAxMDMuMzA5IDE1MS42NTMgMTAzLjMwOSAxNTIuMDI1Wk0xMDIuNTgyIDE1My4wMVYxNTEuOTA0QzEwMi41ODIgMTUxLjY0OSAxMDIuNTY3IDE1MS40MjUgMTAyLjUzNSAxNTEuMjMyQzEwMi41MDcgMTUxLjAzNyAxMDIuNDY0IDE1MC44NyAxMDIuNDA3IDE1MC43MzJDMTAyLjM0OSAxNTAuNTk0IDEwMi4yNzYgMTUwLjQ4MiAxMDIuMTg4IDE1MC4zOTZDMTAyLjEwMiAxNTAuMzExIDEwMi4wMDIgMTUwLjI0OCAxMDEuODg3IDE1MC4yMDlDMTAxLjc3NSAxNTAuMTY3IDEwMS42NDkgMTUwLjE0NiAxMDEuNTA4IDE1MC4xNDZDMTAxLjMzNiAxNTAuMTQ2IDEwMS4xODQgMTUwLjE3OSAxMDEuMDUxIDE1MC4yNDRDMTAwLjkxOCAxNTAuMzA3IDEwMC44MDYgMTUwLjQwNyAxMDAuNzE1IDE1MC41NDVDMTAwLjYyNyAxNTAuNjgzIDEwMC41NTkgMTUwLjg2NCAxMDAuNTEyIDE1MS4wODhDMTAwLjQ2NSAxNTEuMzEyIDEwMC40NDIgMTUxLjU4NCAxMDAuNDQyIDE1MS45MDRWMTUzLjAxQzEwMC40NDIgMTUzLjI2NSAxMDAuNDU2IDE1My40OSAxMDAuNDg1IDE1My42ODZDMTAwLjUxNiAxNTMuODgxIDEwMC41NjEgMTU0LjA1IDEwMC42MjEgMTU0LjE5M0MxMDAuNjgxIDE1NC4zMzQgMTAwLjc1NCAxNTQuNDUgMTAwLjg0IDE1NC41NDFDMTAwLjkyNiAxNTQuNjMyIDEwMS4wMjUgMTU0LjcgMTAxLjEzNyAxNTQuNzQ0QzEwMS4yNTIgMTU0Ljc4NiAxMDEuMzc4IDE1NC44MDcgMTAxLjUxNiAxNTQuODA3QzEwMS42OTMgMTU0LjgwNyAxMDEuODQ4IDE1NC43NzMgMTAxLjk4MSAxNTQuNzA1QzEwMi4xMTQgMTU0LjYzNyAxMDIuMjI0IDE1NC41MzIgMTAyLjMxMyAxNTQuMzg5QzEwMi40MDQgMTU0LjI0MyAxMDIuNDcyIDE1NC4wNTcgMTAyLjUxNiAxNTMuODNDMTAyLjU2IDE1My42MDEgMTAyLjU4MiAxNTMuMzI3IDEwMi41ODIgMTUzLjAxWk0xMDUuMzc2IDE1Mi4xMjNIMTA1Ljg5MUMxMDYuMTQ0IDE1Mi4xMjMgMTA2LjM1MiAxNTIuMDgxIDEwNi41MTYgMTUxLjk5OEMxMDYuNjgzIDE1MS45MTIgMTA2LjgwNyAxNTEuNzk2IDEwNi44ODggMTUxLjY1QzEwNi45NzEgMTUxLjUwMiAxMDcuMDEzIDE1MS4zMzUgMTA3LjAxMyAxNTEuMTVDMTA3LjAxMyAxNTAuOTMyIDEwNi45NzYgMTUwLjc0OCAxMDYuOTAzIDE1MC42QzEwNi44MyAxNTAuNDUxIDEwNi43MjEgMTUwLjMzOSAxMDYuNTc1IDE1MC4yNjRDMTA2LjQyOSAxNTAuMTg4IDEwNi4yNDQgMTUwLjE1IDEwNi4wMiAxNTAuMTVDMTA1LjgxNyAxNTAuMTUgMTA1LjYzOCAxNTAuMTkxIDEwNS40ODEgMTUwLjI3MUMxMDUuMzI4IDE1MC4zNSAxMDUuMjA3IDE1MC40NjIgMTA1LjExOCAxNTAuNjA3QzEwNS4wMzIgMTUwLjc1MyAxMDQuOTg5IDE1MC45MjUgMTA0Ljk4OSAxNTEuMTIzSDEwNC4yNjZDMTA0LjI2NiAxNTAuODM0IDEwNC4zMzkgMTUwLjU3MSAxMDQuNDg1IDE1MC4zMzRDMTA0LjYzMSAxNTAuMDk3IDEwNC44MzYgMTQ5LjkwOCAxMDUuMDk5IDE0OS43NjhDMTA1LjM2NCAxNDkuNjI3IDEwNS42NzEgMTQ5LjU1NyAxMDYuMDIgMTQ5LjU1N0MxMDYuMzY0IDE0OS41NTcgMTA2LjY2NSAxNDkuNjE4IDEwNi45MjMgMTQ5Ljc0QzEwNy4xODEgMTQ5Ljg2IDEwNy4zODEgMTUwLjA0IDEwNy41MjQgMTUwLjI3OUMxMDcuNjY4IDE1MC41MTYgMTA3LjczOSAxNTAuODEyIDEwNy43MzkgMTUxLjE2NkMxMDcuNzM5IDE1MS4zMDkgMTA3LjcwNSAxNTEuNDYzIDEwNy42MzggMTUxLjYyN0MxMDcuNTcyIDE1MS43ODggMTA3LjQ3IDE1MS45MzkgMTA3LjMyOSAxNTIuMDhDMTA3LjE5MSAxNTIuMjIxIDEwNy4wMTEgMTUyLjMzNyAxMDYuNzkgMTUyLjQyOEMxMDYuNTY5IDE1Mi41MTYgMTA2LjMwMyAxNTIuNTYxIDEwNS45OTMgMTUyLjU2MUgxMDUuMzc2VjE1Mi4xMjNaTTEwNS4zNzYgMTUyLjcxN1YxNTIuMjgzSDEwNS45OTNDMTA2LjM1NSAxNTIuMjgzIDEwNi42NTUgMTUyLjMyNiAxMDYuODkxIDE1Mi40MTJDMTA3LjEyOCAxNTIuNDk4IDEwNy4zMTUgMTUyLjYxMyAxMDcuNDUgMTUyLjc1NkMxMDcuNTg4IDE1Mi44OTkgMTA3LjY4NCAxNTMuMDU3IDEwNy43MzkgMTUzLjIyOUMxMDcuNzk2IDE1My4zOTggMTA3LjgyNSAxNTMuNTY3IDEwNy44MjUgMTUzLjczNkMxMDcuODI1IDE1NC4wMDIgMTA3Ljc4IDE1NC4yMzggMTA3LjY4OCAxNTQuNDQzQzEwNy42IDE1NC42NDkgMTA3LjQ3NCAxNTQuODI0IDEwNy4zMDkgMTU0Ljk2N0MxMDcuMTQ4IDE1NS4xMSAxMDYuOTU4IDE1NS4yMTggMTA2LjczOSAxNTUuMjkxQzEwNi41MiAxNTUuMzY0IDEwNi4yODIgMTU1LjQgMTA2LjAyNCAxNTUuNEMxMDUuNzc3IDE1NS40IDEwNS41NDQgMTU1LjM2NSAxMDUuMzI1IDE1NS4yOTVDMTA1LjEwOSAxNTUuMjI1IDEwNC45MTggMTU1LjEyMyAxMDQuNzUxIDE1NC45OUMxMDQuNTg0IDE1NC44NTUgMTA0LjQ1NCAxNTQuNjg5IDEwNC4zNiAxNTQuNDk0QzEwNC4yNjYgMTU0LjI5NiAxMDQuMjIgMTU0LjA3MSAxMDQuMjIgMTUzLjgxOEgxMDQuOTQyQzEwNC45NDIgMTU0LjAxNiAxMDQuOTg1IDE1NC4xODkgMTA1LjA3MSAxNTQuMzM4QzEwNS4xNiAxNTQuNDg2IDEwNS4yODUgMTU0LjYwMiAxMDUuNDQ2IDE1NC42ODZDMTA1LjYxIDE1NC43NjYgMTA1LjgwMyAxNTQuODA3IDEwNi4wMjQgMTU0LjgwN0MxMDYuMjQ2IDE1NC44MDcgMTA2LjQzNiAxNTQuNzY5IDEwNi41OTUgMTU0LjY5M0MxMDYuNzU2IDE1NC42MTUgMTA2Ljg4IDE1NC40OTggMTA2Ljk2NiAxNTQuMzQyQzEwNy4wNTQgMTU0LjE4NiAxMDcuMDk5IDE1My45ODkgMTA3LjA5OSAxNTMuNzUyQzEwNy4wOTkgMTUzLjUxNSAxMDcuMDQ5IDE1My4zMjEgMTA2Ljk1IDE1My4xN0MxMDYuODUxIDE1My4wMTYgMTA2LjcxMSAxNTIuOTAzIDEwNi41MjggMTUyLjgzQzEwNi4zNDkgMTUyLjc1NSAxMDYuMTM2IDE1Mi43MTcgMTA1Ljg5MSAxNTIuNzE3SDEwNS4zNzZaTTExMS45MTIgMTQ5LjYzNVYxNTUuMzIySDExMS4xNThWMTQ5LjYzNUgxMTEuOTEyWk0xMTQuMjk1IDE1Mi4xOTNWMTUyLjgxMUgxMTEuNzQ4VjE1Mi4xOTNIMTE0LjI5NVpNMTE0LjY4MiAxNDkuNjM1VjE1MC4yNTJIMTExLjc0OFYxNDkuNjM1SDExNC42ODJaTTExNy4yMjEgMTU1LjRDMTE2LjkyNyAxNTUuNCAxMTYuNjYgMTU1LjM1MSAxMTYuNDIxIDE1NS4yNTJDMTE2LjE4NCAxNTUuMTUgMTE1Ljk3OSAxNTUuMDA4IDExNS44MDcgMTU0LjgyNkMxMTUuNjM4IDE1NC42NDQgMTE1LjUwOCAxNTQuNDI4IDExNS40MTcgMTU0LjE3OEMxMTUuMzI2IDE1My45MjggMTE1LjI4IDE1My42NTQgMTE1LjI4IDE1My4zNTdWMTUzLjE5M0MxMTUuMjggMTUyLjg1IDExNS4zMzEgMTUyLjU0NCAxMTUuNDMyIDE1Mi4yNzVDMTE1LjUzNCAxNTIuMDA1IDExNS42NzIgMTUxLjc3NSAxMTUuODQ2IDE1MS41ODhDMTE2LjAyMSAxNTEuNCAxMTYuMjE5IDE1MS4yNTggMTE2LjQ0IDE1MS4xNjJDMTE2LjY2MiAxNTEuMDY2IDExNi44OTEgMTUxLjAxOCAxMTcuMTI4IDE1MS4wMThDMTE3LjQzIDE1MS4wMTggMTE3LjY5IDE1MS4wNyAxMTcuOTA5IDE1MS4xNzRDMTE4LjEzIDE1MS4yNzggMTE4LjMxMSAxNTEuNDI0IDExOC40NTIgMTUxLjYxMUMxMTguNTkyIDE1MS43OTYgMTE4LjY5NyAxNTIuMDE1IDExOC43NjQgMTUyLjI2OEMxMTguODMyIDE1Mi41MTggMTE4Ljg2NiAxNTIuNzkxIDExOC44NjYgMTUzLjA4OFYxNTMuNDEySDExNS43MVYxNTIuODIySDExOC4xNDNWMTUyLjc2OEMxMTguMTMzIDE1Mi41OCAxMTguMDk0IDE1Mi4zOTggMTE4LjAyNiAxNTIuMjIxQzExNy45NjEgMTUyLjA0NCAxMTcuODU3IDE1MS44OTggMTE3LjcxNCAxNTEuNzgzQzExNy41NyAxNTEuNjY5IDExNy4zNzUgMTUxLjYxMSAxMTcuMTI4IDE1MS42MTFDMTE2Ljk2NCAxNTEuNjExIDExNi44MTMgMTUxLjY0NiAxMTYuNjc1IDE1MS43MTdDMTE2LjUzNyAxNTEuNzg1IDExNi40MTggMTUxLjg4NiAxMTYuMzE5IDE1Mi4wMjFDMTE2LjIyIDE1Mi4xNTcgMTE2LjE0MyAxNTIuMzIyIDExNi4wODkgMTUyLjUxOEMxMTYuMDM0IDE1Mi43MTMgMTE2LjAwNyAxNTIuOTM4IDExNi4wMDcgMTUzLjE5M1YxNTMuMzU3QzExNi4wMDcgMTUzLjU1OCAxMTYuMDM0IDE1My43NDcgMTE2LjA4OSAxNTMuOTI0QzExNi4xNDYgMTU0LjA5OCAxMTYuMjI4IDE1NC4yNTIgMTE2LjMzNSAxNTQuMzg1QzExNi40NDQgMTU0LjUxOCAxMTYuNTc2IDE1NC42MjIgMTE2LjcyOSAxNTQuNjk3QzExNi44ODUgMTU0Ljc3MyAxMTcuMDYzIDE1NC44MTEgMTE3LjI2IDE1NC44MTFDMTE3LjUxNiAxNTQuODExIDExNy43MzIgMTU0Ljc1OCAxMTcuOTA5IDE1NC42NTRDMTE4LjA4NiAxNTQuNTUgMTE4LjI0MSAxNTQuNDExIDExOC4zNzQgMTU0LjIzNkwxMTguODExIDE1NC41ODRDMTE4LjcyIDE1NC43MjIgMTE4LjYwNCAxNTQuODU0IDExOC40NjQgMTU0Ljk3OUMxMTguMzIzIDE1NS4xMDQgMTE4LjE1IDE1NS4yMDUgMTE3Ljk0NCAxNTUuMjgzQzExNy43NDEgMTU1LjM2MSAxMTcuNSAxNTUuNCAxMTcuMjIxIDE1NS40Wk0xMTkuNzg4IDE0OS4zMjJIMTIwLjUxNVYxNTQuNTAyTDEyMC40NTIgMTU1LjMyMkgxMTkuNzg4VjE0OS4zMjJaTTEyMy4zNyAxNTMuMTc0VjE1My4yNTZDMTIzLjM3IDE1My41NjMgMTIzLjMzNCAxNTMuODQ4IDEyMy4yNjEgMTU0LjExMUMxMjMuMTg4IDE1NC4zNzIgMTIzLjA4MSAxNTQuNTk4IDEyMi45NDEgMTU0Ljc5MUMxMjIuOCAxNTQuOTg0IDEyMi42MjggMTU1LjEzMyAxMjIuNDI1IDE1NS4yNEMxMjIuMjIyIDE1NS4zNDcgMTIxLjk4OSAxNTUuNCAxMjEuNzI2IDE1NS40QzEyMS40NTggMTU1LjQgMTIxLjIyMiAxNTUuMzU1IDEyMS4wMTkgMTU1LjI2NEMxMjAuODE4IDE1NS4xNyAxMjAuNjQ5IDE1NS4wMzYgMTIwLjUxMSAxNTQuODYxQzEyMC4zNzMgMTU0LjY4NyAxMjAuMjYyIDE1NC40NzYgMTIwLjE3OSAxNTQuMjI5QzEyMC4wOTggMTUzLjk4MSAxMjAuMDQyIDE1My43MDIgMTIwLjAxMSAxNTMuMzkzVjE1My4wMzNDMTIwLjA0MiAxNTIuNzIxIDEyMC4wOTggMTUyLjQ0MSAxMjAuMTc5IDE1Mi4xOTNDMTIwLjI2MiAxNTEuOTQ2IDEyMC4zNzMgMTUxLjczNSAxMjAuNTExIDE1MS41NjFDMTIwLjY0OSAxNTEuMzgzIDEyMC44MTggMTUxLjI0OSAxMjEuMDE5IDE1MS4xNThDMTIxLjIxOSAxNTEuMDY0IDEyMS40NTIgMTUxLjAxOCAxMjEuNzE4IDE1MS4wMThDMTIxLjk4NCAxNTEuMDE4IDEyMi4yMTkgMTUxLjA3IDEyMi40MjUgMTUxLjE3NEMxMjIuNjMxIDE1MS4yNzUgMTIyLjgwMyAxNTEuNDIxIDEyMi45NDEgMTUxLjYxMUMxMjMuMDgxIDE1MS44MDEgMTIzLjE4OCAxNTIuMDI5IDEyMy4yNjEgMTUyLjI5NUMxMjMuMzM0IDE1Mi41NTggMTIzLjM3IDE1Mi44NTEgMTIzLjM3IDE1My4xNzRaTTEyMi42NDQgMTUzLjI1NlYxNTMuMTc0QzEyMi42NDQgMTUyLjk2MyAxMjIuNjI0IDE1Mi43NjUgMTIyLjU4NSAxNTIuNThDMTIyLjU0NiAxNTIuMzkzIDEyMi40ODQgMTUyLjIyOSAxMjIuMzk4IDE1Mi4wODhDMTIyLjMxMiAxNTEuOTQ1IDEyMi4xOTkgMTUxLjgzMyAxMjIuMDU4IDE1MS43NTJDMTIxLjkxNyAxNTEuNjY5IDEyMS43NDQgMTUxLjYyNyAxMjEuNTM4IDE1MS42MjdDMTIxLjM1NiAxNTEuNjI3IDEyMS4xOTcgMTUxLjY1OCAxMjEuMDYyIDE1MS43MjFDMTIwLjkyOSAxNTEuNzgzIDEyMC44MTYgMTUxLjg2OCAxMjAuNzIyIDE1MS45NzVDMTIwLjYyOCAxNTIuMDc5IDEyMC41NTEgMTUyLjE5OSAxMjAuNDkyIDE1Mi4zMzRDMTIwLjQzNCAxNTIuNDY3IDEyMC4zOTEgMTUyLjYwNSAxMjAuMzYzIDE1Mi43NDhWMTUzLjY4OUMxMjAuNDA0IDE1My44NzIgMTIwLjQ3MiAxNTQuMDQ4IDEyMC41NjYgMTU0LjIxN0MxMjAuNjYyIDE1NC4zODMgMTIwLjc5IDE1NC41MiAxMjAuOTQ5IDE1NC42MjdDMTIxLjExIDE1NC43MzQgMTIxLjMwOSAxNTQuNzg3IDEyMS41NDYgMTU0Ljc4N0MxMjEuNzQyIDE1NC43ODcgMTIxLjkwOCAxNTQuNzQ4IDEyMi4wNDYgMTU0LjY3QzEyMi4xODcgMTU0LjU4OSAxMjIuMyAxNTQuNDc5IDEyMi4zODYgMTU0LjMzOEMxMjIuNDc1IDE1NC4xOTcgMTIyLjU0IDE1NC4wMzUgMTIyLjU4MSAxNTMuODVDMTIyLjYyMyAxNTMuNjY1IDEyMi42NDQgMTUzLjQ2NyAxMjIuNjQ0IDE1My4yNTZaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjU0Ii8+CjwvZz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAzXzQwNTNfMTg1MTEzKSI+CjxsaW5lIHgxPSIxNDYuNjUiIHkxPSIxNDcuMDcyIiB4Mj0iMTQ2LjY1IiB5Mj0iMTQ2LjI1IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8cGF0aCBkPSJNMTM4LjcwOSAxNTIuMDI1VjE1Mi44OTNDMTM4LjcwOSAxNTMuMzU5IDEzOC42NjggMTUzLjc1MiAxMzguNTg0IDE1NC4wNzJDMTM4LjUwMSAxNTQuMzkzIDEzOC4zODEgMTU0LjY1IDEzOC4yMjUgMTU0Ljg0NkMxMzguMDY5IDE1NS4wNDEgMTM3Ljg4IDE1NS4xODMgMTM3LjY1OCAxNTUuMjcxQzEzNy40NCAxNTUuMzU3IDEzNy4xOTIgMTU1LjQgMTM2LjkxNiAxNTUuNEMxMzYuNjk4IDE1NS40IDEzNi40OTYgMTU1LjM3MyAxMzYuMzExIDE1NS4zMThDMTM2LjEyNiAxNTUuMjY0IDEzNS45NTkgMTU1LjE3NiAxMzUuODExIDE1NS4wNTdDMTM1LjY2NSAxNTQuOTM0IDEzNS41NCAxNTQuNzc1IDEzNS40MzYgMTU0LjU4QzEzNS4zMzIgMTU0LjM4NSAxMzUuMjUyIDE1NC4xNDggMTM1LjE5OCAxNTMuODY5QzEzNS4xNDMgMTUzLjU5IDEzNS4xMTYgMTUzLjI2NSAxMzUuMTE2IDE1Mi44OTNWMTUyLjAyNUMxMzUuMTE2IDE1MS41NTkgMTM1LjE1NyAxNTEuMTY5IDEzNS4yNDEgMTUwLjg1NEMxMzUuMzI2IDE1MC41MzggMTM1LjQ0OCAxNTAuMjg2IDEzNS42MDQgMTUwLjA5NkMxMzUuNzYgMTQ5LjkwMyAxMzUuOTQ4IDE0OS43NjUgMTM2LjE2NiAxNDkuNjgyQzEzNi4zODggMTQ5LjU5OCAxMzYuNjM1IDE0OS41NTcgMTM2LjkwOCAxNDkuNTU3QzEzNy4xMyAxNDkuNTU3IDEzNy4zMzMgMTQ5LjU4NCAxMzcuNTE4IDE0OS42MzlDMTM3LjcwNSAxNDkuNjkxIDEzNy44NzIgMTQ5Ljc3NSAxMzguMDE4IDE0OS44OTNDMTM4LjE2NCAxNTAuMDA3IDEzOC4yODcgMTUwLjE2MSAxMzguMzg5IDE1MC4zNTRDMTM4LjQ5MyAxNTAuNTQ0IDEzOC41NzMgMTUwLjc3NyAxMzguNjI3IDE1MS4wNTNDMTM4LjY4MiAxNTEuMzI5IDEzOC43MDkgMTUxLjY1MyAxMzguNzA5IDE1Mi4wMjVaTTEzNy45ODMgMTUzLjAxVjE1MS45MDRDMTM3Ljk4MyAxNTEuNjQ5IDEzNy45NjcgMTUxLjQyNSAxMzcuOTM2IDE1MS4yMzJDMTM3LjkwNyAxNTEuMDM3IDEzNy44NjQgMTUwLjg3IDEzNy44MDcgMTUwLjczMkMxMzcuNzUgMTUwLjU5NCAxMzcuNjc3IDE1MC40ODIgMTM3LjU4OCAxNTAuMzk2QzEzNy41MDIgMTUwLjMxMSAxMzcuNDAyIDE1MC4yNDggMTM3LjI4NyAxNTAuMjA5QzEzNy4xNzUgMTUwLjE2NyAxMzcuMDQ5IDE1MC4xNDYgMTM2LjkwOCAxNTAuMTQ2QzEzNi43MzcgMTUwLjE0NiAxMzYuNTg0IDE1MC4xNzkgMTM2LjQ1MSAxNTAuMjQ0QzEzNi4zMTkgMTUwLjMwNyAxMzYuMjA3IDE1MC40MDcgMTM2LjExNiAxNTAuNTQ1QzEzNi4wMjcgMTUwLjY4MyAxMzUuOTU5IDE1MC44NjQgMTM1LjkxMiAxNTEuMDg4QzEzNS44NjYgMTUxLjMxMiAxMzUuODQyIDE1MS41ODQgMTM1Ljg0MiAxNTEuOTA0VjE1My4wMUMxMzUuODQyIDE1My4yNjUgMTM1Ljg1NiAxNTMuNDkgMTM1Ljg4NSAxNTMuNjg2QzEzNS45MTYgMTUzLjg4MSAxMzUuOTYyIDE1NC4wNSAxMzYuMDIyIDE1NC4xOTNDMTM2LjA4MiAxNTQuMzM0IDEzNi4xNTUgMTU0LjQ1IDEzNi4yNDEgMTU0LjU0MUMxMzYuMzI2IDE1NC42MzIgMTM2LjQyNSAxNTQuNyAxMzYuNTM3IDE1NC43NDRDMTM2LjY1MiAxNTQuNzg2IDEzNi43NzggMTU0LjgwNyAxMzYuOTE2IDE1NC44MDdDMTM3LjA5MyAxNTQuODA3IDEzNy4yNDggMTU0Ljc3MyAxMzcuMzgxIDE1NC43MDVDMTM3LjUxNCAxNTQuNjM3IDEzNy42MjUgMTU0LjUzMiAxMzcuNzEzIDE1NC4zODlDMTM3LjgwNCAxNTQuMjQzIDEzNy44NzIgMTU0LjA1NyAxMzcuOTE2IDE1My44M0MxMzcuOTYxIDE1My42MDEgMTM3Ljk4MyAxNTMuMzI3IDEzNy45ODMgMTUzLjAxWk0xNDMuNTY1IDE1My40MDhWMTU0LjAwMkgxMzkuNDU2VjE1My41NzZMMTQyLjAwMyAxNDkuNjM1SDE0Mi41OTNMMTQxLjk2IDE1MC43NzVMMTQwLjI3NiAxNTMuNDA4SDE0My41NjVaTTE0Mi43NzIgMTQ5LjYzNVYxNTUuMzIySDE0Mi4wNVYxNDkuNjM1SDE0Mi43NzJaTTE0Ny4zMTMgMTQ5LjYzNVYxNTUuMzIySDE0Ni41NTlWMTQ5LjYzNUgxNDcuMzEzWk0xNDkuNjk1IDE1Mi4xOTNWMTUyLjgxMUgxNDcuMTQ5VjE1Mi4xOTNIMTQ5LjY5NVpNMTUwLjA4MiAxNDkuNjM1VjE1MC4yNTJIMTQ3LjE0OVYxNDkuNjM1SDE1MC4wODJaTTE1Mi42MjIgMTU1LjRDMTUyLjMyOCAxNTUuNCAxNTIuMDYxIDE1NS4zNTEgMTUxLjgyMSAxNTUuMjUyQzE1MS41ODQgMTU1LjE1IDE1MS4zOCAxNTUuMDA4IDE1MS4yMDggMTU0LjgyNkMxNTEuMDM4IDE1NC42NDQgMTUwLjkwOCAxNTQuNDI4IDE1MC44MTcgMTU0LjE3OEMxNTAuNzI2IDE1My45MjggMTUwLjY4IDE1My42NTQgMTUwLjY4IDE1My4zNTdWMTUzLjE5M0MxNTAuNjggMTUyLjg1IDE1MC43MzEgMTUyLjU0NCAxNTAuODMzIDE1Mi4yNzVDMTUwLjkzNCAxNTIuMDA1IDE1MS4wNzIgMTUxLjc3NSAxNTEuMjQ3IDE1MS41ODhDMTUxLjQyMSAxNTEuNCAxNTEuNjE5IDE1MS4yNTggMTUxLjg0MSAxNTEuMTYyQzE1Mi4wNjIgMTUxLjA2NiAxNTIuMjkxIDE1MS4wMTggMTUyLjUyOCAxNTEuMDE4QzE1Mi44MyAxNTEuMDE4IDE1My4wOTEgMTUxLjA3IDE1My4zMDkgMTUxLjE3NEMxNTMuNTMxIDE1MS4yNzggMTUzLjcxMiAxNTEuNDI0IDE1My44NTIgMTUxLjYxMUMxNTMuOTkzIDE1MS43OTYgMTU0LjA5NyAxNTIuMDE1IDE1NC4xNjUgMTUyLjI2OEMxNTQuMjMyIDE1Mi41MTggMTU0LjI2NiAxNTIuNzkxIDE1NC4yNjYgMTUzLjA4OFYxNTMuNDEySDE1MS4xMVYxNTIuODIySDE1My41NDRWMTUyLjc2OEMxNTMuNTMzIDE1Mi41OCAxNTMuNDk0IDE1Mi4zOTggMTUzLjQyNiAxNTIuMjIxQzE1My4zNjEgMTUyLjA0NCAxNTMuMjU3IDE1MS44OTggMTUzLjExNCAxNTEuNzgzQzE1Mi45NzEgMTUxLjY2OSAxNTIuNzc1IDE1MS42MTEgMTUyLjUyOCAxNTEuNjExQzE1Mi4zNjQgMTUxLjYxMSAxNTIuMjEzIDE1MS42NDYgMTUyLjA3NSAxNTEuNzE3QzE1MS45MzcgMTUxLjc4NSAxNTEuODE4IDE1MS44ODYgMTUxLjcxOSAxNTIuMDIxQzE1MS42MiAxNTIuMTU3IDE1MS41NDQgMTUyLjMyMiAxNTEuNDg5IDE1Mi41MThDMTUxLjQzNCAxNTIuNzEzIDE1MS40MDcgMTUyLjkzOCAxNTEuNDA3IDE1My4xOTNWMTUzLjM1N0MxNTEuNDA3IDE1My41NTggMTUxLjQzNCAxNTMuNzQ3IDE1MS40ODkgMTUzLjkyNEMxNTEuNTQ2IDE1NC4wOTggMTUxLjYyOCAxNTQuMjUyIDE1MS43MzUgMTU0LjM4NUMxNTEuODQ0IDE1NC41MTggMTUxLjk3NiAxNTQuNjIyIDE1Mi4xMyAxNTQuNjk3QzE1Mi4yODYgMTU0Ljc3MyAxNTIuNDYzIDE1NC44MTEgMTUyLjY2MSAxNTQuODExQzE1Mi45MTYgMTU0LjgxMSAxNTMuMTMyIDE1NC43NTggMTUzLjMwOSAxNTQuNjU0QzE1My40ODYgMTU0LjU1IDE1My42NDEgMTU0LjQxMSAxNTMuNzc0IDE1NC4yMzZMMTU0LjIxMiAxNTQuNTg0QzE1NC4xMiAxNTQuNzIyIDE1NC4wMDUgMTU0Ljg1NCAxNTMuODY0IDE1NC45NzlDMTUzLjcyMyAxNTUuMTA0IDE1My41NSAxNTUuMjA1IDE1My4zNDQgMTU1LjI4M0MxNTMuMTQxIDE1NS4zNjEgMTUyLjkgMTU1LjQgMTUyLjYyMiAxNTUuNFpNMTU1LjE4OSAxNDkuMzIySDE1NS45MTVWMTU0LjUwMkwxNTUuODUzIDE1NS4zMjJIMTU1LjE4OVYxNDkuMzIyWk0xNTguNzcxIDE1My4xNzRWMTUzLjI1NkMxNTguNzcxIDE1My41NjMgMTU4LjczNCAxNTMuODQ4IDE1OC42NjEgMTU0LjExMUMxNTguNTg5IDE1NC4zNzIgMTU4LjQ4MiAxNTQuNTk4IDE1OC4zNDEgMTU0Ljc5MUMxNTguMjAxIDE1NC45ODQgMTU4LjAyOSAxNTUuMTMzIDE1Ny44MjYgMTU1LjI0QzE1Ny42MjIgMTU1LjM0NyAxNTcuMzg5IDE1NS40IDE1Ny4xMjYgMTU1LjRDMTU2Ljg1OCAxNTUuNCAxNTYuNjIyIDE1NS4zNTUgMTU2LjQxOSAxNTUuMjY0QzE1Ni4yMTkgMTU1LjE3IDE1Ni4wNDkgMTU1LjAzNiAxNTUuOTExIDE1NC44NjFDMTU1Ljc3MyAxNTQuNjg3IDE1NS42NjMgMTU0LjQ3NiAxNTUuNTc5IDE1NC4yMjlDMTU1LjQ5OSAxNTMuOTgxIDE1NS40NDMgMTUzLjcwMiAxNTUuNDExIDE1My4zOTNWMTUzLjAzM0MxNTUuNDQzIDE1Mi43MjEgMTU1LjQ5OSAxNTIuNDQxIDE1NS41NzkgMTUyLjE5M0MxNTUuNjYzIDE1MS45NDYgMTU1Ljc3MyAxNTEuNzM1IDE1NS45MTEgMTUxLjU2MUMxNTYuMDQ5IDE1MS4zODMgMTU2LjIxOSAxNTEuMjQ5IDE1Ni40MTkgMTUxLjE1OEMxNTYuNjIgMTUxLjA2NCAxNTYuODUzIDE1MS4wMTggMTU3LjExOCAxNTEuMDE4QzE1Ny4zODQgMTUxLjAxOCAxNTcuNjIgMTUxLjA3IDE1Ny44MjYgMTUxLjE3NEMxNTguMDMxIDE1MS4yNzUgMTU4LjIwMyAxNTEuNDIxIDE1OC4zNDEgMTUxLjYxMUMxNTguNDgyIDE1MS44MDEgMTU4LjU4OSAxNTIuMDI5IDE1OC42NjEgMTUyLjI5NUMxNTguNzM0IDE1Mi41NTggMTU4Ljc3MSAxNTIuODUxIDE1OC43NzEgMTUzLjE3NFpNMTU4LjA0NCAxNTMuMjU2VjE1My4xNzRDMTU4LjA0NCAxNTIuOTYzIDE1OC4wMjUgMTUyLjc2NSAxNTcuOTg2IDE1Mi41OEMxNTcuOTQ3IDE1Mi4zOTMgMTU3Ljg4NCAxNTIuMjI5IDE1Ny43OTggMTUyLjA4OEMxNTcuNzEyIDE1MS45NDUgMTU3LjU5OSAxNTEuODMzIDE1Ny40NTggMTUxLjc1MkMxNTcuMzE4IDE1MS42NjkgMTU3LjE0NSAxNTEuNjI3IDE1Ni45MzkgMTUxLjYyN0MxNTYuNzU3IDE1MS42MjcgMTU2LjU5OCAxNTEuNjU4IDE1Ni40NjIgMTUxLjcyMUMxNTYuMzI5IDE1MS43ODMgMTU2LjIxNiAxNTEuODY4IDE1Ni4xMjIgMTUxLjk3NUMxNTYuMDI5IDE1Mi4wNzkgMTU1Ljk1MiAxNTIuMTk5IDE1NS44OTIgMTUyLjMzNEMxNTUuODM1IDE1Mi40NjcgMTU1Ljc5MiAxNTIuNjA1IDE1NS43NjMgMTUyLjc0OFYxNTMuNjg5QzE1NS44MDUgMTUzLjg3MiAxNTUuODcyIDE1NC4wNDggMTU1Ljk2NiAxNTQuMjE3QzE1Ni4wNjIgMTU0LjM4MyAxNTYuMTkgMTU0LjUyIDE1Ni4zNDkgMTU0LjYyN0MxNTYuNTEgMTU0LjczNCAxNTYuNzEgMTU0Ljc4NyAxNTYuOTQ3IDE1NC43ODdDMTU3LjE0MiAxNTQuNzg3IDE1Ny4zMDkgMTU0Ljc0OCAxNTcuNDQ3IDE1NC42N0MxNTcuNTg3IDE1NC41ODkgMTU3LjcwMSAxNTQuNDc5IDE1Ny43ODYgMTU0LjMzOEMxNTcuODc1IDE1NC4xOTcgMTU3Ljk0IDE1NC4wMzUgMTU3Ljk4MiAxNTMuODVDMTU4LjAyMyAxNTMuNjY1IDE1OC4wNDQgMTUzLjQ2NyAxNTguMDQ0IDE1My4yNTZaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjU0Ii8+CjwvZz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXA0XzQwNTNfMTg1MTEzKSI+CjxsaW5lIHgxPSIxODIuMDUiIHkxPSIxNDcuMDcyIiB4Mj0iMTgyLjA1IiB5Mj0iMTQ2LjI1IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utb3BhY2l0eT0iMC41IiBzdHJva2Utd2lkdGg9IjAuNSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIvPgo8cGF0aCBkPSJNMTc0LjEwOSAxNTIuMDI1VjE1Mi44OTNDMTc0LjEwOSAxNTMuMzU5IDE3NC4wNjcgMTUzLjc1MiAxNzMuOTg0IDE1NC4wNzJDMTczLjkwMSAxNTQuMzkzIDE3My43ODEgMTU0LjY1IDE3My42MjUgMTU0Ljg0NkMxNzMuNDY5IDE1NS4wNDEgMTczLjI4IDE1NS4xODMgMTczLjA1OCAxNTUuMjcxQzE3Mi44NCAxNTUuMzU3IDE3Mi41OTIgMTU1LjQgMTcyLjMxNiAxNTUuNEMxNzIuMDk3IDE1NS40IDE3MS44OTYgMTU1LjM3MyAxNzEuNzExIDE1NS4zMThDMTcxLjUyNiAxNTUuMjY0IDE3MS4zNTkgMTU1LjE3NiAxNzEuMjExIDE1NS4wNTdDMTcxLjA2NSAxNTQuOTM0IDE3MC45NCAxNTQuNzc1IDE3MC44MzYgMTU0LjU4QzE3MC43MzIgMTU0LjM4NSAxNzAuNjUyIDE1NC4xNDggMTcwLjU5NyAxNTMuODY5QzE3MC41NDMgMTUzLjU5IDE3MC41MTUgMTUzLjI2NSAxNzAuNTE1IDE1Mi44OTNWMTUyLjAyNUMxNzAuNTE1IDE1MS41NTkgMTcwLjU1NyAxNTEuMTY5IDE3MC42NCAxNTAuODU0QzE3MC43MjYgMTUwLjUzOCAxNzAuODQ3IDE1MC4yODYgMTcxLjAwNCAxNTAuMDk2QzE3MS4xNiAxNDkuOTAzIDE3MS4zNDcgMTQ5Ljc2NSAxNzEuNTY2IDE0OS42ODJDMTcxLjc4OCAxNDkuNTk4IDE3Mi4wMzUgMTQ5LjU1NyAxNzIuMzA4IDE0OS41NTdDMTcyLjUzIDE0OS41NTcgMTcyLjczMyAxNDkuNTg0IDE3Mi45MTggMTQ5LjYzOUMxNzMuMTA1IDE0OS42OTEgMTczLjI3MiAxNDkuNzc1IDE3My40MTggMTQ5Ljg5M0MxNzMuNTY0IDE1MC4wMDcgMTczLjY4NyAxNTAuMTYxIDE3My43ODkgMTUwLjM1NEMxNzMuODkzIDE1MC41NDQgMTczLjk3MiAxNTAuNzc3IDE3NC4wMjcgMTUxLjA1M0MxNzQuMDgyIDE1MS4zMjkgMTc0LjEwOSAxNTEuNjUzIDE3NC4xMDkgMTUyLjAyNVpNMTczLjM4MyAxNTMuMDFWMTUxLjkwNEMxNzMuMzgzIDE1MS42NDkgMTczLjM2NyAxNTEuNDI1IDE3My4zMzYgMTUxLjIzMkMxNzMuMzA3IDE1MS4wMzcgMTczLjI2NCAxNTAuODcgMTczLjIwNyAxNTAuNzMyQzE3My4xNSAxNTAuNTk0IDE3My4wNzcgMTUwLjQ4MiAxNzIuOTg4IDE1MC4zOTZDMTcyLjkwMiAxNTAuMzExIDE3Mi44MDIgMTUwLjI0OCAxNzIuNjg3IDE1MC4yMDlDMTcyLjU3NSAxNTAuMTY3IDE3Mi40NDkgMTUwLjE0NiAxNzIuMzA4IDE1MC4xNDZDMTcyLjEzNiAxNTAuMTQ2IDE3MS45ODQgMTUwLjE3OSAxNzEuODUxIDE1MC4yNDRDMTcxLjcxOSAxNTAuMzA3IDE3MS42MDcgMTUwLjQwNyAxNzEuNTE1IDE1MC41NDVDMTcxLjQyNyAxNTAuNjgzIDE3MS4zNTkgMTUwLjg2NCAxNzEuMzEyIDE1MS4wODhDMTcxLjI2NSAxNTEuMzEyIDE3MS4yNDIgMTUxLjU4NCAxNzEuMjQyIDE1MS45MDRWMTUzLjAxQzE3MS4yNDIgMTUzLjI2NSAxNzEuMjU2IDE1My40OSAxNzEuMjg1IDE1My42ODZDMTcxLjMxNiAxNTMuODgxIDE3MS4zNjIgMTU0LjA1IDE3MS40MjIgMTU0LjE5M0MxNzEuNDgyIDE1NC4zMzQgMTcxLjU1NCAxNTQuNDUgMTcxLjY0IDE1NC41NDFDMTcxLjcyNiAxNTQuNjMyIDE3MS44MjUgMTU0LjcgMTcxLjkzNyAxNTQuNzQ0QzE3Mi4wNTIgMTU0Ljc4NiAxNzIuMTc4IDE1NC44MDcgMTcyLjMxNiAxNTQuODA3QzE3Mi40OTMgMTU0LjgwNyAxNzIuNjQ4IDE1NC43NzMgMTcyLjc4MSAxNTQuNzA1QzE3Mi45MTQgMTU0LjYzNyAxNzMuMDI1IDE1NC41MzIgMTczLjExMyAxNTQuMzg5QzE3My4yMDQgMTU0LjI0MyAxNzMuMjcyIDE1NC4wNTcgMTczLjMxNiAxNTMuODNDMTczLjM2IDE1My42MDEgMTczLjM4MyAxNTMuMzI3IDE3My4zODMgMTUzLjAxWk0xNzYuMDM2IDE1Mi42MTVMMTc1LjQ1NyAxNTIuNDY3TDE3NS43NDMgMTQ5LjYzNUgxNzguNjYxVjE1MC4zMDNIMTc2LjM1NkwxNzYuMTg0IDE1MS44NUMxNzYuMjg4IDE1MS43OSAxNzYuNDIgMTUxLjczNCAxNzYuNTc5IDE1MS42ODJDMTc2Ljc0IDE1MS42MyAxNzYuOTI1IDE1MS42MDQgMTc3LjEzMyAxNTEuNjA0QzE3Ny4zOTYgMTUxLjYwNCAxNzcuNjMyIDE1MS42NDkgMTc3Ljg0IDE1MS43NEMxNzguMDQ5IDE1MS44MjkgMTc4LjIyNiAxNTEuOTU2IDE3OC4zNzEgMTUyLjEyM0MxNzguNTIgMTUyLjI5IDE3OC42MzMgMTUyLjQ5IDE3OC43MTEgMTUyLjcyNUMxNzguNzg5IDE1Mi45NTkgMTc4LjgyOSAxNTMuMjIxIDE3OC44MjkgMTUzLjUxQzE3OC44MjkgMTUzLjc4MyAxNzguNzkxIDE1NC4wMzUgMTc4LjcxNSAxNTQuMjY0QzE3OC42NDIgMTU0LjQ5MyAxNzguNTMyIDE1NC42OTMgMTc4LjM4MyAxNTQuODY1QzE3OC4yMzUgMTU1LjAzNSAxNzguMDQ3IDE1NS4xNjYgMTc3LjgyMSAxNTUuMjZDMTc3LjU5NyAxNTUuMzU0IDE3Ny4zMzIgMTU1LjQgMTc3LjAyOCAxNTUuNEMxNzYuNzk5IDE1NS40IDE3Ni41ODEgMTU1LjM2OSAxNzYuMzc1IDE1NS4zMDdDMTc2LjE3MiAxNTUuMjQyIDE3NS45OSAxNTUuMTQ0IDE3NS44MjkgMTU1LjAxNEMxNzUuNjcgMTU0Ljg4MSAxNzUuNTM5IDE1NC43MTcgMTc1LjQzOCAxNTQuNTIxQzE3NS4zMzkgMTU0LjMyNCAxNzUuMjc2IDE1NC4wOTIgMTc1LjI1IDE1My44MjZIMTc1LjkzOEMxNzUuOTY5IDE1NC4wNCAxNzYuMDMyIDE1NC4yMTkgMTc2LjEyNSAxNTQuMzY1QzE3Ni4yMTkgMTU0LjUxMSAxNzYuMzQyIDE1NC42MjIgMTc2LjQ5MyAxNTQuNjk3QzE3Ni42NDYgMTU0Ljc3IDE3Ni44MjUgMTU0LjgwNyAxNzcuMDI4IDE1NC44MDdDMTc3LjIgMTU0LjgwNyAxNzcuMzUyIDE1NC43NzcgMTc3LjQ4NSAxNTQuNzE3QzE3Ny42MTggMTU0LjY1NyAxNzcuNzMgMTU0LjU3MSAxNzcuODIxIDE1NC40NTlDMTc3LjkxMiAxNTQuMzQ3IDE3Ny45ODEgMTU0LjIxMiAxNzguMDI4IDE1NC4wNTNDMTc4LjA3NyAxNTMuODk0IDE3OC4xMDIgMTUzLjcxNSAxNzguMTAyIDE1My41MThDMTc4LjEwMiAxNTMuMzM4IDE3OC4wNzcgMTUzLjE3MSAxNzguMDI4IDE1My4wMThDMTc3Ljk3OCAxNTIuODY0IDE3Ny45MDQgMTUyLjczIDE3Ny44MDUgMTUyLjYxNUMxNzcuNzA5IDE1Mi41MDEgMTc3LjU5IDE1Mi40MTIgMTc3LjQ1IDE1Mi4zNUMxNzcuMzA5IDE1Mi4yODUgMTc3LjE0OCAxNTIuMjUyIDE3Ni45NjUgMTUyLjI1MkMxNzYuNzIzIDE1Mi4yNTIgMTc2LjUzOSAxNTIuMjg1IDE3Ni40MTQgMTUyLjM1QzE3Ni4yOTIgMTUyLjQxNSAxNzYuMTY2IDE1Mi41MDMgMTc2LjAzNiAxNTIuNjE1Wk0xODIuNzEzIDE0OS42MzVWMTU1LjMyMkgxODEuOTU5VjE0OS42MzVIMTgyLjcxM1pNMTg1LjA5NSAxNTIuMTkzVjE1Mi44MTFIMTgyLjU0OFYxNTIuMTkzSDE4NS4wOTVaTTE4NS40ODIgMTQ5LjYzNVYxNTAuMjUySDE4Mi41NDhWMTQ5LjYzNUgxODUuNDgyWk0xODguMDIyIDE1NS40QzE4Ny43MjcgMTU1LjQgMTg3LjQ2IDE1NS4zNTEgMTg3LjIyMSAxNTUuMjUyQzE4Ni45ODQgMTU1LjE1IDE4Ni43OCAxNTUuMDA4IDE4Ni42MDggMTU0LjgyNkMxODYuNDM4IDE1NC42NDQgMTg2LjMwOCAxNTQuNDI4IDE4Ni4yMTcgMTU0LjE3OEMxODYuMTI2IDE1My45MjggMTg2LjA4IDE1My42NTQgMTg2LjA4IDE1My4zNTdWMTUzLjE5M0MxODYuMDggMTUyLjg1IDE4Ni4xMzEgMTUyLjU0NCAxODYuMjMzIDE1Mi4yNzVDMTg2LjMzNCAxNTIuMDA1IDE4Ni40NzIgMTUxLjc3NSAxODYuNjQ3IDE1MS41ODhDMTg2LjgyMSAxNTEuNCAxODcuMDE5IDE1MS4yNTggMTg3LjI0IDE1MS4xNjJDMTg3LjQ2MiAxNTEuMDY2IDE4Ny42OTEgMTUxLjAxOCAxODcuOTI4IDE1MS4wMThDMTg4LjIzIDE1MS4wMTggMTg4LjQ5IDE1MS4wNyAxODguNzA5IDE1MS4xNzRDMTg4LjkzMSAxNTEuMjc4IDE4OS4xMTIgMTUxLjQyNCAxODkuMjUyIDE1MS42MTFDMTg5LjM5MyAxNTEuNzk2IDE4OS40OTcgMTUyLjAxNSAxODkuNTY1IDE1Mi4yNjhDMTg5LjYzMiAxNTIuNTE4IDE4OS42NjYgMTUyLjc5MSAxODkuNjY2IDE1My4wODhWMTUzLjQxMkgxODYuNTFWMTUyLjgyMkgxODguOTQ0VjE1Mi43NjhDMTg4LjkzMyAxNTIuNTggMTg4Ljg5NCAxNTIuMzk4IDE4OC44MjYgMTUyLjIyMUMxODguNzYxIDE1Mi4wNDQgMTg4LjY1NyAxNTEuODk4IDE4OC41MTQgMTUxLjc4M0MxODguMzcxIDE1MS42NjkgMTg4LjE3NSAxNTEuNjExIDE4Ny45MjggMTUxLjYxMUMxODcuNzY0IDE1MS42MTEgMTg3LjYxMyAxNTEuNjQ2IDE4Ny40NzUgMTUxLjcxN0MxODcuMzM3IDE1MS43ODUgMTg3LjIxOCAxNTEuODg2IDE4Ny4xMTkgMTUyLjAyMUMxODcuMDIgMTUyLjE1NyAxODYuOTQ0IDE1Mi4zMjIgMTg2Ljg4OSAxNTIuNTE4QzE4Ni44MzQgMTUyLjcxMyAxODYuODA3IDE1Mi45MzggMTg2LjgwNyAxNTMuMTkzVjE1My4zNTdDMTg2LjgwNyAxNTMuNTU4IDE4Ni44MzQgMTUzLjc0NyAxODYuODg5IDE1My45MjRDMTg2Ljk0NiAxNTQuMDk4IDE4Ny4wMjggMTU0LjI1MiAxODcuMTM1IDE1NC4zODVDMTg3LjI0NCAxNTQuNTE4IDE4Ny4zNzYgMTU0LjYyMiAxODcuNTMgMTU0LjY5N0MxODcuNjg2IDE1NC43NzMgMTg3Ljg2MyAxNTQuODExIDE4OC4wNjEgMTU0LjgxMUMxODguMzE2IDE1NC44MTEgMTg4LjUzMiAxNTQuNzU4IDE4OC43MDkgMTU0LjY1NEMxODguODg2IDE1NC41NSAxODkuMDQxIDE1NC40MTEgMTg5LjE3NCAxNTQuMjM2TDE4OS42MTIgMTU0LjU4NEMxODkuNTIgMTU0LjcyMiAxODkuNDA1IDE1NC44NTQgMTg5LjI2NCAxNTQuOTc5QzE4OS4xMjMgMTU1LjEwNCAxODguOTUgMTU1LjIwNSAxODguNzQ0IDE1NS4yODNDMTg4LjU0MSAxNTUuMzYxIDE4OC4zIDE1NS40IDE4OC4wMjIgMTU1LjRaTTE5MC41ODkgMTQ5LjMyMkgxOTEuMzE1VjE1NC41MDJMMTkxLjI1MyAxNTUuMzIySDE5MC41ODlWMTQ5LjMyMlpNMTk0LjE3MSAxNTMuMTc0VjE1My4yNTZDMTk0LjE3MSAxNTMuNTYzIDE5NC4xMzQgMTUzLjg0OCAxOTQuMDYxIDE1NC4xMTFDMTkzLjk4OCAxNTQuMzcyIDE5My44ODIgMTU0LjU5OCAxOTMuNzQxIDE1NC43OTFDMTkzLjYgMTU0Ljk4NCAxOTMuNDI5IDE1NS4xMzMgMTkzLjIyNSAxNTUuMjRDMTkzLjAyMiAxNTUuMzQ3IDE5Mi43ODkgMTU1LjQgMTkyLjUyNiAxNTUuNEMxOTIuMjU4IDE1NS40IDE5Mi4wMjIgMTU1LjM1NSAxOTEuODE5IDE1NS4yNjRDMTkxLjYxOSAxNTUuMTcgMTkxLjQ0OSAxNTUuMDM2IDE5MS4zMTEgMTU0Ljg2MUMxOTEuMTczIDE1NC42ODcgMTkxLjA2MyAxNTQuNDc2IDE5MC45NzkgMTU0LjIyOUMxOTAuODk5IDE1My45ODEgMTkwLjg0MyAxNTMuNzAyIDE5MC44MTEgMTUzLjM5M1YxNTMuMDMzQzE5MC44NDMgMTUyLjcyMSAxOTAuODk5IDE1Mi40NDEgMTkwLjk3OSAxNTIuMTkzQzE5MS4wNjMgMTUxLjk0NiAxOTEuMTczIDE1MS43MzUgMTkxLjMxMSAxNTEuNTYxQzE5MS40NDkgMTUxLjM4MyAxOTEuNjE5IDE1MS4yNDkgMTkxLjgxOSAxNTEuMTU4QzE5Mi4wMiAxNTEuMDY0IDE5Mi4yNTMgMTUxLjAxOCAxOTIuNTE4IDE1MS4wMThDMTkyLjc4NCAxNTEuMDE4IDE5My4wMiAxNTEuMDcgMTkzLjIyNSAxNTEuMTc0QzE5My40MzEgMTUxLjI3NSAxOTMuNjAzIDE1MS40MjEgMTkzLjc0MSAxNTEuNjExQzE5My44ODIgMTUxLjgwMSAxOTMuOTg4IDE1Mi4wMjkgMTk0LjA2MSAxNTIuMjk1QzE5NC4xMzQgMTUyLjU1OCAxOTQuMTcxIDE1Mi44NTEgMTk0LjE3MSAxNTMuMTc0Wk0xOTMuNDQ0IDE1My4yNTZWMTUzLjE3NEMxOTMuNDQ0IDE1Mi45NjMgMTkzLjQyNSAxNTIuNzY1IDE5My4zODYgMTUyLjU4QzE5My4zNDcgMTUyLjM5MyAxOTMuMjg0IDE1Mi4yMjkgMTkzLjE5OCAxNTIuMDg4QzE5My4xMTIgMTUxLjk0NSAxOTIuOTk5IDE1MS44MzMgMTkyLjg1OCAxNTEuNzUyQzE5Mi43MTggMTUxLjY2OSAxOTIuNTQ0IDE1MS42MjcgMTkyLjMzOSAxNTEuNjI3QzE5Mi4xNTYgMTUxLjYyNyAxOTEuOTk4IDE1MS42NTggMTkxLjg2MiAxNTEuNzIxQzE5MS43MjkgMTUxLjc4MyAxOTEuNjE2IDE1MS44NjggMTkxLjUyMiAxNTEuOTc1QzE5MS40MjkgMTUyLjA3OSAxOTEuMzUyIDE1Mi4xOTkgMTkxLjI5MiAxNTIuMzM0QzE5MS4yMzUgMTUyLjQ2NyAxOTEuMTkyIDE1Mi42MDUgMTkxLjE2MyAxNTIuNzQ4VjE1My42ODlDMTkxLjIwNSAxNTMuODcyIDE5MS4yNzIgMTU0LjA0OCAxOTEuMzY2IDE1NC4yMTdDMTkxLjQ2MiAxNTQuMzgzIDE5MS41OSAxNTQuNTIgMTkxLjc0OSAxNTQuNjI3QzE5MS45MSAxNTQuNzM0IDE5Mi4xMSAxNTQuNzg3IDE5Mi4zNDcgMTU0Ljc4N0MxOTIuNTQyIDE1NC43ODcgMTkyLjcwOCAxNTQuNzQ4IDE5Mi44NDcgMTU0LjY3QzE5Mi45ODcgMTU0LjU4OSAxOTMuMSAxNTQuNDc5IDE5My4xODYgMTU0LjMzOEMxOTMuMjc1IDE1NC4xOTcgMTkzLjM0IDE1NC4wMzUgMTkzLjM4MiAxNTMuODVDMTkzLjQyMyAxNTMuNjY1IDE5My40NDQgMTUzLjQ2NyAxOTMuNDQ0IDE1My4yNTZaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjU0Ii8+CjwvZz4KPHBhdGggZD0iTTI3IDEwNy40NDVMNDAgODguMDk2Mkw3NS44NjUgNDUuMDgwMkM3Ni4yMTY2IDQ0LjY1ODYgNzYuODQyMyA0NC41OTkyIDc3LjI2NjkgNDQuOTQ3MUwxMTEuMTM1IDcyLjcwMDZDMTExLjM2NiA3Mi44OTAyIDExMS42NyA3Mi45NjYyIDExMS45NjMgNzIuOTA4TDE0Ni43OTQgNjUuOTkxQzE0Ni45MyA2NS45NjQgMTQ3LjA1OSA2NS45MDg5IDE0Ny4xNzIgNjUuODI5MkwxODQuMjY3IDM5Ljg0NjhDMTg0LjQxOSAzOS43NCAxODQuNTM5IDM5LjU5MjcgMTg0LjYxMiAzOS40MjE1TDE5OC41IDciIHN0cm9rZT0iI0ZCREIwRiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KPGNpcmNsZSBjeD0iNzYiIGN5PSI0NS4xNjExIiByPSIxLjUiIGZpbGw9IndoaXRlIiBzdHJva2U9IiNGQkRCMEYiLz4KPGNpcmNsZSBjeD0iMTEyIiBjeT0iNzMuMTYxMSIgcj0iMS41IiBmaWxsPSJ3aGl0ZSIgc3Ryb2tlPSIjRkJEQjBGIi8+CjxjaXJjbGUgY3g9IjE0NyIgY3k9IjY2LjE2MTEiIHI9IjEuNSIgZmlsbD0id2hpdGUiIHN0cm9rZT0iI0ZCREIwRiIvPgo8Y2lyY2xlIGN4PSIxODUiIGN5PSIzOS4xNjExIiByPSIxLjUiIGZpbGw9IndoaXRlIiBzdHJva2U9IiNGQkRCMEYiLz4KPGNpcmNsZSBjeD0iMTk4IiBjeT0iNyIgcj0iMS41IiBmaWxsPSJ3aGl0ZSIgc3Ryb2tlPSIjRkJEQjBGIi8+CjxwYXRoIGQ9Ik0yOC41IDEwNy4xMTRDMjguNSAxMDcuOTcyIDI3LjgxOTcgMTA4LjY1MSAyNyAxMDguNjUxQzI2LjE4MDMgMTA4LjY1MSAyNS41IDEwNy45NzIgMjUuNSAxMDcuMTE0QzI1LjUgMTA2LjI1NiAyNi4xODAzIDEwNS41NzYgMjcgMTA1LjU3NkMyNy44MTk3IDEwNS41NzYgMjguNSAxMDYuMjU2IDI4LjUgMTA3LjExNFoiIGZpbGw9IndoaXRlIiBzdHJva2U9IiNGQkRCMEYiLz4KPHBhdGggZD0iTTQxLjUgODcuNzc2OEM0MS41IDg4LjYzNDcgNDAuODE5NyA4OS4zMTQzIDQwIDg5LjMxNDNDMzkuMTgwMyA4OS4zMTQzIDM4LjUgODguNjM0NyAzOC41IDg3Ljc3NjhDMzguNSA4Ni45MTg4IDM5LjE4MDMgODYuMjM5MyA0MCA4Ni4yMzkzQzQwLjgxOTcgODYuMjM5MyA0MS41IDg2LjkxODggNDEuNSA4Ny43NzY4WiIgZmlsbD0id2hpdGUiIHN0cm9rZT0iI0ZCREIwRiIvPgo8cGF0aCBkPSJNMzIgMTIyQzMyIDEyMC44OTUgMzIuODk1NCAxMjAgMzQgMTIwSDQ4QzQ5LjEwNDYgMTIwIDUwIDEyMC44OTUgNTAgMTIyVjE0NkgzMlYxMjJaIiBmaWxsPSIjM0ZBNzFBIi8+CjxwYXRoIGQ9Ik02NyA3OEM2NyA3Ni44OTU0IDY3Ljg5NTQgNzYgNjkgNzZIODNDODQuMTA0NiA3NiA4NSA3Ni44OTU0IDg1IDc4VjE0Nkg2N1Y3OFoiIGZpbGw9IiMzRkE3MUEiLz4KPHBhdGggZD0iTTEwMiA5MkMxMDIgOTAuODk1NCAxMDIuODk1IDkwIDEwNCA5MEgxMThDMTE5LjEwNSA5MCAxMjAgOTAuODk1NCAxMjAgOTJWMTQ2SDEwMlY5MloiIGZpbGw9IiMzRkE3MUEiLz4KPHBhdGggZD0iTTE3MyAxMDVDMTczIDEwMy44OTUgMTczLjg5NSAxMDMgMTc1IDEwM0gxODlDMTkwLjEwNSAxMDMgMTkxIDEwMy44OTUgMTkxIDEwNVYxNDZIMTczVjEwNVoiIGZpbGw9IiMzRkE3MUEiLz4KPHBhdGggZD0iTTI3IDExOEw0MCA3MS43NzkyTDc4LjUgODRMMTE5IDMyTDE0OCAzNi41TDE4MiA3MS43NzkyTDE5NS4zMTEgMTAwLjIxNkwxOTcgMTA3IiBzdHJva2U9IiM0QjcwREQiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+CjxjaXJjbGUgY3g9Ijc4IiBjeT0iODQuMTYxMSIgcj0iMS41IiBmaWxsPSJ3aGl0ZSIgc3Ryb2tlPSIjNEI3MEREIi8+CjxjaXJjbGUgY3g9IjQwIiBjeT0iNzIuMTYxMSIgcj0iMS41IiBmaWxsPSJ3aGl0ZSIgc3Ryb2tlPSIjNEI3MEREIi8+CjxjaXJjbGUgY3g9IjExOSIgY3k9IjMyIiByPSIxLjUiIGZpbGw9IndoaXRlIiBzdHJva2U9IiM0QjcwREQiLz4KPGNpcmNsZSBjeD0iMTgyIiBjeT0iNzIuMTYxMSIgcj0iMS41IiBmaWxsPSJ3aGl0ZSIgc3Ryb2tlPSIjNEI3MEREIi8+CjxjaXJjbGUgY3g9IjE5NyIgY3k9IjEwNyIgcj0iMS41IiBmaWxsPSJ3aGl0ZSIgc3Ryb2tlPSIjNEI3MEREIi8+CjxjaXJjbGUgY3g9IjI3IiBjeT0iMTE3LjE2MSIgcj0iMS41IiBmaWxsPSJ3aGl0ZSIgc3Ryb2tlPSIjNEI3MEREIi8+CjxjaXJjbGUgY3g9IjE0NyIgY3k9IjM3LjE2MTEiIHI9IjEuNSIgZmlsbD0id2hpdGUiIHN0cm9rZT0iIzRCNzBERCIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzQwNTNfMTg1MTEzIj4KPHJlY3Qgd2lkdGg9IjIwMCIgaGVpZ2h0PSIxNjAiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjxjbGlwUGF0aCBpZD0iY2xpcDFfNDA1M18xODUxMTMiPgo8cmVjdCB3aWR0aD0iMzUuNCIgaGVpZ2h0PSIxMC4zMjIiIGZpbGw9IndoaXRlIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1OC4zOTk5IDE0NikiLz4KPC9jbGlwUGF0aD4KPGNsaXBQYXRoIGlkPSJjbGlwMl80MDUzXzE4NTExMyI+CjxyZWN0IHdpZHRoPSIzNS40IiBoZWlnaHQ9IjEwLjMyMiIgZmlsbD0id2hpdGUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDkzLjc5OTggMTQ2KSIvPgo8L2NsaXBQYXRoPgo8Y2xpcFBhdGggaWQ9ImNsaXAzXzQwNTNfMTg1MTEzIj4KPHJlY3Qgd2lkdGg9IjM1LjQiIGhlaWdodD0iMTAuMzIyIiBmaWxsPSJ3aGl0ZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTI5LjIgMTQ2KSIvPgo8L2NsaXBQYXRoPgo8Y2xpcFBhdGggaWQ9ImNsaXA0XzQwNTNfMTg1MTEzIj4KPHJlY3Qgd2lkdGg9IjM1LjQiIGhlaWdodD0iMTAuMzIyIiBmaWxsPSJ3aGl0ZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTY0LjYgMTQ2KSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo=", - "description": "Displays changes to time-series data over time—for example, temperature or humidity readings.", + "description": "Displays changes to time series data over time—for example, temperature or humidity readings.", "descriptor": { "type": "timeseries", "sizeX": 8, diff --git a/application/src/main/data/json/system/widget_types/timeseries_bar_chart.json b/application/src/main/data/json/system/widget_types/timeseries_bar_chart.json index ebb0d2680d..36927578ee 100644 --- a/application/src/main/data/json/system/widget_types/timeseries_bar_chart.json +++ b/application/src/main/data/json/system/widget_types/timeseries_bar_chart.json @@ -3,7 +3,7 @@ "name": "Timeseries Bar Chart", "deprecated": true, "image": "tb-image:dGltZXNlcmllc19iYXJfY2hhcnRfc3lzdGVtX3dpZGdldF9pbWFnZS5wbmc=:IlRpbWVzZXJpZXMgQmFyIENoYXJ0IiBzeXN0ZW0gd2lkZ2V0IGltYWdl;data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAABOFBMVEUAAAA3oPR3d3d6enp8fHyBgYGDg4OGhoaNjY2RkZGSkpKTk5OUlJSVlZWWlpaXl5eYmJiZmZmampqcnJydnZ2enp6goKChoaGioqKjo6OkpKSlpaWmpqanp6eoqKipqamqqqqrq6usrKytra2urq6wsLCxsbGysrK0tLS1tbW2tra3t7e4uLi5ubm6urq8vLy9vb2+vr6/v7/AwMDBwcHDw8PExMTHx8fIyMjJycnLy8vNzc3Ozs7Pz8/S0tLT09PU1NTV1dXW1tbX19fZ2dna2trb29vc3Nzd3d3e3t7f39/h4eHi4uLj4+Pk5OTl5eXm5ubn5+fo6Ojq6urr6+vs7Ozt7e3u7u7v7+/w8PDx8fH09PT19fX29vb39/f4+Pj5+fn6+vr7+/v8/Pz9/f3+/v7/xx////8KXFhiAAAAAWJLR0RnW9PpswAAAvtJREFUeNrt3GtXElEUBuDpZlAqylVHCyMwyi4SpkaWAl4qk8wwUhJlmOn9//+gLzbKbc6AAm5991p82WvPOedZZw6zYM3aGupCQ4tYkZDSsKDrocNVvz8jHQKYY1ZiT/6OAJllRPSpsnzIqIG9SjYmHpKfAzI4CIuHjJeBtyHfZwCatiI1/m+BYV2Dw35NniOEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQUp/6Wx+DgbRbxOAhEb/fXJ98akiH1AIAHtSWlqRDSvdDGcOHnYR0SPV7zVsaQyHeO0jTJapEV5C9bUz9fIjsvHRIefhRxHqpjxzA3ftajXOudHGJOtHV+1rV04/wHenDc2QQkFbLJISQwUDaP90IIYQQQgghpAvIxRO9gFxG4lZ9XBFIx6sihJD+QVRjEEIIIYQQcoMgahkhhBDSe0iLX3ydQdSLIKR9ghCJkIYEIYQQIhPS8d+AhPQboppFw9HMxBrs/lqCIbObpgezP67YjnQ8iwagHDzrryUZUgsUz/prCYZY05vn+msJhiwM6XrR7q/V9OJU4xQuEpcxRsezNPbXEn3Yzz9Hulg3IYT0C3LxxI2CNCQIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQlpA7P5a0iF2fy3hkLP+WsIhVbu/lvRby+6vJR1i99eSG6ffu1XHxlRNcfWq3I0iIK4tJK2nXVyV0lOqEmvjGWAtvnCu+jMzkQWMiOlYdRSd3MAH/UnVPWR/ApFfSkcpiOBvRc3XtAeYW1ZUJbZMD5C8azhWLX4xvZVR692Se0huHm9ySogxkhs3lFVewPM4WlFUlUMoJEYUox1+jAFIbLuHrKaQWlMu8TicDp+4gdw7/qS4t2qBohk4UUF2nieBfLyDM/ItgXhBucT113i14QbixW7M+SRNb6EQ0u8kHavyZQxj2/kgNUCsYDRoqXfEF/Mdu4G8D43uOtakh3R9H1DsyKZvOmneDjt+D/0DTzolrPMHmggAAAAASUVORK5CYII=", - "description": "Displays changes to time-series data over time—for example, daily water consumption for the last month.", + "description": "Displays changes to time series data over time—for example, daily water consumption for the last month.", "descriptor": { "type": "timeseries", "sizeX": 8, diff --git a/application/src/main/data/json/system/widget_types/timeseries_line_chart.json b/application/src/main/data/json/system/widget_types/timeseries_line_chart.json index 87f7330657..bd19d0b765 100644 --- a/application/src/main/data/json/system/widget_types/timeseries_line_chart.json +++ b/application/src/main/data/json/system/widget_types/timeseries_line_chart.json @@ -3,7 +3,7 @@ "name": "Timeseries Line Chart", "deprecated": true, "image": "tb-image:Z2F0ZXdheV9nZW5lcmFsX2NoYXJ0X3N0YXRpc3RpY3Nfc3lzdGVtX3dpZGdldF9pbWFnZS5wbmc=:IkdhdGV3YXkgZ2VuZXJhbCBjaGFydCBzdGF0aXN0aWNzIiBzeXN0ZW0gd2lkZ2V0IGltYWdl;data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAIAAADGnbT+AAAABmJLR0QA/wD/AP+gvaeTAAAXk0lEQVR42u2deXwb1bXH+Y+lfY/utK98WkogZSk7LcujBF4pAT6lLaWvlBZeCrTw4AGFvrbQkPYRwlIWfyCr7WA7XrN4wzZx7DjxIu+2vEteZHmTJdmOd8faR9K838y1FUWWZVmaGQM585mPPmNZR/fOvV+de+455849iw/7OOuss/gojpaWFhI8cwQFVlQqVbp4JCUlzc3NaTSaveKhVqsJLBKMHCx2eDyeuLg4r9ebl5c3NjZGGosEpQEL+qm+vh4XKSkp0F4ZGRkWi4XAIsFowdqzZw/HcbiAurLb7Z2dndnZ2QQWCUYFVnd3d1FRkU914XV2djY5OZnAIsGowIqPj/cNfOXl5bDiY2Nj+/v7/anC0UIHHeEdwZUQxkRY8WS8k6AENhb5sUiQwCJBAosancAisEiQwCJBAosancAisEiQwCJBAosancAisEiQwCJBAosancAisEiQwCJBAosECSwCiwQJLBIksEiQwCKwSJDAIkECiwQJLAKLBAksEiSwSJDAIrBIkMAiQQKLBAksAosECSwSJLBIkMAisEiQwCJBAuuzK5jV7SKwCCyJBd+pd35nz7x6xE1gEViSCb7XIFCF84lCO4FFYEkj+IFaoOqSuPn1cfMX75nvnvQQWARWtIKvFg6CqnWx8+83Op8ptuP6hWN2AovAikowsd3FqIppcDaOuIv7uUtj5y+JnR+a9RBYBFaEgvs6BKow9r1V6wBV7PzdYRve3KJyEFgEViSC6VrXxSJVL+YN+6jC+ZHOBQV2Wfz8uNW75lXVTni2q50/zbLWGN0E1qdAEP4q0AOqXq92ZKi6/cHC+auPBKX1dp1zTarq9vBweWytctyaamETVZy3pFim7V6FwMKOX2yPQmwup9Pp0tLScnJy2J5NBFaII7uHA1Xora3Vwgi4FKz9ncIQeXWC5aTTq1hVwU1OD/fMUfv3PjzF0w1JlieL7HcftOIaF0qA5Xa7ExISfG/t2LED79SJB4EV4ijs4+BWQD+9orIzjJaChfMnWUJfxrY45a6qYc6T1O56pMB2ady8j6cfpllfOOZI1bgaxMpgSnGVSNtbRf2ygzU9Pf3mm29iYybs+wUthe2Z8KbRaMSOmATWcgd6iFH1t3K7j6GgYCWIs8Ub91nsnPRVbWpu0Yx7Pmh03i/iy851cfP3HLS+WuU40s8trc/OJsHTtj52ric8H1vkYLlcLrPZDC21fft2QIZ9VvGmyWTKzc0lsIIeRwc4phX+Umb377OgYOH80X5BSaRpXBJW1eXhXy53XL131scTBtxNH9t2qJ2Vw1zQavjOxw8LPrb7DlkdbjnBws6XBoMBF9hVdXx8fOfOnbjGRpilpaUE1tKjwuD+brzQkc+V2AM6bDmwdoju+NvTrW6PZFVl4UicN6dY4YyFF63O5A7Nk+8Eed9PnIHstmqHjGBhp8Jdu3YdOHAAJjx2kysuLsbWvbt378ZemP5U0X6FOJLKdevjTqJLHs0cAUZhnukV3awjPyjRS1KNA6qudXtOrgMZhYPhV8P/fLe4/5LYk5jMJpTp5N2vEANi0GvSWL4DHqDL9wpKAhoiqCZYTmPhfLvOAcG7D1g93miranV578wQLKo/lTpClBj6hOAWlTAg3rDPsqKbjfxYMgo2jbqvFOdTyFloWL63lutIjFPf3yeIlw5xUVZ1c4XA6J0ZllqzOxqw6s3un4omPywzL4G1JoLNY27mE3rssB390bh6sHD+X6UAxC9ybNFUtXLYjfELqRPZ3dyKJYYGC6/HBrlrE4X7golGYCkniN9xy5h7W42DUYXYXwiqVuxmmMyYuOF7GszuyKoKz+dNycI3/KPSEU6J4VR1T4sTpGI60jnhIbBkF+ydEpxDd2Sccg49nG+rN4XbW8udfy1ziGrPFllVnz0qWEX3HrL6+I4eLJxPiRk+Pz5gtXEEljyCjKd/3zd9KhKyzwJT3ee5jhKsMgN3+V5B5cCrudqq5uo4CF7xoQUe//BLDKeqNSb3hnSLtIkYBJZwwAeNJKr/2G/1j6yBpzRtWDytqpuZ1nm+xL6qqo7Me68RjaF36hyrLTGcqsJiWy+6fIsHuGhatc7sZhH3Mxos/bQHmcQYAnw8wZLFuPDGkYHQtlQ0YBUtJgAitBdmVWHq/bZAyJJArkQEJYZZ1deqhWH6+iTLqCWSePngrOepIjtrRrTemQWW082PznvbTgjxsnsOWf2DIU8ese9rdzGeJOytEBGVVyocYd5jkhhtBPTHBzn5wIJu/kWugO9v8m2nnG1el3CGbNVZh/f1GgdTeBjoETxFVFResMb82JcbLLTFpM0LIwnauEDPJXe4/ppvQOfhZ/TLXBuGuasSTuWQsBNx/scL7QltrgCrXG6wPupdSAA8seiZDHGPUKvMK4vpW8QlhikIcK9LspyWi9H7DF/1Rb7jXn74XX6+GaD5V5Xz8Cka1/WiCO4IjYkoaqO46E1esKC64VeUFawdTc6NB63wPbK8qNAnxiAYT3ekWx4rtMe3LhtckxssnL/OE3TDPxcTAJe7R/QcS1tAnylT1Q/bhGxYhNih13nj+7zq7NPO2gtnau/jRxJ4x3DZEHfXolWK1KDM7lPaVF6wgD8zC+QDyzzvZfFgdkIbIyKLjJEHc23wJm/KHEHqyHv1Tnj/cns4mKVhWk4KgHWgU2gcuMfmHN4Q9xgjLlG8KcW6XMKCHFV9tkQYqV/K+YivPI9XncOP7uPHMviex/m6i/wh6z16TVLu83/Oyo1tnAr4BhnBwsDkM2KqZUuy/psY3IBNUAJoTGvAx3KC7QbdaMfLjSOh0ldYOGVXk3O5e2wdcyPlC5oYzg4lfwPwPjyRqZ4v+4oAkOGN0zy009pDha+XHb7fWv4lH2Heys+fbLzTpHmta6BGbXbICxZSY9m46wtiSA4WZlXQ2Ehqy+91rZXiWU5wuvnXaPHxtqcbR5ZlImkxARBuyaX3iDeZ++OPxxwKK9cW45i1ej3qX1DwcIF+wXJH5hYML2anAvfnik826yrN2q3zDbd7Kz/ng8xd9dWZpgd554gsYCEHbUO60Chv1DpYKAqRV8nB+suCF9u+hiNaUMHOgXphBBEberLlUfYjDnqyDHQopKX3+HcxsIjE4hqToqO22mw/qb4LNR+tvPmKuBOYLxtPemDVsA7FeVfKBOwKf5Em01xP37GRjs2W+ptx457K83mPTRawEtuE3yKcubBpYOWwZEUk0UoIFrwm+N1cFj+tbXt/qvm3zaaZTwxYHH7E6Jjp5l+5q74iXjykHrEF/fwuMUv4tjSLurk1aKQ5q4dT2BycaP096uysubh12PSQOMO4cnFFBiY90LKhS2wbNuj6imUZCi0u741ifgimXWzAZn/uOq6XECwsXX8k5bC5/FqmGGz117cZBj4JYOl7s1AfV/WFYL1zoJGr/gb+nG26v8k8v/TzcB1BJ6Fx3i/p890azHm2WmuLyq7wPGO4813U1lP5hc6BBvxZbuBYqg/0FhRE+E4+WcBiE5n7Mq2+Yt6sFZTWbUlTbq80YA2NaisL72VI2WuvstddLfRlzbfRkWsL1gGVxl57JSoz2B3P3tEONnPV38Q7c+q7m0yzS0XeqRca5/bkKV/bPF8SGGlWBqxefQGvOhdjmb43x/cmVnj/6bjDf066NmDBRckyTDK0tq6BamPnWzp9Yb3RCS8A3sQqvGjB4qZ4/YselWAtWisuMHR9AJug2TQ517hR/Kl90b9RlAerSfV3QX3WXeNvV2mGNK5qYaKOIbLZOLVcAiBSo3B/Bb0c81/DalbSM6IZbGcDt0n7RvQlSg9WjKpn84Hddccecldd4JspQJc01Lx8R0IH1L7LEylYXk7wy9UKv35Pxbl5BY+W6Uf8TE7HifbnxOLOMWtfWxOwwLdDJdx1r/7jgH+1G/odtZcJP4b6H2DOFZgAKJqhD+TYEKi4TpzrvF3nUGwCK04Dxx11l6N6Uy0Ph3aRKAuWtZs3x/KdD3lqvuHvQLPVXTve+hSGqgVvh+qc+iN31jan8B7rqsGaOsqrr2Hf01V+z8bExv85GsT+MHa+Iyrzs1EuNJnCYGFahKIxpVrGqh1ko6St/rrWYXNAAuCV8XPChEv0LyAAFV6JLoyz0d+j2mydb9ggKNTG25rMFkkaJwqwnKP8eLYQSKq/xB+mE6XfUpc+NNi9t214yFdM56AaHh1nxaJLrfqrfM9j/HSpGLZf6bD1CjEEJth45XBfFkt3LB4I/sPS9+ZiQBRsmsaNWapGxcDCbEgs95wAO8//bB0esdbdgLo5ar/bbujz/9cT2WZfOLxkgFsRqT5dJgZcfNWM6qqBnlS12RnxPeJHKM42LmobHpaqcVYP1vghvvdpvvHy0+JHdd/muzeN6JM2JHRihlzUH7xdDqmaYvLjmotuOyWovlaIRrnGg5fHTfN9/8tXfl74ZM3XhE96nQiZregzxIwGg6/AlurydoNeGbAmWzYJNrvqgdAfazGesNbfJMznay9tH+o9FaEr07EEQKxADCEO7YJpAbhcdHl/zjc49OvSQzjMlrtHUcef7an6knawRcLGWT1Yi34/vvrL/hFv/PcRMWEoRJejQvEtwoTxgfQ218Brp1QdAlL4Kug/X3rGgjl1ofiBc6dqf8Y7T+DtjnHPxaJhuzR7ZIn+GGb9h9l+d79KbrDQK6gnHIN5qrKVTTHjFHN0uWq+BZPZVyIidI8V2kMitRfuJdZoMInwJzyTtap3HbVXLLxZu97QtT2c4YyVqNMf8YrRwD7dIWkbZ/Vg9T7LG2NEmE4zwrHgjoVUkXobukIbDy4GyEDP5Me89kHeFxCA5ht4hR/P4tXXLbzTvpG3dPhmhYgrC4vpjjvCM6VnzKofiVPF8/t0+2UFC64EwVXd8XKYgvA7zKl/LHL/TaYqIIiEk4pgrddsmsbkmvksBPO/7kaMg75IEQRhTQ707LPXfo99APAZunYEdZv5n4WqfE/Vl/F5fLnkjSON8Q59hRmN/0NXQlQI4Qt8Elm2M45FA8s5xpt28k03nja8Qp+NpfvPCptH3cwLHILdwEcIqbRj7S+yqSIs6xXnO5GBhTmggEjV11tM4+ELQgMJMTUhuHZB10BtUEFM1jDD5aoWpkSYUYpIcctU1QVHlBhUOZshC1lMVJcbkedV68Rp4G/CbxalwTrcx7Ek8SpjWBVij/WBHzXwi+bqeN1TfP13+OH3eE9gdiVSGCCFVS6rbQJD105R4SNstwndKS1YsJph3+DLMQatlkjEeRDtEdn6yjFVeoCZD/3HNAqbaSIYF15VuV59nqXh1gW8qr4OvEB8ANNsLJ5v+GGY4+YagIWnXLBJsv+zN0NXCKlIF4u6ZzK89bcAC4+lY0NtuYGLoAkQvXJXf401ZatxVEKwYOiIxs1lDNnVjqHgcqL1CYEA1fmoJPNKQMuyia1Y4Q09faURVLWrv2qm6efsSwAovrPVaFycBj6JNy2qi4CvTDMbCcDC4zfR5bemWle15u5BMbcaidJhgsXyLf2fRLXaJtAOtTlr1jGzt8PQJQlYMJXY9FMcoSJ2gLngixGneP8K/6RX9S9syjLd/J9w00TZzd39lbNNP1nAq/ILY20vjHS8wlCDjSWfLyZasJAzjwdaosuxNmFVFcJiI5b0jaUNK5abVNbDHDwrPvYpdBMgXG9puIUNEJGpgYATowzTgj4zJVJ3JdetenzRg3DeZMt/aQY7JOxmuNamm355akavOrdXny+r9zhasFjWx10HLBEs5nw4P9xHVW9MnQgz1L9iEzSZT0ITMA8Qpk7RtB0whftHiAQMVEsSYDFrXp1o/QNST2WawAJWIAtw4buSO94VFVhY9MOS+LBqKoIKIe0TyZ9IAfVfXrf0QMY+m0WuVl0t3wQucTgQfr4TrY8HtV7Dabvxtv9mSVefkJzVsCMEQwqUGBVYsJDE0Kk14gptEp+vj0TQEIX+PFv4DMK00jYBxgIk0bI5fASJXJohLXQecr07DD2fLrCUEYwcLCyWxWI3TO4OdkXuHCrsc7E1wVg6F7RE+AwF62rvbLVR+iaA15tlHHDV/xZgcq3YdkjcgyCmWp9tPtYArD+XCjE7hHGirBDWH7OHeQb1u94nrvN5/iOjTE0AB8+s+j5mchk73w5TsKevjDmflnouCKyowOqbEfLNcX4cXjJaiAohSQGpCtB8Sx/OVCj6XZHWnFLRI18TwJMkprucwzyoLBISUtDFvNv+IBJY0oD1e1HNPF1sl6RCbHnkH47Ygy5LRFqzAm3X13uQTfEQuoaLMoQgclRYPA4TTAJLSrCwipKlGBwblCbGhJVhLGMEz87zlZUvZuj+IDnax2yuIkNhqI2loyDKdlyVFtwjarY4a4SkDOSonAl8KArWw3mrjtmtWCEkLDCLbSFM5F0IE2GhgZJth6DvnPpeMdn1vKAjHRIBWH7BcstQCawIwTomPo4BTnCVgZOwQogAsg1eak1CnbLFVdS3pCyEiZRsu0WTS1xr2vo7/+QTZASwgONy8WACK3Kw2DSN7XQlbYVeLrezdG9ucRU126d0TdquWvU+CwMLQethE3vzRNsfhVTgpp+dOXwoB5Zo96yw6DviJwqzRyE+J9ryt6VJ/PzW1Qoi/46ZXEgGR74U0ogRHkY8JHQUj8A6DSybzZaZmZmamqrVavGnRqNhexdiM7AAsGC2v9fglKlC/xCfVsDOD/xSv9eq7bBOiz3FAHkHWGnNFv+cUXxEC1Z+fr5er8cWmDExMezP0dHRoBoL1k9kD+cMp0Jwr98gLt3ckGHxL2UN2w7LE3wmF1JNfMMigbWKoRBbNSUmJvLibqvYpAk7geGdALAiq0r4FXpdfL7qrtOTcNa87fp1GTC5TJqtZxofEoDFNlmdmJjgxV3m7HY7tpXLzs5WGCysN0dOX8Mnr+2Qcxf0yQsEViiwsJXc/v37MRoygJhphT3lkpOTFQaLsUW99RkBS6VSbdu2LV08oLTKy8uxjS828O3v7/enCkdkW+PReaadp+1XeNoiZI6DGltqvCugsUjwTPRjEVgkSGCRIIFFggQWgUWCBBYJElgkSGBR25EggUWCBBYJEljUdiRIYJEggUWCBBY1OgkSWCRIYJEggUWNToIEFgkSWCRIYFGjkyCBRYIEFgkSWNToJEhgkSCBRYIEFjU6CRJYJEhgkSCBRY1OggQWCRJYJEhgUaOTIIFFggQWCRJY1OgkSGCRIIFFggQWNToJElgkSGCRIIFFjU5gEVgkqBRYOp0uLS0tJycH+1MQWCQoGVg7duzAZmB14kFgkaA0YEFLYXsmXBiNxry8PAKLBKUBy+VyxcXF4cJkMuXm5hJYJCjZULhz5068YiPM0tJSAosEJQOruLgYGxfu3r0be2EG7FdIBx3hHkE9CxgQQzgdeGUPKvHTWKLS90PHGXIQWHR8AsCqqKiA+YVXxepXVlaGEvEqd0HYqri3t7egoMD3Z3V19ZEjR+Qr0Wq1ZmZmpqamYp7k8/WkpKR4PB6ZSrTZbIcOHYL3G3eKP5ubm9G2WVlZISwfJcCanJxEK+ACNz81NaUAVdPT04mJibjA68zMjKxlDQ4O4gcTExPD/iwqKqqvr5e1RLgJ+/r6gJGv0MOHD2/ZsiUg4CHhgZ/KwMAASty+fbvdbt+zZw9+P1VVVQGecKXB6urqYg6I48eP41oBsNDEmJyirPj4ePma2/9Ai7MLdPbBgwfx+0YHyFqixWJJSkpijkOghvuV9U7n5uZaW1uzs7P9+TYYDGsJlkajYUMSXrVarQLd7HA4kpOToUjw6nQ6lQRr69atKL2jo0PW0RChs4SEhImJCWgRXOAe5QYLAZXCwkKoRvZnd3d3gBt8DcAaHh5mQR5UBT8vBboZ4fCSkhJcHD16lJkFioHFLkZGRvx/3JJbdfv378doyNQV9BYsns2bN/t6XfIDGEFB4mLXrl14haKSz6Q7a1UNAVsHowNeca2MxkLgEl2LV1wrCVZDQwN6GlYI2JKpLGjibdu2pYsH7Ff2pqwaS6/XoyVBM9gFTy+99BKGAplmY/8Pl7O7ukBGoYYAAAAASUVORK5CYII=", - "description": "Displays changes to time-series data over time—for example, temperature or humidity readings.", + "description": "Displays changes to time series data over time—for example, temperature or humidity readings.", "descriptor": { "type": "timeseries", "sizeX": 8, diff --git a/application/src/main/data/json/system/widget_types/toggle_button.json b/application/src/main/data/json/system/widget_types/toggle_button.json index a7e2ae9312..e756b31e0f 100644 --- a/application/src/main/data/json/system/widget_types/toggle_button.json +++ b/application/src/main/data/json/system/widget_types/toggle_button.json @@ -2,8 +2,8 @@ "fqn": "toggle_button", "name": "Toggle button", "deprecated": false, - "image": "tb-image:VG9nZ2xlIGJ1dHRvbnMuc3Zn:IlRvZ2dsZSBidXR0b24iIHN5c3RlbSB3aWRnZXQgaW1hZ2U=;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjE2IiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIxNiAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGZpbHRlcj0idXJsKCNmaWx0ZXIwX2RfNDY5N18zNjcxMykiPgo8cmVjdCB4PSI4Ljc1IiB5PSIxNi43NSIgd2lkdGg9IjE5OC41IiBoZWlnaHQ9IjU4LjUiIHJ4PSIzLjI1IiBzdHJva2U9IiMxOTgwMzgiIHN0cm9rZS13aWR0aD0iMS41IiBzaGFwZS1yZW5kZXJpbmc9ImNyaXNwRWRnZXMiLz4KPG1hc2sgaWQ9Im1hc2swXzQ2OTdfMzY3MTMiIHN0eWxlPSJtYXNrLXR5cGU6YWxwaGEiIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHg9IjYyIiB5PSIzNCIgd2lkdGg9IjI1IiBoZWlnaHQ9IjI0Ij4KPHJlY3QgeD0iNjIuNSIgeT0iMzQiIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0iI0Q5RDlEOSIvPgo8L21hc2s+CjxnIG1hc2s9InVybCgjbWFzazBfNDY5N18zNjcxMykiPgo8cGF0aCBkPSJNNjguNSA0Mkg3Ny41VjQwQzc3LjUgMzkuMTY2NyA3Ny4yMDgzIDM4LjQ1ODMgNzYuNjI1IDM3Ljg3NUM3Ni4wNDE3IDM3LjI5MTcgNzUuMzMzMyAzNyA3NC41IDM3QzczLjY2NjcgMzcgNzIuOTU4MyAzNy4yOTE3IDcyLjM3NSAzNy44NzVDNzEuNzkxNyAzOC40NTgzIDcxLjUgMzkuMTY2NyA3MS41IDQwSDY5LjVDNjkuNSAzOC42MTY3IDY5Ljk4NzUgMzcuNDM3NSA3MC45NjI1IDM2LjQ2MjVDNzEuOTM3NSAzNS40ODc1IDczLjExNjcgMzUgNzQuNSAzNUM3NS44ODMzIDM1IDc3LjA2MjUgMzUuNDg3NSA3OC4wMzc1IDM2LjQ2MjVDNzkuMDEyNSAzNy40Mzc1IDc5LjUgMzguNjE2NyA3OS41IDQwVjQySDgwLjVDODEuMDUgNDIgODEuNTIwOCA0Mi4xOTU4IDgxLjkxMjUgNDIuNTg3NUM4Mi4zMDQyIDQyLjk3OTIgODIuNSA0My40NSA4Mi41IDQ0VjU0QzgyLjUgNTQuNTUgODIuMzA0MiA1NS4wMjA4IDgxLjkxMjUgNTUuNDEyNUM4MS41MjA4IDU1LjgwNDIgODEuMDUgNTYgODAuNSA1Nkg2OC41QzY3Ljk1IDU2IDY3LjQ3OTIgNTUuODA0MiA2Ny4wODc1IDU1LjQxMjVDNjYuNjk1OCA1NS4wMjA4IDY2LjUgNTQuNTUgNjYuNSA1NFY0NEM2Ni41IDQzLjQ1IDY2LjY5NTggNDIuOTc5MiA2Ny4wODc1IDQyLjU4NzVDNjcuNDc5MiA0Mi4xOTU4IDY3Ljk1IDQyIDY4LjUgNDJaTTY4LjUgNTRIODAuNVY0NEg2OC41VjU0Wk03NC41IDUxQzc1LjA1IDUxIDc1LjUyMDggNTAuODA0MiA3NS45MTI1IDUwLjQxMjVDNzYuMzA0MiA1MC4wMjA4IDc2LjUgNDkuNTUgNzYuNSA0OUM3Ni41IDQ4LjQ1IDc2LjMwNDIgNDcuOTc5MiA3NS45MTI1IDQ3LjU4NzVDNzUuNTIwOCA0Ny4xOTU4IDc1LjA1IDQ3IDc0LjUgNDdDNzMuOTUgNDcgNzMuNDc5MiA0Ny4xOTU4IDczLjA4NzUgNDcuNTg3NUM3Mi42OTU4IDQ3Ljk3OTIgNzIuNSA0OC40NSA3Mi41IDQ5QzcyLjUgNDkuNTUgNzIuNjk1OCA1MC4wMjA4IDczLjA4NzUgNTAuNDEyNUM3My40NzkyIDUwLjgwNDIgNzMuOTUgNTEgNzQuNSA1MVoiIGZpbGw9IiMxOTgwMzgiLz4KPC9nPgo8cGF0aCBkPSJNMTAyLjEzMSA0NS4yNVY0NS45NTMxQzEwMi4xMzEgNDYuOTE5OSAxMDIuMDA1IDQ3Ljc4NzEgMTAxLjc1MyA0OC41NTQ3QzEwMS41MDEgNDkuMzIyMyAxMDEuMTQxIDQ5Ljk3NTYgMTAwLjY3MiA1MC41MTQ2QzEwMC4yMDkgNTEuMDUzNyA5OS42NTIzIDUxLjQ2NjggOTkuMDAyIDUxLjc1MzlDOTguMzUxNiA1Mi4wMzUyIDk3LjYzMDkgNTIuMTc1OCA5Ni44Mzk4IDUyLjE3NThDOTYuMDU0NyA1Mi4xNzU4IDk1LjMzNjkgNTIuMDM1MiA5NC42ODY1IDUxLjc1MzlDOTQuMDQyIDUxLjQ2NjggOTMuNDgyNCA1MS4wNTM3IDkzLjAwNzggNTAuNTE0NkM5Mi41MzMyIDQ5Ljk3NTYgOTIuMTY0MSA0OS4zMjIzIDkxLjkwMDQgNDguNTU0N0M5MS42NDI2IDQ3Ljc4NzEgOTEuNTEzNyA0Ni45MTk5IDkxLjUxMzcgNDUuOTUzMVY0NS4yNUM5MS41MTM3IDQ0LjI4MzIgOTEuNjQyNiA0My40MTg5IDkxLjkwMDQgNDIuNjU3MkM5Mi4xNTgyIDQxLjg4OTYgOTIuNTIxNSA0MS4yMzYzIDkyLjk5MDIgNDAuNjk3M0M5My40NjQ4IDQwLjE1MjMgOTQuMDI0NCAzOS43MzkzIDk0LjY2ODkgMzkuNDU4Qzk1LjMxOTMgMzkuMTcwOSA5Ni4wMzcxIDM5LjAyNzMgOTYuODIyMyAzOS4wMjczQzk3LjYxMzMgMzkuMDI3MyA5OC4zMzQgMzkuMTcwOSA5OC45ODQ0IDM5LjQ1OEM5OS42MzQ4IDM5LjczOTMgMTAwLjE5NCA0MC4xNTIzIDEwMC42NjMgNDAuNjk3M0MxMDEuMTMyIDQxLjIzNjMgMTAxLjQ5MiA0MS44ODk2IDEwMS43NDQgNDIuNjU3MkMxMDIuMDAyIDQzLjQxODkgMTAyLjEzMSA0NC4yODMyIDEwMi4xMzEgNDUuMjVaTTk5LjkyNDggNDUuOTUzMVY0NS4yMzI0Qzk5LjkyNDggNDQuNTE3NiA5OS44NTQ1IDQzLjg4NzcgOTkuNzEzOSA0My4zNDI4Qzk5LjU3OTEgNDIuNzkyIDk5LjM3NyA0Mi4zMzIgOTkuMTA3NCA0MS45NjI5Qzk4Ljg0MzggNDEuNTg3OSA5OC41MTg2IDQxLjMwNjYgOTguMTMxOCA0MS4xMTkxQzk3Ljc0NTEgNDAuOTI1OCA5Ny4zMDg2IDQwLjgyOTEgOTYuODIyMyA0MC44MjkxQzk2LjMzNTkgNDAuODI5MSA5NS45MDIzIDQwLjkyNTggOTUuNTIxNSA0MS4xMTkxQzk1LjE0MDYgNDEuMzA2NiA5NC44MTU0IDQxLjU4NzkgOTQuNTQ1OSA0MS45NjI5Qzk0LjI4MjIgNDIuMzMyIDk0LjA4MDEgNDIuNzkyIDkzLjkzOTUgNDMuMzQyOEM5My43OTg4IDQzLjg4NzcgOTMuNzI4NSA0NC41MTc2IDkzLjcyODUgNDUuMjMyNFY0NS45NTMxQzkzLjcyODUgNDYuNjY4IDkzLjc5ODggNDcuMzAwOCA5My45Mzk1IDQ3Ljg1MTZDOTQuMDgwMSA0OC40MDIzIDk0LjI4NTIgNDguODY4MiA5NC41NTQ3IDQ5LjI0OUM5NC44MzAxIDQ5LjYyNCA5NS4xNTgyIDQ5LjkwODIgOTUuNTM5MSA1MC4xMDE2Qzk1LjkxOTkgNTAuMjg5MSA5Ni4zNTM1IDUwLjM4MjggOTYuODM5OCA1MC4zODI4Qzk3LjMzMiA1MC4zODI4IDk3Ljc2ODYgNTAuMjg5MSA5OC4xNDk0IDUwLjEwMTZDOTguNTMwMyA0OS45MDgyIDk4Ljg1MjUgNDkuNjI0IDk5LjExNjIgNDkuMjQ5Qzk5LjM3OTkgNDguODY4MiA5OS41NzkxIDQ4LjQwMjMgOTkuNzEzOSA0Ny44NTE2Qzk5Ljg1NDUgNDcuMzAwOCA5OS45MjQ4IDQ2LjY2OCA5OS45MjQ4IDQ1Ljk1MzFaTTEwNi40MDMgNDQuMzE4NFY1NS42NTYySDEwNC4yODVWNDIuNDkwMkgxMDYuMjM2TDEwNi40MDMgNDQuMzE4NFpNMTEyLjU5OSA0Ny4xNTcyVjQ3LjM0MThDMTEyLjU5OSA0OC4wMzMyIDExMi41MTcgNDguNjc0OCAxMTIuMzUzIDQ5LjI2NjZDMTEyLjE5NSA0OS44NTI1IDExMS45NTggNTAuMzY1MiAxMTEuNjQxIDUwLjgwNDdDMTExLjMzMSA1MS4yMzgzIDExMC45NDcgNTEuNTc1MiAxMTAuNDkgNTEuODE1NEMxMTAuMDMzIDUyLjA1NTcgMTA5LjUwNSA1Mi4xNzU4IDEwOC45MDggNTIuMTc1OEMxMDguMzE2IDUyLjE3NTggMTA3Ljc5NyA1Mi4wNjc0IDEwNy4zNTIgNTEuODUwNkMxMDYuOTEzIDUxLjYyNzkgMTA2LjU0MSA1MS4zMTQ1IDEwNi4yMzYgNTAuOTEwMkMxMDUuOTMxIDUwLjUwNTkgMTA1LjY4NSA1MC4wMzEyIDEwNS40OTggNDkuNDg2M0MxMDUuMzE2IDQ4LjkzNTUgMTA1LjE4NyA0OC4zMzIgMTA1LjExMSA0Ny42NzU4VjQ2Ljk2MzlDMTA1LjE4NyA0Ni4yNjY2IDEwNS4zMTYgNDUuNjMzOCAxMDUuNDk4IDQ1LjA2NTRDMTA1LjY4NSA0NC40OTcxIDEwNS45MzEgNDQuMDA3OCAxMDYuMjM2IDQzLjU5NzdDMTA2LjU0MSA0My4xODc1IDEwNi45MTMgNDIuODcxMSAxMDcuMzUyIDQyLjY0ODRDMTA3Ljc5MiA0Mi40MjU4IDEwOC4zMDQgNDIuMzE0NSAxMDguODkgNDIuMzE0NUMxMDkuNDg4IDQyLjMxNDUgMTEwLjAxOCA0Mi40MzE2IDExMC40ODEgNDIuNjY2QzExMC45NDQgNDIuODk0NSAxMTEuMzM0IDQzLjIyMjcgMTExLjY1IDQzLjY1MDRDMTExLjk2NiA0NC4wNzIzIDExMi4yMDQgNDQuNTgyIDExMi4zNjIgNDUuMTc5N0MxMTIuNTIgNDUuNzcxNSAxMTIuNTk5IDQ2LjQzMDcgMTEyLjU5OSA0Ny4xNTcyWk0xMTAuNDgxIDQ3LjM0MThWNDcuMTU3MkMxMTAuNDgxIDQ2LjcxNzggMTEwLjQ0IDQ2LjMxMDUgMTEwLjM1OCA0NS45MzU1QzExMC4yNzYgNDUuNTU0NyAxMTAuMTQ3IDQ1LjIyMDcgMTA5Ljk3MSA0NC45MzM2QzEwOS43OTYgNDQuNjQ2NSAxMDkuNTcgNDQuNDIzOCAxMDkuMjk1IDQ0LjI2NTZDMTA5LjAyNSA0NC4xMDE2IDEwOC43IDQ0LjAxOTUgMTA4LjMxOSA0NC4wMTk1QzEwNy45NDQgNDQuMDE5NSAxMDcuNjIyIDQ0LjA4NCAxMDcuMzUyIDQ0LjIxMjlDMTA3LjA4MyA0NC4zMzU5IDEwNi44NTcgNDQuNTA4OCAxMDYuNjc1IDQ0LjczMTRDMTA2LjQ5NCA0NC45NTQxIDEwNi4zNTMgNDUuMjE0OCAxMDYuMjU0IDQ1LjUxMzdDMTA2LjE1NCA0NS44MDY2IDEwNi4wODQgNDYuMTI2IDEwNi4wNDMgNDYuNDcxN1Y0OC4xNzY4QzEwNi4xMTMgNDguNTk4NiAxMDYuMjMzIDQ4Ljk4NTQgMTA2LjQwMyA0OS4zMzY5QzEwNi41NzMgNDkuNjg4NSAxMDYuODEzIDQ5Ljk2OTcgMTA3LjEyNCA1MC4xODA3QzEwNy40NCA1MC4zODU3IDEwNy44NDQgNTAuNDg4MyAxMDguMzM3IDUwLjQ4ODNDMTA4LjcxNyA1MC40ODgzIDEwOS4wNDMgNTAuNDA2MiAxMDkuMzEyIDUwLjI0MjJDMTA5LjU4MiA1MC4wNzgxIDEwOS44MDEgNDkuODUyNSAxMDkuOTcxIDQ5LjU2NTRDMTEwLjE0NyA0OS4yNzI1IDExMC4yNzYgNDguOTM1NSAxMTAuMzU4IDQ4LjU1NDdDMTEwLjQ0IDQ4LjE3MzggMTEwLjQ4MSA0Ny43Njk1IDExMC40ODEgNDcuMzQxOFpNMTE4Ljc0MyA1Mi4xNzU4QzExOC4wNCA1Mi4xNzU4IDExNy40MDQgNTIuMDYxNSAxMTYuODM2IDUxLjgzM0MxMTYuMjc0IDUxLjU5ODYgMTE1Ljc5MyA1MS4yNzM0IDExNS4zOTUgNTAuODU3NEMxMTUuMDAyIDUwLjQ0MTQgMTE0LjcgNDkuOTUyMSAxMTQuNDg5IDQ5LjM4OTZDMTE0LjI3OSA0OC44MjcxIDExNC4xNzMgNDguMjIwNyAxMTQuMTczIDQ3LjU3MDNWNDcuMjE4OEMxMTQuMTczIDQ2LjQ3NDYgMTE0LjI4MSA0NS44MDA4IDExNC40OTggNDUuMTk3M0MxMTQuNzE1IDQ0LjU5MzggMTE1LjAxNyA0NC4wNzgxIDExNS40MDQgNDMuNjUwNEMxMTUuNzkgNDMuMjE2OCAxMTYuMjQ3IDQyLjg4NTcgMTE2Ljc3NSA0Mi42NTcyQzExNy4zMDIgNDIuNDI4NyAxMTcuODczIDQyLjMxNDUgMTE4LjQ4OCA0Mi4zMTQ1QzExOS4xNjggNDIuMzE0NSAxMTkuNzYzIDQyLjQyODcgMTIwLjI3MyA0Mi42NTcyQzEyMC43ODIgNDIuODg1NyAxMjEuMjA0IDQzLjIwOCAxMjEuNTM4IDQzLjYyNEMxMjEuODc4IDQ0LjAzNDIgMTIyLjEzIDQ0LjUyMzQgMTIyLjI5NCA0NS4wOTE4QzEyMi40NjQgNDUuNjYwMiAxMjIuNTQ5IDQ2LjI4NzEgMTIyLjU0OSA0Ni45NzI3VjQ3Ljg3NzlIMTE1LjIwMVY0Ni4zNTc0SDEyMC40NTdWNDYuMTkwNEMxMjAuNDQ2IDQ1LjgwOTYgMTIwLjM2OSA0NS40NTIxIDEyMC4yMjkgNDUuMTE4MkMxMjAuMDk0IDQ0Ljc4NDIgMTE5Ljg4NiA0NC41MTQ2IDExOS42MDUgNDQuMzA5NkMxMTkuMzIzIDQ0LjEwNDUgMTE4Ljk0OCA0NC4wMDIgMTE4LjQ4IDQ0LjAwMkMxMTguMTI4IDQ0LjAwMiAxMTcuODE1IDQ0LjA3ODEgMTE3LjUzOSA0NC4yMzA1QzExNy4yNyA0NC4zNzcgMTE3LjA0NCA0NC41OTA4IDExNi44NjIgNDQuODcyMUMxMTYuNjgxIDQ1LjE1MzMgMTE2LjU0IDQ1LjQ5MzIgMTE2LjQ0MSA0NS44OTE2QzExNi4zNDcgNDYuMjg0MiAxMTYuMyA0Ni43MjY2IDExNi4zIDQ3LjIxODhWNDcuNTcwM0MxMTYuMyA0Ny45ODYzIDExNi4zNTYgNDguMzczIDExNi40NjcgNDguNzMwNUMxMTYuNTg0IDQ5LjA4MiAxMTYuNzU0IDQ5LjM4OTYgMTE2Ljk3NyA0OS42NTMzQzExNy4xOTkgNDkuOTE3IDExNy40NjkgNTAuMTI1IDExNy43ODUgNTAuMjc3M0MxMTguMTAyIDUwLjQyMzggMTE4LjQ2MiA1MC40OTcxIDExOC44NjYgNTAuNDk3MUMxMTkuMzc2IDUwLjQ5NzEgMTE5LjgzIDUwLjM5NDUgMTIwLjIyOSA1MC4xODk1QzEyMC42MjcgNDkuOTg0NCAxMjAuOTczIDQ5LjY5NDMgMTIxLjI2NiA0OS4zMTkzTDEyMi4zODIgNTAuNDAwNEMxMjIuMTc3IDUwLjY5OTIgMTIxLjkxIDUwLjk4NjMgMTIxLjU4MiA1MS4yNjE3QzEyMS4yNTQgNTEuNTMxMiAxMjAuODUzIDUxLjc1MSAxMjAuMzc4IDUxLjkyMDlDMTE5LjkwOSA1Mi4wOTA4IDExOS4zNjQgNTIuMTc1OCAxMTguNzQzIDUyLjE3NThaTTEyNi40NTIgNDQuNTIwNVY1MkgxMjQuMzM0VjQyLjQ5MDJIMTI2LjMyOUwxMjYuNDUyIDQ0LjUyMDVaTTEyNi4wNzQgNDYuODkzNkwxMjUuMzg4IDQ2Ljg4NDhDMTI1LjM5NCA0Ni4yMTA5IDEyNS40ODggNDUuNTkyOCAxMjUuNjcgNDUuMDMwM0MxMjUuODU3IDQ0LjQ2NzggMTI2LjExNSA0My45ODQ0IDEyNi40NDMgNDMuNTgwMUMxMjYuNzc3IDQzLjE3NTggMTI3LjE3NiA0Mi44NjUyIDEyNy42MzggNDIuNjQ4NEMxMjguMTAxIDQyLjQyNTggMTI4LjYxNyA0Mi4zMTQ1IDEyOS4xODUgNDIuMzE0NUMxMjkuNjQyIDQyLjMxNDUgMTMwLjA1NSA0Mi4zNzg5IDEzMC40MjUgNDIuNTA3OEMxMzAuOCA0Mi42MzA5IDEzMS4xMTkgNDIuODMzIDEzMS4zODMgNDMuMTE0M0MxMzEuNjUyIDQzLjM5NTUgMTMxLjg1NyA0My43NjE3IDEzMS45OTggNDQuMjEyOUMxMzIuMTM4IDQ0LjY1ODIgMTMyLjIwOSA0NS4yMDYxIDEzMi4yMDkgNDUuODU2NFY1MkgxMzAuMDgyVjQ1Ljg0NzdDMTMwLjA4MiA0NS4zOTA2IDEzMC4wMTQgNDUuMDMwMyAxMjkuODggNDQuNzY2NkMxMjkuNzUxIDQ0LjQ5NzEgMTI5LjU2IDQ0LjMwNjYgMTI5LjMwOCA0NC4xOTUzQzEyOS4wNjIgNDQuMDc4MSAxMjguNzU1IDQ0LjAxOTUgMTI4LjM4NiA0NC4wMTk1QzEyOC4wMjIgNDQuMDE5NSAxMjcuNjk3IDQ0LjA5NTcgMTI3LjQxIDQ0LjI0OEMxMjcuMTIzIDQ0LjQwMDQgMTI2Ljg4IDQ0LjYwODQgMTI2LjY4IDQ0Ljg3MjFDMTI2LjQ4NyA0NS4xMzU3IDEyNi4zMzggNDUuNDQwNCAxMjYuMjMyIDQ1Ljc4NjFDMTI2LjEyNyA0Ni4xMzE4IDEyNi4wNzQgNDYuNTAxIDEyNi4wNzQgNDYuODkzNlpNMTM4LjcxMyA1Mi4xNzU4QzEzOC4wMSA1Mi4xNzU4IDEzNy4zNzQgNTIuMDYxNSAxMzYuODA2IDUxLjgzM0MxMzYuMjQ0IDUxLjU5ODYgMTM1Ljc2MyA1MS4yNzM0IDEzNS4zNjUgNTAuODU3NEMxMzQuOTcyIDUwLjQ0MTQgMTM0LjY3IDQ5Ljk1MjEgMTM0LjQ1OSA0OS4zODk2QzEzNC4yNDggNDguODI3MSAxMzQuMTQzIDQ4LjIyMDcgMTM0LjE0MyA0Ny41NzAzVjQ3LjIxODhDMTM0LjE0MyA0Ni40NzQ2IDEzNC4yNTEgNDUuODAwOCAxMzQuNDY4IDQ1LjE5NzNDMTM0LjY4NSA0NC41OTM4IDEzNC45ODcgNDQuMDc4MSAxMzUuMzczIDQzLjY1MDRDMTM1Ljc2IDQzLjIxNjggMTM2LjIxNyA0Mi44ODU3IDEzNi43NDUgNDIuNjU3MkMxMzcuMjcyIDQyLjQyODcgMTM3Ljg0MyA0Mi4zMTQ1IDEzOC40NTggNDIuMzE0NUMxMzkuMTM4IDQyLjMxNDUgMTM5LjczMyA0Mi40Mjg3IDE0MC4yNDMgNDIuNjU3MkMxNDAuNzUyIDQyLjg4NTcgMTQxLjE3NCA0My4yMDggMTQxLjUwOCA0My42MjRDMTQxLjg0OCA0NC4wMzQyIDE0Mi4xIDQ0LjUyMzQgMTQyLjI2NCA0NS4wOTE4QzE0Mi40MzQgNDUuNjYwMiAxNDIuNTE5IDQ2LjI4NzEgMTQyLjUxOSA0Ni45NzI3VjQ3Ljg3NzlIMTM1LjE3MVY0Ni4zNTc0SDE0MC40MjdWNDYuMTkwNEMxNDAuNDE1IDQ1LjgwOTYgMTQwLjMzOSA0NS40NTIxIDE0MC4xOTkgNDUuMTE4MkMxNDAuMDY0IDQ0Ljc4NDIgMTM5Ljg1NiA0NC41MTQ2IDEzOS41NzUgNDQuMzA5NkMxMzkuMjkzIDQ0LjEwNDUgMTM4LjkxOCA0NC4wMDIgMTM4LjQ1IDQ0LjAwMkMxMzguMDk4IDQ0LjAwMiAxMzcuNzg1IDQ0LjA3ODEgMTM3LjUwOSA0NC4yMzA1QzEzNy4yNCA0NC4zNzcgMTM3LjAxNCA0NC41OTA4IDEzNi44MzIgNDQuODcyMUMxMzYuNjUxIDQ1LjE1MzMgMTM2LjUxIDQ1LjQ5MzIgMTM2LjQxMSA0NS44OTE2QzEzNi4zMTcgNDYuMjg0MiAxMzYuMjcgNDYuNzI2NiAxMzYuMjcgNDcuMjE4OFY0Ny41NzAzQzEzNi4yNyA0Ny45ODYzIDEzNi4zMjYgNDguMzczIDEzNi40MzcgNDguNzMwNUMxMzYuNTU0IDQ5LjA4MiAxMzYuNzI0IDQ5LjM4OTYgMTM2Ljk0NyA0OS42NTMzQzEzNy4xNjkgNDkuOTE3IDEzNy40MzkgNTAuMTI1IDEzNy43NTUgNTAuMjc3M0MxMzguMDcyIDUwLjQyMzggMTM4LjQzMiA1MC40OTcxIDEzOC44MzYgNTAuNDk3MUMxMzkuMzQ2IDUwLjQ5NzEgMTM5LjggNTAuMzk0NSAxNDAuMTk5IDUwLjE4OTVDMTQwLjU5NyA0OS45ODQ0IDE0MC45NDMgNDkuNjk0MyAxNDEuMjM2IDQ5LjMxOTNMMTQyLjM1MiA1MC40MDA0QzE0Mi4xNDcgNTAuNjk5MiAxNDEuODggNTAuOTg2MyAxNDEuNTUyIDUxLjI2MTdDMTQxLjIyNCA1MS41MzEyIDE0MC44MjMgNTEuNzUxIDE0MC4zNDggNTEuOTIwOUMxMzkuODc5IDUyLjA5MDggMTM5LjMzNCA1Mi4xNzU4IDEzOC43MTMgNTIuMTc1OFpNMTUwLjEyMiA1MC4wMzEyVjM4LjVIMTUyLjI0OVY1MkgxNTAuMzI0TDE1MC4xMjIgNTAuMDMxMlpNMTQzLjkzNSA0Ny4zNTA2VjQ3LjE2NkMxNDMuOTM1IDQ2LjQ0NTMgMTQ0LjAyIDQ1Ljc4OTEgMTQ0LjE4OSA0NS4xOTczQzE0NC4zNTkgNDQuNTk5NiAxNDQuNjA1IDQ0LjA4NjkgMTQ0LjkyOCA0My42NTkyQzE0NS4yNSA0My4yMjU2IDE0NS42NDMgNDIuODk0NSAxNDYuMTA1IDQyLjY2NkMxNDYuNTY4IDQyLjQzMTYgMTQ3LjA5IDQyLjMxNDUgMTQ3LjY3IDQyLjMxNDVDMTQ4LjI0NCA0Mi4zMTQ1IDE0OC43NDggNDIuNDI1OCAxNDkuMTgyIDQyLjY0ODRDMTQ5LjYxNSA0Mi44NzExIDE0OS45ODQgNDMuMTkwNCAxNTAuMjg5IDQzLjYwNjRDMTUwLjU5NCA0NC4wMTY2IDE1MC44MzcgNDQuNTA4OCAxNTEuMDE5IDQ1LjA4M0MxNTEuMiA0NS42NTE0IDE1MS4zMjkgNDYuMjg0MiAxNTEuNDA1IDQ2Ljk4MTRWNDcuNTcwM0MxNTEuMzI5IDQ4LjI1IDE1MS4yIDQ4Ljg3MTEgMTUxLjAxOSA0OS40MzM2QzE1MC44MzcgNDkuOTk2MSAxNTAuNTk0IDUwLjQ4MjQgMTUwLjI4OSA1MC44OTI2QzE0OS45ODQgNTEuMzAyNyAxNDkuNjEyIDUxLjYxOTEgMTQ5LjE3MyA1MS44NDE4QzE0OC43MzkgNTIuMDY0NSAxNDguMjMyIDUyLjE3NTggMTQ3LjY1MiA1Mi4xNzU4QzE0Ny4wNzggNTIuMTc1OCAxNDYuNTYgNTIuMDU1NyAxNDYuMDk3IDUxLjgxNTRDMTQ1LjY0IDUxLjU3NTIgMTQ1LjI1IDUxLjIzODMgMTQ0LjkyOCA1MC44MDQ3QzE0NC42MDUgNTAuMzcxMSAxNDQuMzU5IDQ5Ljg2MTMgMTQ0LjE4OSA0OS4yNzU0QzE0NC4wMiA0OC42ODM2IDE0My45MzUgNDguMDQyIDE0My45MzUgNDcuMzUwNlpNMTQ2LjA1MyA0Ny4xNjZWNDcuMzUwNkMxNDYuMDUzIDQ3Ljc4NDIgMTQ2LjA5MSA0OC4xODg1IDE0Ni4xNjcgNDguNTYzNUMxNDYuMjQ5IDQ4LjkzODUgMTQ2LjM3NSA0OS4yNjk1IDE0Ni41NDUgNDkuNTU2NkMxNDYuNzE1IDQ5LjgzNzkgMTQ2LjkzNSA1MC4wNjA1IDE0Ny4yMDQgNTAuMjI0NkMxNDcuNDc5IDUwLjM4MjggMTQ3LjgwOCA1MC40NjE5IDE0OC4xODggNTAuNDYxOUMxNDguNjY5IDUwLjQ2MTkgMTQ5LjA2NCA1MC4zNTY0IDE0OS4zNzUgNTAuMTQ1NUMxNDkuNjg2IDQ5LjkzNDYgMTQ5LjkyOSA0OS42NTA0IDE1MC4xMDQgNDkuMjkzQzE1MC4yODYgNDguOTI5NyAxNTAuNDA5IDQ4LjUyNTQgMTUwLjQ3NCA0OC4wODAxVjQ2LjQ4OTNDMTUwLjQzOCA0Ni4xNDM2IDE1MC4zNjUgNDUuODIxMyAxNTAuMjU0IDQ1LjUyMjVDMTUwLjE0OCA0NS4yMjM2IDE1MC4wMDUgNDQuOTYyOSAxNDkuODIzIDQ0Ljc0MDJDMTQ5LjY0MiA0NC41MTE3IDE0OS40MTYgNDQuMzM1OSAxNDkuMTQ2IDQ0LjIxMjlDMTQ4Ljg4MyA0NC4wODQgMTQ4LjU2OSA0NC4wMTk1IDE0OC4yMDYgNDQuMDE5NUMxNDcuODE5IDQ0LjAxOTUgMTQ3LjQ5MSA0NC4xMDE2IDE0Ny4yMjIgNDQuMjY1NkMxNDYuOTUyIDQ0LjQyOTcgMTQ2LjcyOSA0NC42NTUzIDE0Ni41NTQgNDQuOTQyNEMxNDYuMzg0IDQ1LjIyOTUgMTQ2LjI1OCA0NS41NjM1IDE0Ni4xNzYgNDUuOTQ0M0MxNDYuMDk0IDQ2LjMyNTIgMTQ2LjA1MyA0Ni43MzI0IDE0Ni4wNTMgNDcuMTY2WiIgZmlsbD0iIzE5ODAzOCIvPgo8L2c+CjxtYXNrIGlkPSJwYXRoLTYtaW5zaWRlLTFfNDY5N18zNjcxMyIgZmlsbD0id2hpdGUiPgo8cGF0aCBkPSJNOCA4OEM4IDg1Ljc5MDkgOS43OTA4NiA4NCAxMiA4NEgyMDRDMjA2LjIwOSA4NCAyMDggODUuNzkwOSAyMDggODhWMTQwQzIwOCAxNDIuMjA5IDIwNi4yMDkgMTQ0IDIwNCAxNDRIMTJDOS43OTA4NiAxNDQgOCAxNDIuMjA5IDggMTQwVjg4WiIvPgo8L21hc2s+CjxwYXRoIGQ9Ik04IDg4QzggODUuNzkwOSA5Ljc5MDg2IDg0IDEyIDg0SDIwNEMyMDYuMjA5IDg0IDIwOCA4NS43OTA5IDIwOCA4OFYxNDBDMjA4IDE0Mi4yMDkgMjA2LjIwOSAxNDQgMjA0IDE0NEgxMkM5Ljc5MDg2IDE0NCA4IDE0Mi4yMDkgOCAxNDBWODhaIiBmaWxsPSIjRDEyNzMwIi8+CjxwYXRoIGQ9Ik04IDg0SDIwOEg4Wk0yMDggMTQwQzIwOCAxNDMuMzE0IDIwNS4zMTQgMTQ2IDIwMiAxNDZIMTRDMTAuNjg2MyAxNDYgOCAxNDMuMzE0IDggMTQwQzggMTQxLjEwNSA5Ljc5MDg2IDE0MiAxMiAxNDJIMjA0QzIwNi4yMDkgMTQyIDIwOCAxNDEuMTA1IDIwOCAxNDBaTTggMTQ0Vjg0VjE0NFpNMjA4IDg0VjE0NFY4NFoiIGZpbGw9IiNEMTI3MzAiIG1hc2s9InVybCgjcGF0aC02LWluc2lkZS0xXzQ2OTdfMzY3MTMpIi8+CjxtYXNrIGlkPSJtYXNrMV80Njk3XzM2NzEzIiBzdHlsZT0ibWFzay10eXBlOmFscGhhIiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4PSI2NSIgeT0iMTAyIiB3aWR0aD0iMjUiIGhlaWdodD0iMjQiPgo8cmVjdCB4PSI2NS41IiB5PSIxMDIiIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0iI0Q5RDlEOSIvPgo8L21hc2s+CjxnIG1hc2s9InVybCgjbWFzazFfNDY5N18zNjcxMykiPgo8cGF0aCBkPSJNNzEuNSAxMjRDNzAuOTUgMTI0IDcwLjQ3OTIgMTIzLjgwNCA3MC4wODc1IDEyMy40MTNDNjkuNjk1OCAxMjMuMDIxIDY5LjUgMTIyLjU1IDY5LjUgMTIyVjExMkM2OS41IDExMS40NSA2OS42OTU4IDExMC45NzkgNzAuMDg3NSAxMTAuNTg4QzcwLjQ3OTIgMTEwLjE5NiA3MC45NSAxMTAgNzEuNSAxMTBINzIuNVYxMDhDNzIuNSAxMDYuNjE3IDcyLjk4NzUgMTA1LjQzOCA3My45NjI1IDEwNC40NjJDNzQuOTM3NSAxMDMuNDg3IDc2LjExNjcgMTAzIDc3LjUgMTAzQzc4Ljg4MzMgMTAzIDgwLjA2MjUgMTAzLjQ4NyA4MS4wMzc1IDEwNC40NjJDODIuMDEyNSAxMDUuNDM4IDgyLjUgMTA2LjYxNyA4Mi41IDEwOFYxMTBIODMuNUM4NC4wNSAxMTAgODQuNTIwOCAxMTAuMTk2IDg0LjkxMjUgMTEwLjU4OEM4NS4zMDQyIDExMC45NzkgODUuNSAxMTEuNDUgODUuNSAxMTJWMTIyQzg1LjUgMTIyLjU1IDg1LjMwNDIgMTIzLjAyMSA4NC45MTI1IDEyMy40MTNDODQuNTIwOCAxMjMuODA0IDg0LjA1IDEyNCA4My41IDEyNEg3MS41Wk03MS41IDEyMkg4My41VjExMkg3MS41VjEyMlpNNzcuNSAxMTlDNzguMDUgMTE5IDc4LjUyMDggMTE4LjgwNCA3OC45MTI1IDExOC40MTNDNzkuMzA0MiAxMTguMDIxIDc5LjUgMTE3LjU1IDc5LjUgMTE3Qzc5LjUgMTE2LjQ1IDc5LjMwNDIgMTE1Ljk3OSA3OC45MTI1IDExNS41ODhDNzguNTIwOCAxMTUuMTk2IDc4LjA1IDExNSA3Ny41IDExNUM3Ni45NSAxMTUgNzYuNDc5MiAxMTUuMTk2IDc2LjA4NzUgMTE1LjU4OEM3NS42OTU4IDExNS45NzkgNzUuNSAxMTYuNDUgNzUuNSAxMTdDNzUuNSAxMTcuNTUgNzUuNjk1OCAxMTguMDIxIDc2LjA4NzUgMTE4LjQxM0M3Ni40NzkyIDExOC44MDQgNzYuOTUgMTE5IDc3LjUgMTE5Wk03NC41IDExMEg4MC41VjEwOEM4MC41IDEwNy4xNjcgODAuMjA4MyAxMDYuNDU4IDc5LjYyNSAxMDUuODc1Qzc5LjA0MTcgMTA1LjI5MiA3OC4zMzMzIDEwNSA3Ny41IDEwNUM3Ni42NjY3IDEwNSA3NS45NTgzIDEwNS4yOTIgNzUuMzc1IDEwNS44NzVDNzQuNzkxNyAxMDYuNDU4IDc0LjUgMTA3LjE2NyA3NC41IDEwOFYxMTBaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxwYXRoIGQ9Ik0xMDIuNjc2IDExNS44MzRIMTA0Ljg3M0MxMDQuODAzIDExNi42NzIgMTA0LjU2OCAxMTcuNDE5IDEwNC4xNyAxMTguMDc1QzEwMy43NzEgMTE4LjcyNiAxMDMuMjEyIDExOS4yMzggMTAyLjQ5MSAxMTkuNjEzQzEwMS43NzEgMTE5Ljk4OCAxMDAuODk1IDEyMC4xNzYgOTkuODYzMyAxMjAuMTc2Qzk5LjA3MjMgMTIwLjE3NiA5OC4zNjA0IDEyMC4wMzUgOTcuNzI3NSAxMTkuNzU0Qzk3LjA5NDcgMTE5LjQ2NyA5Ni41NTI3IDExOS4wNjIgOTYuMTAxNiAxMTguNTQxQzk1LjY1MDQgMTE4LjAxNCA5NS4zMDQ3IDExNy4zNzggOTUuMDY0NSAxMTYuNjM0Qzk0LjgzMDEgMTE1Ljg5IDk0LjcxMjkgMTE1LjA1OCA5NC43MTI5IDExNC4xMzhWMTEzLjA3NEM5NC43MTI5IDExMi4xNTQgOTQuODMzIDExMS4zMjIgOTUuMDczMiAxMTAuNTc4Qzk1LjMxOTMgMTA5LjgzNCA5NS42NzA5IDEwOS4xOTggOTYuMTI3OSAxMDguNjcxQzk2LjU4NSAxMDguMTM4IDk3LjEzMjggMTA3LjczIDk3Ljc3MTUgMTA3LjQ0OUM5OC40MTYgMTA3LjE2OCA5OS4xMzk2IDEwNy4wMjcgOTkuOTQyNCAxMDcuMDI3QzEwMC45NjIgMTA3LjAyNyAxMDEuODIzIDEwNy4yMTUgMTAyLjUyNiAxMDcuNTlDMTAzLjIyOSAxMDcuOTY1IDEwMy43NzQgMTA4LjQ4MyAxMDQuMTYxIDEwOS4xNDZDMTA0LjU1NCAxMDkuODA4IDEwNC43OTQgMTEwLjU2NiAxMDQuODgyIDExMS40MjJIMTAyLjY4NUMxMDIuNjI2IDExMC44NzEgMTAyLjQ5NyAxMTAuMzk5IDEwMi4yOTggMTEwLjAwN0MxMDIuMTA0IDEwOS42MTQgMTAxLjgxNyAxMDkuMzE1IDEwMS40MzcgMTA5LjExQzEwMS4wNTYgMTA4Ljg5OSAxMDAuNTU4IDEwOC43OTQgOTkuOTQyNCAxMDguNzk0Qzk5LjQzODUgMTA4Ljc5NCA5OC45OTkgMTA4Ljg4OCA5OC42MjQgMTA5LjA3NUM5OC4yNDkgMTA5LjI2MyA5Ny45MzU1IDEwOS41MzggOTcuNjgzNiAxMDkuOTAxQzk3LjQzMTYgMTEwLjI2NSA5Ny4yNDEyIDExMC43MTMgOTcuMTEyMyAxMTEuMjQ2Qzk2Ljk4OTMgMTExLjc3MyA5Ni45Mjc3IDExMi4zNzcgOTYuOTI3NyAxMTMuMDU3VjExNC4xMzhDOTYuOTI3NyAxMTQuNzgyIDk2Ljk4MzQgMTE1LjM2OCA5Ny4wOTQ3IDExNS44OTZDOTcuMjExOSAxMTYuNDE3IDk3LjM4NzcgMTE2Ljg2NSA5Ny42MjIxIDExNy4yNEM5Ny44NjIzIDExNy42MTUgOTguMTY3IDExNy45MDUgOTguNTM2MSAxMTguMTFDOTguOTA1MyAxMTguMzE1IDk5LjM0NzcgMTE4LjQxOCA5OS44NjMzIDExOC40MThDMTAwLjQ5IDExOC40MTggMTAwLjk5NyAxMTguMzE4IDEwMS4zODQgMTE4LjExOUMxMDEuNzc2IDExNy45MiAxMDIuMDcyIDExNy42MyAxMDIuMjcxIDExNy4yNDlDMTAyLjQ3NyAxMTYuODYyIDEwMi42MTEgMTE2LjM5MSAxMDIuNjc2IDExNS44MzRaTTEwOS4wODQgMTA2LjVWMTIwSDEwNi45NTdWMTA2LjVIMTA5LjA4NFpNMTExLjE1IDExNS4zNTFWMTE1LjE0OEMxMTEuMTUgMTE0LjQ2MyAxMTEuMjQ5IDExMy44MjcgMTExLjQ0OCAxMTMuMjQxQzExMS42NDggMTEyLjY0OSAxMTEuOTM1IDExMi4xMzcgMTEyLjMxIDExMS43MDNDMTEyLjY5MSAxMTEuMjY0IDExMy4xNTQgMTEwLjkyNCAxMTMuNjk4IDExMC42ODRDMTE0LjI0OSAxMTAuNDM4IDExNC44NyAxMTAuMzE0IDExNS41NjIgMTEwLjMxNEMxMTYuMjU5IDExMC4zMTQgMTE2Ljg4IDExMC40MzggMTE3LjQyNSAxMTAuNjg0QzExNy45NzYgMTEwLjkyNCAxMTguNDQyIDExMS4yNjQgMTE4LjgyMiAxMTEuNzAzQzExOS4yMDMgMTEyLjEzNyAxMTkuNDkzIDExMi42NDkgMTE5LjY5MyAxMTMuMjQxQzExOS44OTIgMTEzLjgyNyAxMTkuOTkxIDExNC40NjMgMTE5Ljk5MSAxMTUuMTQ4VjExNS4zNTFDMTE5Ljk5MSAxMTYuMDM2IDExOS44OTIgMTE2LjY3MiAxMTkuNjkzIDExNy4yNThDMTE5LjQ5MyAxMTcuODQ0IDExOS4yMDMgMTE4LjM1NiAxMTguODIyIDExOC43OTZDMTE4LjQ0MiAxMTkuMjI5IDExNy45NzkgMTE5LjU2OSAxMTcuNDM0IDExOS44MTVDMTE2Ljg4OSAxMjAuMDU2IDExNi4yNzEgMTIwLjE3NiAxMTUuNTc5IDEyMC4xNzZDMTE0Ljg4MiAxMjAuMTc2IDExNC4yNTggMTIwLjA1NiAxMTMuNzA3IDExOS44MTVDMTEzLjE2MiAxMTkuNTY5IDExMi42OTkgMTE5LjIyOSAxMTIuMzE5IDExOC43OTZDMTExLjkzOCAxMTguMzU2IDExMS42NDggMTE3Ljg0NCAxMTEuNDQ4IDExNy4yNThDMTExLjI0OSAxMTYuNjcyIDExMS4xNSAxMTYuMDM2IDExMS4xNSAxMTUuMzUxWk0xMTMuMjY4IDExNS4xNDhWMTE1LjM1MUMxMTMuMjY4IDExNS43NzggMTEzLjMxMiAxMTYuMTgzIDExMy40IDExNi41NjNDMTEzLjQ4NyAxMTYuOTQ0IDExMy42MjUgMTE3LjI3OCAxMTMuODEzIDExNy41NjVDMTE0IDExNy44NTMgMTE0LjI0IDExOC4wNzggMTE0LjUzMyAxMTguMjQyQzExNC44MjYgMTE4LjQwNiAxMTUuMTc1IDExOC40ODggMTE1LjU3OSAxMTguNDg4QzExNS45NzIgMTE4LjQ4OCAxMTYuMzEyIDExOC40MDYgMTE2LjU5OSAxMTguMjQyQzExNi44OTIgMTE4LjA3OCAxMTcuMTMyIDExNy44NTMgMTE3LjMyIDExNy41NjVDMTE3LjUwNyAxMTcuMjc4IDExNy42NDUgMTE2Ljk0NCAxMTcuNzMzIDExNi41NjNDMTE3LjgyNiAxMTYuMTgzIDExNy44NzMgMTE1Ljc3OCAxMTcuODczIDExNS4zNTFWMTE1LjE0OEMxMTcuODczIDExNC43MjcgMTE3LjgyNiAxMTQuMzI4IDExNy43MzMgMTEzLjk1M0MxMTcuNjQ1IDExMy41NzIgMTE3LjUwNCAxMTMuMjM1IDExNy4zMTEgMTEyLjk0MkMxMTcuMTIzIDExMi42NDkgMTE2Ljg4MyAxMTIuNDIxIDExNi41OSAxMTIuMjU3QzExNi4zMDMgMTEyLjA4NyAxMTUuOTYgMTEyLjAwMiAxMTUuNTYyIDExMi4wMDJDMTE1LjE2MyAxMTIuMDAyIDExNC44MTggMTEyLjA4NyAxMTQuNTI1IDExMi4yNTdDMTE0LjIzNyAxMTIuNDIxIDExNCAxMTIuNjQ5IDExMy44MTMgMTEyLjk0MkMxMTMuNjI1IDExMy4yMzUgMTEzLjQ4NyAxMTMuNTcyIDExMy40IDExMy45NTNDMTEzLjMxMiAxMTQuMzI4IDExMy4yNjggMTE0LjcyNyAxMTMuMjY4IDExNS4xNDhaTTEyNy4yNTIgMTE3LjQyNUMxMjcuMjUyIDExNy4yMTQgMTI3LjE5OSAxMTcuMDIzIDEyNy4wOTQgMTE2Ljg1NEMxMjYuOTg4IDExNi42NzggMTI2Ljc4NiAxMTYuNTIgMTI2LjQ4NyAxMTYuMzc5QzEyNi4xOTQgMTE2LjIzOCAxMjUuNzYxIDExNi4xMDkgMTI1LjE4NiAxMTUuOTkyQzEyNC42ODIgMTE1Ljg4MSAxMjQuMjIgMTE1Ljc0OSAxMjMuNzk4IDExNS41OTdDMTIzLjM4MiAxMTUuNDM4IDEyMy4wMjQgMTE1LjI0OCAxMjIuNzI1IDExNS4wMjVDMTIyLjQyNyAxMTQuODAzIDEyMi4xOTUgMTE0LjUzOSAxMjIuMDMxIDExNC4yMzRDMTIxLjg2NyAxMTMuOTMgMTIxLjc4NSAxMTMuNTc4IDEyMS43ODUgMTEzLjE4QzEyMS43ODUgMTEyLjc5MyAxMjEuODcgMTEyLjQyNyAxMjIuMDQgMTEyLjA4MUMxMjIuMjEgMTExLjczNSAxMjIuNDUzIDExMS40MzEgMTIyLjc2OSAxMTEuMTY3QzEyMy4wODYgMTEwLjkwMyAxMjMuNDcgMTEwLjY5NSAxMjMuOTIxIDExMC41NDNDMTI0LjM3OCAxMTAuMzkxIDEyNC44ODggMTEwLjMxNCAxMjUuNDUgMTEwLjMxNEMxMjYuMjQ3IDExMC4zMTQgMTI2LjkyOSAxMTAuNDQ5IDEyNy40OTggMTEwLjcxOUMxMjguMDcyIDExMC45ODIgMTI4LjUxMiAxMTEuMzQzIDEyOC44MTYgMTExLjhDMTI5LjEyMSAxMTIuMjUxIDEyOS4yNzMgMTEyLjc2MSAxMjkuMjczIDExMy4zMjlIMTI3LjE1NUMxMjcuMTU1IDExMy4wNzcgMTI3LjA5MSAxMTIuODQzIDEyNi45NjIgMTEyLjYyNkMxMjYuODM5IDExMi40MDMgMTI2LjY1MSAxMTIuMjI1IDEyNi4zOTkgMTEyLjA5QzEyNi4xNDcgMTExLjk0OSAxMjUuODMxIDExMS44NzkgMTI1LjQ1IDExMS44NzlDMTI1LjA4NyAxMTEuODc5IDEyNC43ODUgMTExLjkzOCAxMjQuNTQ1IDExMi4wNTVDMTI0LjMxIDExMi4xNjYgMTI0LjEzNSAxMTIuMzEyIDEyNC4wMTcgMTEyLjQ5NEMxMjMuOTA2IDExMi42NzYgMTIzLjg1IDExMi44NzUgMTIzLjg1IDExMy4wOTJDMTIzLjg1IDExMy4yNSAxMjMuODggMTEzLjM5NCAxMjMuOTM4IDExMy41MjJDMTI0LjAwMyAxMTMuNjQ2IDEyNC4xMDggMTEzLjc2IDEyNC4yNTUgMTEzLjg2NUMxMjQuNDAxIDExMy45NjUgMTI0LjYgMTE0LjA1OSAxMjQuODUyIDExNC4xNDZDMTI1LjExIDExNC4yMzQgMTI1LjQzMiAxMTQuMzE5IDEyNS44MTkgMTE0LjQwMUMxMjYuNTQ2IDExNC41NTQgMTI3LjE3IDExNC43NSAxMjcuNjkxIDExNC45OUMxMjguMjE5IDExNS4yMjUgMTI4LjYyMyAxMTUuNTI5IDEyOC45MDQgMTE1LjkwNEMxMjkuMTg1IDExNi4yNzMgMTI5LjMyNiAxMTYuNzQyIDEyOS4zMjYgMTE3LjMxMUMxMjkuMzI2IDExNy43MzIgMTI5LjIzNSAxMTguMTE5IDEyOS4wNTQgMTE4LjQ3MUMxMjguODc4IDExOC44MTYgMTI4LjYyIDExOS4xMTggMTI4LjI4IDExOS4zNzZDMTI3Ljk0IDExOS42MjggMTI3LjUzMyAxMTkuODI0IDEyNy4wNTggMTE5Ljk2NUMxMjYuNTkgMTIwLjEwNSAxMjYuMDYyIDEyMC4xNzYgMTI1LjQ3NiAxMjAuMTc2QzEyNC42MTUgMTIwLjE3NiAxMjMuODg2IDEyMC4wMjMgMTIzLjI4OCAxMTkuNzE5QzEyMi42OSAxMTkuNDA4IDEyMi4yMzYgMTE5LjAxMyAxMjEuOTI2IDExOC41MzJDMTIxLjYyMSAxMTguMDQ2IDEyMS40NjkgMTE3LjU0MiAxMjEuNDY5IDExNy4wMjFIMTIzLjUxNkMxMjMuNTQgMTE3LjQxMyAxMjMuNjQ4IDExNy43MjcgMTIzLjg0MiAxMTcuOTYxQzEyNC4wNDEgMTE4LjE4OSAxMjQuMjg3IDExOC4zNTYgMTI0LjU4IDExOC40NjJDMTI0Ljg3OSAxMTguNTYyIDEyNS4xODYgMTE4LjYxMSAxMjUuNTAzIDExOC42MTFDMTI1Ljg4NCAxMTguNjExIDEyNi4yMDMgMTE4LjU2MiAxMjYuNDYxIDExOC40NjJDMTI2LjcxOSAxMTguMzU2IDEyNi45MTUgMTE4LjIxNiAxMjcuMDUgMTE4LjA0QzEyNy4xODQgMTE3Ljg1OCAxMjcuMjUyIDExNy42NTMgMTI3LjI1MiAxMTcuNDI1Wk0xMzUuNTIzIDEyMC4xNzZDMTM0LjgyIDEyMC4xNzYgMTM0LjE4NCAxMjAuMDYyIDEzMy42MTYgMTE5LjgzM0MxMzMuMDUzIDExOS41OTkgMTMyLjU3MyAxMTkuMjczIDEzMi4xNzQgMTE4Ljg1N0MxMzEuNzgyIDExOC40NDEgMTMxLjQ4IDExNy45NTIgMTMxLjI2OSAxMTcuMzlDMTMxLjA1OCAxMTYuODI3IDEzMC45NTMgMTE2LjIyMSAxMzAuOTUzIDExNS41N1YxMTUuMjE5QzEzMC45NTMgMTE0LjQ3NSAxMzEuMDYxIDExMy44MDEgMTMxLjI3OCAxMTMuMTk3QzEzMS40OTUgMTEyLjU5NCAxMzEuNzk2IDExMi4wNzggMTMyLjE4MyAxMTEuNjVDMTMyLjU3IDExMS4yMTcgMTMzLjAyNyAxMTAuODg2IDEzMy41NTQgMTEwLjY1N0MxMzQuMDgxIDExMC40MjkgMTM0LjY1MyAxMTAuMzE0IDEzNS4yNjggMTEwLjMxNEMxMzUuOTQ4IDExMC4zMTQgMTM2LjU0MiAxMTAuNDI5IDEzNy4wNTIgMTEwLjY1N0MxMzcuNTYyIDExMC44ODYgMTM3Ljk4NCAxMTEuMjA4IDEzOC4zMTggMTExLjYyNEMxMzguNjU4IDExMi4wMzQgMTM4LjkxIDExMi41MjMgMTM5LjA3NCAxMTMuMDkyQzEzOS4yNDQgMTEzLjY2IDEzOS4zMjkgMTE0LjI4NyAxMzkuMzI5IDExNC45NzNWMTE1Ljg3OEgxMzEuOTgxVjExNC4zNTdIMTM3LjIzN1YxMTQuMTlDMTM3LjIyNSAxMTMuODEgMTM3LjE0OSAxMTMuNDUyIDEzNy4wMDggMTEzLjExOEMxMzYuODczIDExMi43ODQgMTM2LjY2NSAxMTIuNTE1IDEzNi4zODQgMTEyLjMxQzEzNi4xMDMgMTEyLjEwNCAxMzUuNzI4IDExMi4wMDIgMTM1LjI1OSAxMTIuMDAyQzEzNC45MDggMTEyLjAwMiAxMzQuNTk0IDExMi4wNzggMTM0LjMxOSAxMTIuMjNDMTM0LjA0OSAxMTIuMzc3IDEzMy44MjQgMTEyLjU5MSAxMzMuNjQyIDExMi44NzJDMTMzLjQ2IDExMy4xNTMgMTMzLjMyIDExMy40OTMgMTMzLjIyIDExMy44OTJDMTMzLjEyNiAxMTQuMjg0IDEzMy4wNzkgMTE0LjcyNyAxMzMuMDc5IDExNS4yMTlWMTE1LjU3QzEzMy4wNzkgMTE1Ljk4NiAxMzMuMTM1IDExNi4zNzMgMTMzLjI0NiAxMTYuNzNDMTMzLjM2NCAxMTcuMDgyIDEzMy41MzQgMTE3LjM5IDEzMy43NTYgMTE3LjY1M0MxMzMuOTc5IDExNy45MTcgMTM0LjI0OCAxMTguMTI1IDEzNC41NjUgMTE4LjI3N0MxMzQuODgxIDExOC40MjQgMTM1LjI0MiAxMTguNDk3IDEzNS42NDYgMTE4LjQ5N0MxMzYuMTU2IDExOC40OTcgMTM2LjYxIDExOC4zOTUgMTM3LjAwOCAxMTguMTg5QzEzNy40MDcgMTE3Ljk4NCAxMzcuNzUyIDExNy42OTQgMTM4LjA0NSAxMTcuMzE5TDEzOS4xNjIgMTE4LjRDMTM4Ljk1NiAxMTguNjk5IDEzOC42OSAxMTguOTg2IDEzOC4zNjIgMTE5LjI2MkMxMzguMDM0IDExOS41MzEgMTM3LjYzMiAxMTkuNzUxIDEzNy4xNTggMTE5LjkyMUMxMzYuNjg5IDEyMC4wOTEgMTM2LjE0NCAxMjAuMTc2IDEzNS41MjMgMTIwLjE3NlpNMTQ2LjkzMiAxMTguMDMxVjEwNi41SDE0OS4wNTlWMTIwSDE0Ny4xMzRMMTQ2LjkzMiAxMTguMDMxWk0xNDAuNzQ0IDExNS4zNTFWMTE1LjE2NkMxNDAuNzQ0IDExNC40NDUgMTQwLjgyOSAxMTMuNzg5IDE0MC45OTkgMTEzLjE5N0MxNDEuMTY5IDExMi42IDE0MS40MTUgMTEyLjA4NyAxNDEuNzM3IDExMS42NTlDMTQyLjA2IDExMS4yMjYgMTQyLjQ1MiAxMTAuODk1IDE0Mi45MTUgMTEwLjY2NkMxNDMuMzc4IDExMC40MzIgMTQzLjg5OSAxMTAuMzE0IDE0NC40NzkgMTEwLjMxNEMxNDUuMDU0IDExMC4zMTQgMTQ1LjU1OCAxMTAuNDI2IDE0NS45OTEgMTEwLjY0OEMxNDYuNDI1IDExMC44NzEgMTQ2Ljc5NCAxMTEuMTkgMTQ3LjA5OSAxMTEuNjA2QzE0Ny40MDMgMTEyLjAxNyAxNDcuNjQ2IDExMi41MDkgMTQ3LjgyOCAxMTMuMDgzQzE0OC4wMSAxMTMuNjUxIDE0OC4xMzkgMTE0LjI4NCAxNDguMjE1IDExNC45ODFWMTE1LjU3QzE0OC4xMzkgMTE2LjI1IDE0OC4wMSAxMTYuODcxIDE0Ny44MjggMTE3LjQzNEMxNDcuNjQ2IDExNy45OTYgMTQ3LjQwMyAxMTguNDgyIDE0Ny4wOTkgMTE4Ljg5M0MxNDYuNzk0IDExOS4zMDMgMTQ2LjQyMiAxMTkuNjE5IDE0NS45ODIgMTE5Ljg0MkMxNDUuNTQ5IDEyMC4wNjQgMTQ1LjA0MiAxMjAuMTc2IDE0NC40NjIgMTIwLjE3NkMxNDMuODg4IDEyMC4xNzYgMTQzLjM2OSAxMjAuMDU2IDE0Mi45MDYgMTE5LjgxNUMxNDIuNDQ5IDExOS41NzUgMTQyLjA2IDExOS4yMzggMTQxLjczNyAxMTguODA1QzE0MS40MTUgMTE4LjM3MSAxNDEuMTY5IDExNy44NjEgMTQwLjk5OSAxMTcuMjc1QzE0MC44MjkgMTE2LjY4NCAxNDAuNzQ0IDExNi4wNDIgMTQwLjc0NCAxMTUuMzUxWk0xNDIuODYyIDExNS4xNjZWMTE1LjM1MUMxNDIuODYyIDExNS43ODQgMTQyLjkgMTE2LjE4OCAxNDIuOTc3IDExNi41NjNDMTQzLjA1OSAxMTYuOTM4IDE0My4xODUgMTE3LjI3IDE0My4zNTQgMTE3LjU1N0MxNDMuNTI0IDExNy44MzggMTQzLjc0NCAxMTguMDYxIDE0NC4wMTQgMTE4LjIyNUMxNDQuMjg5IDExOC4zODMgMTQ0LjYxNyAxMTguNDYyIDE0NC45OTggMTE4LjQ2MkMxNDUuNDc5IDExOC40NjIgMTQ1Ljg3NCAxMTguMzU2IDE0Ni4xODUgMTE4LjE0NkMxNDYuNDk1IDExNy45MzUgMTQ2LjczOCAxMTcuNjUgMTQ2LjkxNCAxMTcuMjkzQzE0Ny4wOTYgMTE2LjkzIDE0Ny4yMTkgMTE2LjUyNSAxNDcuMjgzIDExNi4wOFYxMTQuNDg5QzE0Ny4yNDggMTE0LjE0NCAxNDcuMTc1IDExMy44MjEgMTQ3LjA2MyAxMTMuNTIyQzE0Ni45NTggMTEzLjIyNCAxNDYuODE0IDExMi45NjMgMTQ2LjYzMyAxMTIuNzRDMTQ2LjQ1MSAxMTIuNTEyIDE0Ni4yMjYgMTEyLjMzNiAxNDUuOTU2IDExMi4yMTNDMTQ1LjY5MiAxMTIuMDg0IDE0NS4zNzkgMTEyLjAyIDE0NS4wMTYgMTEyLjAyQzE0NC42MjkgMTEyLjAyIDE0NC4zMDEgMTEyLjEwMiAxNDQuMDMxIDExMi4yNjZDMTQzLjc2MiAxMTIuNDMgMTQzLjUzOSAxMTIuNjU1IDE0My4zNjMgMTEyLjk0MkMxNDMuMTkzIDExMy4yMjkgMTQzLjA2NyAxMTMuNTYzIDE0Mi45ODUgMTEzLjk0NEMxNDIuOTAzIDExNC4zMjUgMTQyLjg2MiAxMTQuNzMyIDE0Mi44NjIgMTE1LjE2NloiIGZpbGw9IndoaXRlIi8+CjxkZWZzPgo8ZmlsdGVyIGlkPSJmaWx0ZXIwX2RfNDY5N18zNjcxMyIgeD0iMCIgeT0iMTIiIHdpZHRoPSIyMTYiIGhlaWdodD0iNzYiIGZpbHRlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzPSJzUkdCIj4KPGZlRmxvb2QgZmxvb2Qtb3BhY2l0eT0iMCIgcmVzdWx0PSJCYWNrZ3JvdW5kSW1hZ2VGaXgiLz4KPGZlQ29sb3JNYXRyaXggaW49IlNvdXJjZUFscGhhIiB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMTI3IDAiIHJlc3VsdD0iaGFyZEFscGhhIi8+CjxmZU9mZnNldCBkeT0iNCIvPgo8ZmVHYXVzc2lhbkJsdXIgc3RkRGV2aWF0aW9uPSI0Ii8+CjxmZUNvbXBvc2l0ZSBpbjI9ImhhcmRBbHBoYSIgb3BlcmF0b3I9Im91dCIvPgo8ZmVDb2xvck1hdHJpeCB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMC4wOCAwIi8+CjxmZUJsZW5kIG1vZGU9Im5vcm1hbCIgaW4yPSJCYWNrZ3JvdW5kSW1hZ2VGaXgiIHJlc3VsdD0iZWZmZWN0MV9kcm9wU2hhZG93XzQ2OTdfMzY3MTMiLz4KPGZlQmxlbmQgbW9kZT0ibm9ybWFsIiBpbj0iU291cmNlR3JhcGhpYyIgaW4yPSJlZmZlY3QxX2Ryb3BTaGFkb3dfNDY5N18zNjcxMyIgcmVzdWx0PSJzaGFwZSIvPgo8L2ZpbHRlcj4KPC9kZWZzPgo8L3N2Zz4K", - "description": "Sends the command to the device or updates attribute/time-series when the user toggles the button. Widget settings will enable you to configure behavior how to fetch the initial state and what to trigger when button checked/unchecked states.", + "image": "tb-image:VG9nZ2xlIGJ1dHRvbnMuc3Zn:IlRvZ2dsZSBidXR0b24iIHN5c3RlbSB3aWRnZXQgaW1hZ2U=:SU1BR0U=;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjE2IiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIxNiAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGZpbHRlcj0idXJsKCNmaWx0ZXIwX2RfNDY5N18zNjcxMykiPgo8cmVjdCB4PSI4Ljc1IiB5PSIxNi43NSIgd2lkdGg9IjE5OC41IiBoZWlnaHQ9IjU4LjUiIHJ4PSIzLjI1IiBzdHJva2U9IiMxOTgwMzgiIHN0cm9rZS13aWR0aD0iMS41IiBzaGFwZS1yZW5kZXJpbmc9ImNyaXNwRWRnZXMiLz4KPG1hc2sgaWQ9Im1hc2swXzQ2OTdfMzY3MTMiIHN0eWxlPSJtYXNrLXR5cGU6YWxwaGEiIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHg9IjYyIiB5PSIzNCIgd2lkdGg9IjI1IiBoZWlnaHQ9IjI0Ij4KPHJlY3QgeD0iNjIuNSIgeT0iMzQiIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0iI0Q5RDlEOSIvPgo8L21hc2s+CjxnIG1hc2s9InVybCgjbWFzazBfNDY5N18zNjcxMykiPgo8cGF0aCBkPSJNNjguNSA0Mkg3Ny41VjQwQzc3LjUgMzkuMTY2NyA3Ny4yMDgzIDM4LjQ1ODMgNzYuNjI1IDM3Ljg3NUM3Ni4wNDE3IDM3LjI5MTcgNzUuMzMzMyAzNyA3NC41IDM3QzczLjY2NjcgMzcgNzIuOTU4MyAzNy4yOTE3IDcyLjM3NSAzNy44NzVDNzEuNzkxNyAzOC40NTgzIDcxLjUgMzkuMTY2NyA3MS41IDQwSDY5LjVDNjkuNSAzOC42MTY3IDY5Ljk4NzUgMzcuNDM3NSA3MC45NjI1IDM2LjQ2MjVDNzEuOTM3NSAzNS40ODc1IDczLjExNjcgMzUgNzQuNSAzNUM3NS44ODMzIDM1IDc3LjA2MjUgMzUuNDg3NSA3OC4wMzc1IDM2LjQ2MjVDNzkuMDEyNSAzNy40Mzc1IDc5LjUgMzguNjE2NyA3OS41IDQwVjQySDgwLjVDODEuMDUgNDIgODEuNTIwOCA0Mi4xOTU4IDgxLjkxMjUgNDIuNTg3NUM4Mi4zMDQyIDQyLjk3OTIgODIuNSA0My40NSA4Mi41IDQ0VjU0QzgyLjUgNTQuNTUgODIuMzA0MiA1NS4wMjA4IDgxLjkxMjUgNTUuNDEyNUM4MS41MjA4IDU1LjgwNDIgODEuMDUgNTYgODAuNSA1Nkg2OC41QzY3Ljk1IDU2IDY3LjQ3OTIgNTUuODA0MiA2Ny4wODc1IDU1LjQxMjVDNjYuNjk1OCA1NS4wMjA4IDY2LjUgNTQuNTUgNjYuNSA1NFY0NEM2Ni41IDQzLjQ1IDY2LjY5NTggNDIuOTc5MiA2Ny4wODc1IDQyLjU4NzVDNjcuNDc5MiA0Mi4xOTU4IDY3Ljk1IDQyIDY4LjUgNDJaTTY4LjUgNTRIODAuNVY0NEg2OC41VjU0Wk03NC41IDUxQzc1LjA1IDUxIDc1LjUyMDggNTAuODA0MiA3NS45MTI1IDUwLjQxMjVDNzYuMzA0MiA1MC4wMjA4IDc2LjUgNDkuNTUgNzYuNSA0OUM3Ni41IDQ4LjQ1IDc2LjMwNDIgNDcuOTc5MiA3NS45MTI1IDQ3LjU4NzVDNzUuNTIwOCA0Ny4xOTU4IDc1LjA1IDQ3IDc0LjUgNDdDNzMuOTUgNDcgNzMuNDc5MiA0Ny4xOTU4IDczLjA4NzUgNDcuNTg3NUM3Mi42OTU4IDQ3Ljk3OTIgNzIuNSA0OC40NSA3Mi41IDQ5QzcyLjUgNDkuNTUgNzIuNjk1OCA1MC4wMjA4IDczLjA4NzUgNTAuNDEyNUM3My40NzkyIDUwLjgwNDIgNzMuOTUgNTEgNzQuNSA1MVoiIGZpbGw9IiMxOTgwMzgiLz4KPC9nPgo8cGF0aCBkPSJNMTAyLjEzMSA0NS4yNVY0NS45NTMxQzEwMi4xMzEgNDYuOTE5OSAxMDIuMDA1IDQ3Ljc4NzEgMTAxLjc1MyA0OC41NTQ3QzEwMS41MDEgNDkuMzIyMyAxMDEuMTQxIDQ5Ljk3NTYgMTAwLjY3MiA1MC41MTQ2QzEwMC4yMDkgNTEuMDUzNyA5OS42NTIzIDUxLjQ2NjggOTkuMDAyIDUxLjc1MzlDOTguMzUxNiA1Mi4wMzUyIDk3LjYzMDkgNTIuMTc1OCA5Ni44Mzk4IDUyLjE3NThDOTYuMDU0NyA1Mi4xNzU4IDk1LjMzNjkgNTIuMDM1MiA5NC42ODY1IDUxLjc1MzlDOTQuMDQyIDUxLjQ2NjggOTMuNDgyNCA1MS4wNTM3IDkzLjAwNzggNTAuNTE0NkM5Mi41MzMyIDQ5Ljk3NTYgOTIuMTY0MSA0OS4zMjIzIDkxLjkwMDQgNDguNTU0N0M5MS42NDI2IDQ3Ljc4NzEgOTEuNTEzNyA0Ni45MTk5IDkxLjUxMzcgNDUuOTUzMVY0NS4yNUM5MS41MTM3IDQ0LjI4MzIgOTEuNjQyNiA0My40MTg5IDkxLjkwMDQgNDIuNjU3MkM5Mi4xNTgyIDQxLjg4OTYgOTIuNTIxNSA0MS4yMzYzIDkyLjk5MDIgNDAuNjk3M0M5My40NjQ4IDQwLjE1MjMgOTQuMDI0NCAzOS43MzkzIDk0LjY2ODkgMzkuNDU4Qzk1LjMxOTMgMzkuMTcwOSA5Ni4wMzcxIDM5LjAyNzMgOTYuODIyMyAzOS4wMjczQzk3LjYxMzMgMzkuMDI3MyA5OC4zMzQgMzkuMTcwOSA5OC45ODQ0IDM5LjQ1OEM5OS42MzQ4IDM5LjczOTMgMTAwLjE5NCA0MC4xNTIzIDEwMC42NjMgNDAuNjk3M0MxMDEuMTMyIDQxLjIzNjMgMTAxLjQ5MiA0MS44ODk2IDEwMS43NDQgNDIuNjU3MkMxMDIuMDAyIDQzLjQxODkgMTAyLjEzMSA0NC4yODMyIDEwMi4xMzEgNDUuMjVaTTk5LjkyNDggNDUuOTUzMVY0NS4yMzI0Qzk5LjkyNDggNDQuNTE3NiA5OS44NTQ1IDQzLjg4NzcgOTkuNzEzOSA0My4zNDI4Qzk5LjU3OTEgNDIuNzkyIDk5LjM3NyA0Mi4zMzIgOTkuMTA3NCA0MS45NjI5Qzk4Ljg0MzggNDEuNTg3OSA5OC41MTg2IDQxLjMwNjYgOTguMTMxOCA0MS4xMTkxQzk3Ljc0NTEgNDAuOTI1OCA5Ny4zMDg2IDQwLjgyOTEgOTYuODIyMyA0MC44MjkxQzk2LjMzNTkgNDAuODI5MSA5NS45MDIzIDQwLjkyNTggOTUuNTIxNSA0MS4xMTkxQzk1LjE0MDYgNDEuMzA2NiA5NC44MTU0IDQxLjU4NzkgOTQuNTQ1OSA0MS45NjI5Qzk0LjI4MjIgNDIuMzMyIDk0LjA4MDEgNDIuNzkyIDkzLjkzOTUgNDMuMzQyOEM5My43OTg4IDQzLjg4NzcgOTMuNzI4NSA0NC41MTc2IDkzLjcyODUgNDUuMjMyNFY0NS45NTMxQzkzLjcyODUgNDYuNjY4IDkzLjc5ODggNDcuMzAwOCA5My45Mzk1IDQ3Ljg1MTZDOTQuMDgwMSA0OC40MDIzIDk0LjI4NTIgNDguODY4MiA5NC41NTQ3IDQ5LjI0OUM5NC44MzAxIDQ5LjYyNCA5NS4xNTgyIDQ5LjkwODIgOTUuNTM5MSA1MC4xMDE2Qzk1LjkxOTkgNTAuMjg5MSA5Ni4zNTM1IDUwLjM4MjggOTYuODM5OCA1MC4zODI4Qzk3LjMzMiA1MC4zODI4IDk3Ljc2ODYgNTAuMjg5MSA5OC4xNDk0IDUwLjEwMTZDOTguNTMwMyA0OS45MDgyIDk4Ljg1MjUgNDkuNjI0IDk5LjExNjIgNDkuMjQ5Qzk5LjM3OTkgNDguODY4MiA5OS41NzkxIDQ4LjQwMjMgOTkuNzEzOSA0Ny44NTE2Qzk5Ljg1NDUgNDcuMzAwOCA5OS45MjQ4IDQ2LjY2OCA5OS45MjQ4IDQ1Ljk1MzFaTTEwNi40MDMgNDQuMzE4NFY1NS42NTYySDEwNC4yODVWNDIuNDkwMkgxMDYuMjM2TDEwNi40MDMgNDQuMzE4NFpNMTEyLjU5OSA0Ny4xNTcyVjQ3LjM0MThDMTEyLjU5OSA0OC4wMzMyIDExMi41MTcgNDguNjc0OCAxMTIuMzUzIDQ5LjI2NjZDMTEyLjE5NSA0OS44NTI1IDExMS45NTggNTAuMzY1MiAxMTEuNjQxIDUwLjgwNDdDMTExLjMzMSA1MS4yMzgzIDExMC45NDcgNTEuNTc1MiAxMTAuNDkgNTEuODE1NEMxMTAuMDMzIDUyLjA1NTcgMTA5LjUwNSA1Mi4xNzU4IDEwOC45MDggNTIuMTc1OEMxMDguMzE2IDUyLjE3NTggMTA3Ljc5NyA1Mi4wNjc0IDEwNy4zNTIgNTEuODUwNkMxMDYuOTEzIDUxLjYyNzkgMTA2LjU0MSA1MS4zMTQ1IDEwNi4yMzYgNTAuOTEwMkMxMDUuOTMxIDUwLjUwNTkgMTA1LjY4NSA1MC4wMzEyIDEwNS40OTggNDkuNDg2M0MxMDUuMzE2IDQ4LjkzNTUgMTA1LjE4NyA0OC4zMzIgMTA1LjExMSA0Ny42NzU4VjQ2Ljk2MzlDMTA1LjE4NyA0Ni4yNjY2IDEwNS4zMTYgNDUuNjMzOCAxMDUuNDk4IDQ1LjA2NTRDMTA1LjY4NSA0NC40OTcxIDEwNS45MzEgNDQuMDA3OCAxMDYuMjM2IDQzLjU5NzdDMTA2LjU0MSA0My4xODc1IDEwNi45MTMgNDIuODcxMSAxMDcuMzUyIDQyLjY0ODRDMTA3Ljc5MiA0Mi40MjU4IDEwOC4zMDQgNDIuMzE0NSAxMDguODkgNDIuMzE0NUMxMDkuNDg4IDQyLjMxNDUgMTEwLjAxOCA0Mi40MzE2IDExMC40ODEgNDIuNjY2QzExMC45NDQgNDIuODk0NSAxMTEuMzM0IDQzLjIyMjcgMTExLjY1IDQzLjY1MDRDMTExLjk2NiA0NC4wNzIzIDExMi4yMDQgNDQuNTgyIDExMi4zNjIgNDUuMTc5N0MxMTIuNTIgNDUuNzcxNSAxMTIuNTk5IDQ2LjQzMDcgMTEyLjU5OSA0Ny4xNTcyWk0xMTAuNDgxIDQ3LjM0MThWNDcuMTU3MkMxMTAuNDgxIDQ2LjcxNzggMTEwLjQ0IDQ2LjMxMDUgMTEwLjM1OCA0NS45MzU1QzExMC4yNzYgNDUuNTU0NyAxMTAuMTQ3IDQ1LjIyMDcgMTA5Ljk3MSA0NC45MzM2QzEwOS43OTYgNDQuNjQ2NSAxMDkuNTcgNDQuNDIzOCAxMDkuMjk1IDQ0LjI2NTZDMTA5LjAyNSA0NC4xMDE2IDEwOC43IDQ0LjAxOTUgMTA4LjMxOSA0NC4wMTk1QzEwNy45NDQgNDQuMDE5NSAxMDcuNjIyIDQ0LjA4NCAxMDcuMzUyIDQ0LjIxMjlDMTA3LjA4MyA0NC4zMzU5IDEwNi44NTcgNDQuNTA4OCAxMDYuNjc1IDQ0LjczMTRDMTA2LjQ5NCA0NC45NTQxIDEwNi4zNTMgNDUuMjE0OCAxMDYuMjU0IDQ1LjUxMzdDMTA2LjE1NCA0NS44MDY2IDEwNi4wODQgNDYuMTI2IDEwNi4wNDMgNDYuNDcxN1Y0OC4xNzY4QzEwNi4xMTMgNDguNTk4NiAxMDYuMjMzIDQ4Ljk4NTQgMTA2LjQwMyA0OS4zMzY5QzEwNi41NzMgNDkuNjg4NSAxMDYuODEzIDQ5Ljk2OTcgMTA3LjEyNCA1MC4xODA3QzEwNy40NCA1MC4zODU3IDEwNy44NDQgNTAuNDg4MyAxMDguMzM3IDUwLjQ4ODNDMTA4LjcxNyA1MC40ODgzIDEwOS4wNDMgNTAuNDA2MiAxMDkuMzEyIDUwLjI0MjJDMTA5LjU4MiA1MC4wNzgxIDEwOS44MDEgNDkuODUyNSAxMDkuOTcxIDQ5LjU2NTRDMTEwLjE0NyA0OS4yNzI1IDExMC4yNzYgNDguOTM1NSAxMTAuMzU4IDQ4LjU1NDdDMTEwLjQ0IDQ4LjE3MzggMTEwLjQ4MSA0Ny43Njk1IDExMC40ODEgNDcuMzQxOFpNMTE4Ljc0MyA1Mi4xNzU4QzExOC4wNCA1Mi4xNzU4IDExNy40MDQgNTIuMDYxNSAxMTYuODM2IDUxLjgzM0MxMTYuMjc0IDUxLjU5ODYgMTE1Ljc5MyA1MS4yNzM0IDExNS4zOTUgNTAuODU3NEMxMTUuMDAyIDUwLjQ0MTQgMTE0LjcgNDkuOTUyMSAxMTQuNDg5IDQ5LjM4OTZDMTE0LjI3OSA0OC44MjcxIDExNC4xNzMgNDguMjIwNyAxMTQuMTczIDQ3LjU3MDNWNDcuMjE4OEMxMTQuMTczIDQ2LjQ3NDYgMTE0LjI4MSA0NS44MDA4IDExNC40OTggNDUuMTk3M0MxMTQuNzE1IDQ0LjU5MzggMTE1LjAxNyA0NC4wNzgxIDExNS40MDQgNDMuNjUwNEMxMTUuNzkgNDMuMjE2OCAxMTYuMjQ3IDQyLjg4NTcgMTE2Ljc3NSA0Mi42NTcyQzExNy4zMDIgNDIuNDI4NyAxMTcuODczIDQyLjMxNDUgMTE4LjQ4OCA0Mi4zMTQ1QzExOS4xNjggNDIuMzE0NSAxMTkuNzYzIDQyLjQyODcgMTIwLjI3MyA0Mi42NTcyQzEyMC43ODIgNDIuODg1NyAxMjEuMjA0IDQzLjIwOCAxMjEuNTM4IDQzLjYyNEMxMjEuODc4IDQ0LjAzNDIgMTIyLjEzIDQ0LjUyMzQgMTIyLjI5NCA0NS4wOTE4QzEyMi40NjQgNDUuNjYwMiAxMjIuNTQ5IDQ2LjI4NzEgMTIyLjU0OSA0Ni45NzI3VjQ3Ljg3NzlIMTE1LjIwMVY0Ni4zNTc0SDEyMC40NTdWNDYuMTkwNEMxMjAuNDQ2IDQ1LjgwOTYgMTIwLjM2OSA0NS40NTIxIDEyMC4yMjkgNDUuMTE4MkMxMjAuMDk0IDQ0Ljc4NDIgMTE5Ljg4NiA0NC41MTQ2IDExOS42MDUgNDQuMzA5NkMxMTkuMzIzIDQ0LjEwNDUgMTE4Ljk0OCA0NC4wMDIgMTE4LjQ4IDQ0LjAwMkMxMTguMTI4IDQ0LjAwMiAxMTcuODE1IDQ0LjA3ODEgMTE3LjUzOSA0NC4yMzA1QzExNy4yNyA0NC4zNzcgMTE3LjA0NCA0NC41OTA4IDExNi44NjIgNDQuODcyMUMxMTYuNjgxIDQ1LjE1MzMgMTE2LjU0IDQ1LjQ5MzIgMTE2LjQ0MSA0NS44OTE2QzExNi4zNDcgNDYuMjg0MiAxMTYuMyA0Ni43MjY2IDExNi4zIDQ3LjIxODhWNDcuNTcwM0MxMTYuMyA0Ny45ODYzIDExNi4zNTYgNDguMzczIDExNi40NjcgNDguNzMwNUMxMTYuNTg0IDQ5LjA4MiAxMTYuNzU0IDQ5LjM4OTYgMTE2Ljk3NyA0OS42NTMzQzExNy4xOTkgNDkuOTE3IDExNy40NjkgNTAuMTI1IDExNy43ODUgNTAuMjc3M0MxMTguMTAyIDUwLjQyMzggMTE4LjQ2MiA1MC40OTcxIDExOC44NjYgNTAuNDk3MUMxMTkuMzc2IDUwLjQ5NzEgMTE5LjgzIDUwLjM5NDUgMTIwLjIyOSA1MC4xODk1QzEyMC42MjcgNDkuOTg0NCAxMjAuOTczIDQ5LjY5NDMgMTIxLjI2NiA0OS4zMTkzTDEyMi4zODIgNTAuNDAwNEMxMjIuMTc3IDUwLjY5OTIgMTIxLjkxIDUwLjk4NjMgMTIxLjU4MiA1MS4yNjE3QzEyMS4yNTQgNTEuNTMxMiAxMjAuODUzIDUxLjc1MSAxMjAuMzc4IDUxLjkyMDlDMTE5LjkwOSA1Mi4wOTA4IDExOS4zNjQgNTIuMTc1OCAxMTguNzQzIDUyLjE3NThaTTEyNi40NTIgNDQuNTIwNVY1MkgxMjQuMzM0VjQyLjQ5MDJIMTI2LjMyOUwxMjYuNDUyIDQ0LjUyMDVaTTEyNi4wNzQgNDYuODkzNkwxMjUuMzg4IDQ2Ljg4NDhDMTI1LjM5NCA0Ni4yMTA5IDEyNS40ODggNDUuNTkyOCAxMjUuNjcgNDUuMDMwM0MxMjUuODU3IDQ0LjQ2NzggMTI2LjExNSA0My45ODQ0IDEyNi40NDMgNDMuNTgwMUMxMjYuNzc3IDQzLjE3NTggMTI3LjE3NiA0Mi44NjUyIDEyNy42MzggNDIuNjQ4NEMxMjguMTAxIDQyLjQyNTggMTI4LjYxNyA0Mi4zMTQ1IDEyOS4xODUgNDIuMzE0NUMxMjkuNjQyIDQyLjMxNDUgMTMwLjA1NSA0Mi4zNzg5IDEzMC40MjUgNDIuNTA3OEMxMzAuOCA0Mi42MzA5IDEzMS4xMTkgNDIuODMzIDEzMS4zODMgNDMuMTE0M0MxMzEuNjUyIDQzLjM5NTUgMTMxLjg1NyA0My43NjE3IDEzMS45OTggNDQuMjEyOUMxMzIuMTM4IDQ0LjY1ODIgMTMyLjIwOSA0NS4yMDYxIDEzMi4yMDkgNDUuODU2NFY1MkgxMzAuMDgyVjQ1Ljg0NzdDMTMwLjA4MiA0NS4zOTA2IDEzMC4wMTQgNDUuMDMwMyAxMjkuODggNDQuNzY2NkMxMjkuNzUxIDQ0LjQ5NzEgMTI5LjU2IDQ0LjMwNjYgMTI5LjMwOCA0NC4xOTUzQzEyOS4wNjIgNDQuMDc4MSAxMjguNzU1IDQ0LjAxOTUgMTI4LjM4NiA0NC4wMTk1QzEyOC4wMjIgNDQuMDE5NSAxMjcuNjk3IDQ0LjA5NTcgMTI3LjQxIDQ0LjI0OEMxMjcuMTIzIDQ0LjQwMDQgMTI2Ljg4IDQ0LjYwODQgMTI2LjY4IDQ0Ljg3MjFDMTI2LjQ4NyA0NS4xMzU3IDEyNi4zMzggNDUuNDQwNCAxMjYuMjMyIDQ1Ljc4NjFDMTI2LjEyNyA0Ni4xMzE4IDEyNi4wNzQgNDYuNTAxIDEyNi4wNzQgNDYuODkzNlpNMTM4LjcxMyA1Mi4xNzU4QzEzOC4wMSA1Mi4xNzU4IDEzNy4zNzQgNTIuMDYxNSAxMzYuODA2IDUxLjgzM0MxMzYuMjQ0IDUxLjU5ODYgMTM1Ljc2MyA1MS4yNzM0IDEzNS4zNjUgNTAuODU3NEMxMzQuOTcyIDUwLjQ0MTQgMTM0LjY3IDQ5Ljk1MjEgMTM0LjQ1OSA0OS4zODk2QzEzNC4yNDggNDguODI3MSAxMzQuMTQzIDQ4LjIyMDcgMTM0LjE0MyA0Ny41NzAzVjQ3LjIxODhDMTM0LjE0MyA0Ni40NzQ2IDEzNC4yNTEgNDUuODAwOCAxMzQuNDY4IDQ1LjE5NzNDMTM0LjY4NSA0NC41OTM4IDEzNC45ODcgNDQuMDc4MSAxMzUuMzczIDQzLjY1MDRDMTM1Ljc2IDQzLjIxNjggMTM2LjIxNyA0Mi44ODU3IDEzNi43NDUgNDIuNjU3MkMxMzcuMjcyIDQyLjQyODcgMTM3Ljg0MyA0Mi4zMTQ1IDEzOC40NTggNDIuMzE0NUMxMzkuMTM4IDQyLjMxNDUgMTM5LjczMyA0Mi40Mjg3IDE0MC4yNDMgNDIuNjU3MkMxNDAuNzUyIDQyLjg4NTcgMTQxLjE3NCA0My4yMDggMTQxLjUwOCA0My42MjRDMTQxLjg0OCA0NC4wMzQyIDE0Mi4xIDQ0LjUyMzQgMTQyLjI2NCA0NS4wOTE4QzE0Mi40MzQgNDUuNjYwMiAxNDIuNTE5IDQ2LjI4NzEgMTQyLjUxOSA0Ni45NzI3VjQ3Ljg3NzlIMTM1LjE3MVY0Ni4zNTc0SDE0MC40MjdWNDYuMTkwNEMxNDAuNDE1IDQ1LjgwOTYgMTQwLjMzOSA0NS40NTIxIDE0MC4xOTkgNDUuMTE4MkMxNDAuMDY0IDQ0Ljc4NDIgMTM5Ljg1NiA0NC41MTQ2IDEzOS41NzUgNDQuMzA5NkMxMzkuMjkzIDQ0LjEwNDUgMTM4LjkxOCA0NC4wMDIgMTM4LjQ1IDQ0LjAwMkMxMzguMDk4IDQ0LjAwMiAxMzcuNzg1IDQ0LjA3ODEgMTM3LjUwOSA0NC4yMzA1QzEzNy4yNCA0NC4zNzcgMTM3LjAxNCA0NC41OTA4IDEzNi44MzIgNDQuODcyMUMxMzYuNjUxIDQ1LjE1MzMgMTM2LjUxIDQ1LjQ5MzIgMTM2LjQxMSA0NS44OTE2QzEzNi4zMTcgNDYuMjg0MiAxMzYuMjcgNDYuNzI2NiAxMzYuMjcgNDcuMjE4OFY0Ny41NzAzQzEzNi4yNyA0Ny45ODYzIDEzNi4zMjYgNDguMzczIDEzNi40MzcgNDguNzMwNUMxMzYuNTU0IDQ5LjA4MiAxMzYuNzI0IDQ5LjM4OTYgMTM2Ljk0NyA0OS42NTMzQzEzNy4xNjkgNDkuOTE3IDEzNy40MzkgNTAuMTI1IDEzNy43NTUgNTAuMjc3M0MxMzguMDcyIDUwLjQyMzggMTM4LjQzMiA1MC40OTcxIDEzOC44MzYgNTAuNDk3MUMxMzkuMzQ2IDUwLjQ5NzEgMTM5LjggNTAuMzk0NSAxNDAuMTk5IDUwLjE4OTVDMTQwLjU5NyA0OS45ODQ0IDE0MC45NDMgNDkuNjk0MyAxNDEuMjM2IDQ5LjMxOTNMMTQyLjM1MiA1MC40MDA0QzE0Mi4xNDcgNTAuNjk5MiAxNDEuODggNTAuOTg2MyAxNDEuNTUyIDUxLjI2MTdDMTQxLjIyNCA1MS41MzEyIDE0MC44MjMgNTEuNzUxIDE0MC4zNDggNTEuOTIwOUMxMzkuODc5IDUyLjA5MDggMTM5LjMzNCA1Mi4xNzU4IDEzOC43MTMgNTIuMTc1OFpNMTUwLjEyMiA1MC4wMzEyVjM4LjVIMTUyLjI0OVY1MkgxNTAuMzI0TDE1MC4xMjIgNTAuMDMxMlpNMTQzLjkzNSA0Ny4zNTA2VjQ3LjE2NkMxNDMuOTM1IDQ2LjQ0NTMgMTQ0LjAyIDQ1Ljc4OTEgMTQ0LjE4OSA0NS4xOTczQzE0NC4zNTkgNDQuNTk5NiAxNDQuNjA1IDQ0LjA4NjkgMTQ0LjkyOCA0My42NTkyQzE0NS4yNSA0My4yMjU2IDE0NS42NDMgNDIuODk0NSAxNDYuMTA1IDQyLjY2NkMxNDYuNTY4IDQyLjQzMTYgMTQ3LjA5IDQyLjMxNDUgMTQ3LjY3IDQyLjMxNDVDMTQ4LjI0NCA0Mi4zMTQ1IDE0OC43NDggNDIuNDI1OCAxNDkuMTgyIDQyLjY0ODRDMTQ5LjYxNSA0Mi44NzExIDE0OS45ODQgNDMuMTkwNCAxNTAuMjg5IDQzLjYwNjRDMTUwLjU5NCA0NC4wMTY2IDE1MC44MzcgNDQuNTA4OCAxNTEuMDE5IDQ1LjA4M0MxNTEuMiA0NS42NTE0IDE1MS4zMjkgNDYuMjg0MiAxNTEuNDA1IDQ2Ljk4MTRWNDcuNTcwM0MxNTEuMzI5IDQ4LjI1IDE1MS4yIDQ4Ljg3MTEgMTUxLjAxOSA0OS40MzM2QzE1MC44MzcgNDkuOTk2MSAxNTAuNTk0IDUwLjQ4MjQgMTUwLjI4OSA1MC44OTI2QzE0OS45ODQgNTEuMzAyNyAxNDkuNjEyIDUxLjYxOTEgMTQ5LjE3MyA1MS44NDE4QzE0OC43MzkgNTIuMDY0NSAxNDguMjMyIDUyLjE3NTggMTQ3LjY1MiA1Mi4xNzU4QzE0Ny4wNzggNTIuMTc1OCAxNDYuNTYgNTIuMDU1NyAxNDYuMDk3IDUxLjgxNTRDMTQ1LjY0IDUxLjU3NTIgMTQ1LjI1IDUxLjIzODMgMTQ0LjkyOCA1MC44MDQ3QzE0NC42MDUgNTAuMzcxMSAxNDQuMzU5IDQ5Ljg2MTMgMTQ0LjE4OSA0OS4yNzU0QzE0NC4wMiA0OC42ODM2IDE0My45MzUgNDguMDQyIDE0My45MzUgNDcuMzUwNlpNMTQ2LjA1MyA0Ny4xNjZWNDcuMzUwNkMxNDYuMDUzIDQ3Ljc4NDIgMTQ2LjA5MSA0OC4xODg1IDE0Ni4xNjcgNDguNTYzNUMxNDYuMjQ5IDQ4LjkzODUgMTQ2LjM3NSA0OS4yNjk1IDE0Ni41NDUgNDkuNTU2NkMxNDYuNzE1IDQ5LjgzNzkgMTQ2LjkzNSA1MC4wNjA1IDE0Ny4yMDQgNTAuMjI0NkMxNDcuNDc5IDUwLjM4MjggMTQ3LjgwOCA1MC40NjE5IDE0OC4xODggNTAuNDYxOUMxNDguNjY5IDUwLjQ2MTkgMTQ5LjA2NCA1MC4zNTY0IDE0OS4zNzUgNTAuMTQ1NUMxNDkuNjg2IDQ5LjkzNDYgMTQ5LjkyOSA0OS42NTA0IDE1MC4xMDQgNDkuMjkzQzE1MC4yODYgNDguOTI5NyAxNTAuNDA5IDQ4LjUyNTQgMTUwLjQ3NCA0OC4wODAxVjQ2LjQ4OTNDMTUwLjQzOCA0Ni4xNDM2IDE1MC4zNjUgNDUuODIxMyAxNTAuMjU0IDQ1LjUyMjVDMTUwLjE0OCA0NS4yMjM2IDE1MC4wMDUgNDQuOTYyOSAxNDkuODIzIDQ0Ljc0MDJDMTQ5LjY0MiA0NC41MTE3IDE0OS40MTYgNDQuMzM1OSAxNDkuMTQ2IDQ0LjIxMjlDMTQ4Ljg4MyA0NC4wODQgMTQ4LjU2OSA0NC4wMTk1IDE0OC4yMDYgNDQuMDE5NUMxNDcuODE5IDQ0LjAxOTUgMTQ3LjQ5MSA0NC4xMDE2IDE0Ny4yMjIgNDQuMjY1NkMxNDYuOTUyIDQ0LjQyOTcgMTQ2LjcyOSA0NC42NTUzIDE0Ni41NTQgNDQuOTQyNEMxNDYuMzg0IDQ1LjIyOTUgMTQ2LjI1OCA0NS41NjM1IDE0Ni4xNzYgNDUuOTQ0M0MxNDYuMDk0IDQ2LjMyNTIgMTQ2LjA1MyA0Ni43MzI0IDE0Ni4wNTMgNDcuMTY2WiIgZmlsbD0iIzE5ODAzOCIvPgo8L2c+CjxtYXNrIGlkPSJwYXRoLTYtaW5zaWRlLTFfNDY5N18zNjcxMyIgZmlsbD0id2hpdGUiPgo8cGF0aCBkPSJNOCA4OEM4IDg1Ljc5MDkgOS43OTA4NiA4NCAxMiA4NEgyMDRDMjA2LjIwOSA4NCAyMDggODUuNzkwOSAyMDggODhWMTQwQzIwOCAxNDIuMjA5IDIwNi4yMDkgMTQ0IDIwNCAxNDRIMTJDOS43OTA4NiAxNDQgOCAxNDIuMjA5IDggMTQwVjg4WiIvPgo8L21hc2s+CjxwYXRoIGQ9Ik04IDg4QzggODUuNzkwOSA5Ljc5MDg2IDg0IDEyIDg0SDIwNEMyMDYuMjA5IDg0IDIwOCA4NS43OTA5IDIwOCA4OFYxNDBDMjA4IDE0Mi4yMDkgMjA2LjIwOSAxNDQgMjA0IDE0NEgxMkM5Ljc5MDg2IDE0NCA4IDE0Mi4yMDkgOCAxNDBWODhaIiBmaWxsPSIjRDEyNzMwIi8+CjxwYXRoIGQ9Ik04IDg0SDIwOEg4Wk0yMDggMTQwQzIwOCAxNDMuMzE0IDIwNS4zMTQgMTQ2IDIwMiAxNDZIMTRDMTAuNjg2MyAxNDYgOCAxNDMuMzE0IDggMTQwQzggMTQxLjEwNSA5Ljc5MDg2IDE0MiAxMiAxNDJIMjA0QzIwNi4yMDkgMTQyIDIwOCAxNDEuMTA1IDIwOCAxNDBaTTggMTQ0Vjg0VjE0NFpNMjA4IDg0VjE0NFY4NFoiIGZpbGw9IiNEMTI3MzAiIG1hc2s9InVybCgjcGF0aC02LWluc2lkZS0xXzQ2OTdfMzY3MTMpIi8+CjxtYXNrIGlkPSJtYXNrMV80Njk3XzM2NzEzIiBzdHlsZT0ibWFzay10eXBlOmFscGhhIiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4PSI2NSIgeT0iMTAyIiB3aWR0aD0iMjUiIGhlaWdodD0iMjQiPgo8cmVjdCB4PSI2NS41IiB5PSIxMDIiIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0iI0Q5RDlEOSIvPgo8L21hc2s+CjxnIG1hc2s9InVybCgjbWFzazFfNDY5N18zNjcxMykiPgo8cGF0aCBkPSJNNzEuNSAxMjRDNzAuOTUgMTI0IDcwLjQ3OTIgMTIzLjgwNCA3MC4wODc1IDEyMy40MTNDNjkuNjk1OCAxMjMuMDIxIDY5LjUgMTIyLjU1IDY5LjUgMTIyVjExMkM2OS41IDExMS40NSA2OS42OTU4IDExMC45NzkgNzAuMDg3NSAxMTAuNTg4QzcwLjQ3OTIgMTEwLjE5NiA3MC45NSAxMTAgNzEuNSAxMTBINzIuNVYxMDhDNzIuNSAxMDYuNjE3IDcyLjk4NzUgMTA1LjQzOCA3My45NjI1IDEwNC40NjJDNzQuOTM3NSAxMDMuNDg3IDc2LjExNjcgMTAzIDc3LjUgMTAzQzc4Ljg4MzMgMTAzIDgwLjA2MjUgMTAzLjQ4NyA4MS4wMzc1IDEwNC40NjJDODIuMDEyNSAxMDUuNDM4IDgyLjUgMTA2LjYxNyA4Mi41IDEwOFYxMTBIODMuNUM4NC4wNSAxMTAgODQuNTIwOCAxMTAuMTk2IDg0LjkxMjUgMTEwLjU4OEM4NS4zMDQyIDExMC45NzkgODUuNSAxMTEuNDUgODUuNSAxMTJWMTIyQzg1LjUgMTIyLjU1IDg1LjMwNDIgMTIzLjAyMSA4NC45MTI1IDEyMy40MTNDODQuNTIwOCAxMjMuODA0IDg0LjA1IDEyNCA4My41IDEyNEg3MS41Wk03MS41IDEyMkg4My41VjExMkg3MS41VjEyMlpNNzcuNSAxMTlDNzguMDUgMTE5IDc4LjUyMDggMTE4LjgwNCA3OC45MTI1IDExOC40MTNDNzkuMzA0MiAxMTguMDIxIDc5LjUgMTE3LjU1IDc5LjUgMTE3Qzc5LjUgMTE2LjQ1IDc5LjMwNDIgMTE1Ljk3OSA3OC45MTI1IDExNS41ODhDNzguNTIwOCAxMTUuMTk2IDc4LjA1IDExNSA3Ny41IDExNUM3Ni45NSAxMTUgNzYuNDc5MiAxMTUuMTk2IDc2LjA4NzUgMTE1LjU4OEM3NS42OTU4IDExNS45NzkgNzUuNSAxMTYuNDUgNzUuNSAxMTdDNzUuNSAxMTcuNTUgNzUuNjk1OCAxMTguMDIxIDc2LjA4NzUgMTE4LjQxM0M3Ni40NzkyIDExOC44MDQgNzYuOTUgMTE5IDc3LjUgMTE5Wk03NC41IDExMEg4MC41VjEwOEM4MC41IDEwNy4xNjcgODAuMjA4MyAxMDYuNDU4IDc5LjYyNSAxMDUuODc1Qzc5LjA0MTcgMTA1LjI5MiA3OC4zMzMzIDEwNSA3Ny41IDEwNUM3Ni42NjY3IDEwNSA3NS45NTgzIDEwNS4yOTIgNzUuMzc1IDEwNS44NzVDNzQuNzkxNyAxMDYuNDU4IDc0LjUgMTA3LjE2NyA3NC41IDEwOFYxMTBaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxwYXRoIGQ9Ik0xMDIuNjc2IDExNS44MzRIMTA0Ljg3M0MxMDQuODAzIDExNi42NzIgMTA0LjU2OCAxMTcuNDE5IDEwNC4xNyAxMTguMDc1QzEwMy43NzEgMTE4LjcyNiAxMDMuMjEyIDExOS4yMzggMTAyLjQ5MSAxMTkuNjEzQzEwMS43NzEgMTE5Ljk4OCAxMDAuODk1IDEyMC4xNzYgOTkuODYzMyAxMjAuMTc2Qzk5LjA3MjMgMTIwLjE3NiA5OC4zNjA0IDEyMC4wMzUgOTcuNzI3NSAxMTkuNzU0Qzk3LjA5NDcgMTE5LjQ2NyA5Ni41NTI3IDExOS4wNjIgOTYuMTAxNiAxMTguNTQxQzk1LjY1MDQgMTE4LjAxNCA5NS4zMDQ3IDExNy4zNzggOTUuMDY0NSAxMTYuNjM0Qzk0LjgzMDEgMTE1Ljg5IDk0LjcxMjkgMTE1LjA1OCA5NC43MTI5IDExNC4xMzhWMTEzLjA3NEM5NC43MTI5IDExMi4xNTQgOTQuODMzIDExMS4zMjIgOTUuMDczMiAxMTAuNTc4Qzk1LjMxOTMgMTA5LjgzNCA5NS42NzA5IDEwOS4xOTggOTYuMTI3OSAxMDguNjcxQzk2LjU4NSAxMDguMTM4IDk3LjEzMjggMTA3LjczIDk3Ljc3MTUgMTA3LjQ0OUM5OC40MTYgMTA3LjE2OCA5OS4xMzk2IDEwNy4wMjcgOTkuOTQyNCAxMDcuMDI3QzEwMC45NjIgMTA3LjAyNyAxMDEuODIzIDEwNy4yMTUgMTAyLjUyNiAxMDcuNTlDMTAzLjIyOSAxMDcuOTY1IDEwMy43NzQgMTA4LjQ4MyAxMDQuMTYxIDEwOS4xNDZDMTA0LjU1NCAxMDkuODA4IDEwNC43OTQgMTEwLjU2NiAxMDQuODgyIDExMS40MjJIMTAyLjY4NUMxMDIuNjI2IDExMC44NzEgMTAyLjQ5NyAxMTAuMzk5IDEwMi4yOTggMTEwLjAwN0MxMDIuMTA0IDEwOS42MTQgMTAxLjgxNyAxMDkuMzE1IDEwMS40MzcgMTA5LjExQzEwMS4wNTYgMTA4Ljg5OSAxMDAuNTU4IDEwOC43OTQgOTkuOTQyNCAxMDguNzk0Qzk5LjQzODUgMTA4Ljc5NCA5OC45OTkgMTA4Ljg4OCA5OC42MjQgMTA5LjA3NUM5OC4yNDkgMTA5LjI2MyA5Ny45MzU1IDEwOS41MzggOTcuNjgzNiAxMDkuOTAxQzk3LjQzMTYgMTEwLjI2NSA5Ny4yNDEyIDExMC43MTMgOTcuMTEyMyAxMTEuMjQ2Qzk2Ljk4OTMgMTExLjc3MyA5Ni45Mjc3IDExMi4zNzcgOTYuOTI3NyAxMTMuMDU3VjExNC4xMzhDOTYuOTI3NyAxMTQuNzgyIDk2Ljk4MzQgMTE1LjM2OCA5Ny4wOTQ3IDExNS44OTZDOTcuMjExOSAxMTYuNDE3IDk3LjM4NzcgMTE2Ljg2NSA5Ny42MjIxIDExNy4yNEM5Ny44NjIzIDExNy42MTUgOTguMTY3IDExNy45MDUgOTguNTM2MSAxMTguMTFDOTguOTA1MyAxMTguMzE1IDk5LjM0NzcgMTE4LjQxOCA5OS44NjMzIDExOC40MThDMTAwLjQ5IDExOC40MTggMTAwLjk5NyAxMTguMzE4IDEwMS4zODQgMTE4LjExOUMxMDEuNzc2IDExNy45MiAxMDIuMDcyIDExNy42MyAxMDIuMjcxIDExNy4yNDlDMTAyLjQ3NyAxMTYuODYyIDEwMi42MTEgMTE2LjM5MSAxMDIuNjc2IDExNS44MzRaTTEwOS4wODQgMTA2LjVWMTIwSDEwNi45NTdWMTA2LjVIMTA5LjA4NFpNMTExLjE1IDExNS4zNTFWMTE1LjE0OEMxMTEuMTUgMTE0LjQ2MyAxMTEuMjQ5IDExMy44MjcgMTExLjQ0OCAxMTMuMjQxQzExMS42NDggMTEyLjY0OSAxMTEuOTM1IDExMi4xMzcgMTEyLjMxIDExMS43MDNDMTEyLjY5MSAxMTEuMjY0IDExMy4xNTQgMTEwLjkyNCAxMTMuNjk4IDExMC42ODRDMTE0LjI0OSAxMTAuNDM4IDExNC44NyAxMTAuMzE0IDExNS41NjIgMTEwLjMxNEMxMTYuMjU5IDExMC4zMTQgMTE2Ljg4IDExMC40MzggMTE3LjQyNSAxMTAuNjg0QzExNy45NzYgMTEwLjkyNCAxMTguNDQyIDExMS4yNjQgMTE4LjgyMiAxMTEuNzAzQzExOS4yMDMgMTEyLjEzNyAxMTkuNDkzIDExMi42NDkgMTE5LjY5MyAxMTMuMjQxQzExOS44OTIgMTEzLjgyNyAxMTkuOTkxIDExNC40NjMgMTE5Ljk5MSAxMTUuMTQ4VjExNS4zNTFDMTE5Ljk5MSAxMTYuMDM2IDExOS44OTIgMTE2LjY3MiAxMTkuNjkzIDExNy4yNThDMTE5LjQ5MyAxMTcuODQ0IDExOS4yMDMgMTE4LjM1NiAxMTguODIyIDExOC43OTZDMTE4LjQ0MiAxMTkuMjI5IDExNy45NzkgMTE5LjU2OSAxMTcuNDM0IDExOS44MTVDMTE2Ljg4OSAxMjAuMDU2IDExNi4yNzEgMTIwLjE3NiAxMTUuNTc5IDEyMC4xNzZDMTE0Ljg4MiAxMjAuMTc2IDExNC4yNTggMTIwLjA1NiAxMTMuNzA3IDExOS44MTVDMTEzLjE2MiAxMTkuNTY5IDExMi42OTkgMTE5LjIyOSAxMTIuMzE5IDExOC43OTZDMTExLjkzOCAxMTguMzU2IDExMS42NDggMTE3Ljg0NCAxMTEuNDQ4IDExNy4yNThDMTExLjI0OSAxMTYuNjcyIDExMS4xNSAxMTYuMDM2IDExMS4xNSAxMTUuMzUxWk0xMTMuMjY4IDExNS4xNDhWMTE1LjM1MUMxMTMuMjY4IDExNS43NzggMTEzLjMxMiAxMTYuMTgzIDExMy40IDExNi41NjNDMTEzLjQ4NyAxMTYuOTQ0IDExMy42MjUgMTE3LjI3OCAxMTMuODEzIDExNy41NjVDMTE0IDExNy44NTMgMTE0LjI0IDExOC4wNzggMTE0LjUzMyAxMTguMjQyQzExNC44MjYgMTE4LjQwNiAxMTUuMTc1IDExOC40ODggMTE1LjU3OSAxMTguNDg4QzExNS45NzIgMTE4LjQ4OCAxMTYuMzEyIDExOC40MDYgMTE2LjU5OSAxMTguMjQyQzExNi44OTIgMTE4LjA3OCAxMTcuMTMyIDExNy44NTMgMTE3LjMyIDExNy41NjVDMTE3LjUwNyAxMTcuMjc4IDExNy42NDUgMTE2Ljk0NCAxMTcuNzMzIDExNi41NjNDMTE3LjgyNiAxMTYuMTgzIDExNy44NzMgMTE1Ljc3OCAxMTcuODczIDExNS4zNTFWMTE1LjE0OEMxMTcuODczIDExNC43MjcgMTE3LjgyNiAxMTQuMzI4IDExNy43MzMgMTEzLjk1M0MxMTcuNjQ1IDExMy41NzIgMTE3LjUwNCAxMTMuMjM1IDExNy4zMTEgMTEyLjk0MkMxMTcuMTIzIDExMi42NDkgMTE2Ljg4MyAxMTIuNDIxIDExNi41OSAxMTIuMjU3QzExNi4zMDMgMTEyLjA4NyAxMTUuOTYgMTEyLjAwMiAxMTUuNTYyIDExMi4wMDJDMTE1LjE2MyAxMTIuMDAyIDExNC44MTggMTEyLjA4NyAxMTQuNTI1IDExMi4yNTdDMTE0LjIzNyAxMTIuNDIxIDExNCAxMTIuNjQ5IDExMy44MTMgMTEyLjk0MkMxMTMuNjI1IDExMy4yMzUgMTEzLjQ4NyAxMTMuNTcyIDExMy40IDExMy45NTNDMTEzLjMxMiAxMTQuMzI4IDExMy4yNjggMTE0LjcyNyAxMTMuMjY4IDExNS4xNDhaTTEyNy4yNTIgMTE3LjQyNUMxMjcuMjUyIDExNy4yMTQgMTI3LjE5OSAxMTcuMDIzIDEyNy4wOTQgMTE2Ljg1NEMxMjYuOTg4IDExNi42NzggMTI2Ljc4NiAxMTYuNTIgMTI2LjQ4NyAxMTYuMzc5QzEyNi4xOTQgMTE2LjIzOCAxMjUuNzYxIDExNi4xMDkgMTI1LjE4NiAxMTUuOTkyQzEyNC42ODIgMTE1Ljg4MSAxMjQuMjIgMTE1Ljc0OSAxMjMuNzk4IDExNS41OTdDMTIzLjM4MiAxMTUuNDM4IDEyMy4wMjQgMTE1LjI0OCAxMjIuNzI1IDExNS4wMjVDMTIyLjQyNyAxMTQuODAzIDEyMi4xOTUgMTE0LjUzOSAxMjIuMDMxIDExNC4yMzRDMTIxLjg2NyAxMTMuOTMgMTIxLjc4NSAxMTMuNTc4IDEyMS43ODUgMTEzLjE4QzEyMS43ODUgMTEyLjc5MyAxMjEuODcgMTEyLjQyNyAxMjIuMDQgMTEyLjA4MUMxMjIuMjEgMTExLjczNSAxMjIuNDUzIDExMS40MzEgMTIyLjc2OSAxMTEuMTY3QzEyMy4wODYgMTEwLjkwMyAxMjMuNDcgMTEwLjY5NSAxMjMuOTIxIDExMC41NDNDMTI0LjM3OCAxMTAuMzkxIDEyNC44ODggMTEwLjMxNCAxMjUuNDUgMTEwLjMxNEMxMjYuMjQ3IDExMC4zMTQgMTI2LjkyOSAxMTAuNDQ5IDEyNy40OTggMTEwLjcxOUMxMjguMDcyIDExMC45ODIgMTI4LjUxMiAxMTEuMzQzIDEyOC44MTYgMTExLjhDMTI5LjEyMSAxMTIuMjUxIDEyOS4yNzMgMTEyLjc2MSAxMjkuMjczIDExMy4zMjlIMTI3LjE1NUMxMjcuMTU1IDExMy4wNzcgMTI3LjA5MSAxMTIuODQzIDEyNi45NjIgMTEyLjYyNkMxMjYuODM5IDExMi40MDMgMTI2LjY1MSAxMTIuMjI1IDEyNi4zOTkgMTEyLjA5QzEyNi4xNDcgMTExLjk0OSAxMjUuODMxIDExMS44NzkgMTI1LjQ1IDExMS44NzlDMTI1LjA4NyAxMTEuODc5IDEyNC43ODUgMTExLjkzOCAxMjQuNTQ1IDExMi4wNTVDMTI0LjMxIDExMi4xNjYgMTI0LjEzNSAxMTIuMzEyIDEyNC4wMTcgMTEyLjQ5NEMxMjMuOTA2IDExMi42NzYgMTIzLjg1IDExMi44NzUgMTIzLjg1IDExMy4wOTJDMTIzLjg1IDExMy4yNSAxMjMuODggMTEzLjM5NCAxMjMuOTM4IDExMy41MjJDMTI0LjAwMyAxMTMuNjQ2IDEyNC4xMDggMTEzLjc2IDEyNC4yNTUgMTEzLjg2NUMxMjQuNDAxIDExMy45NjUgMTI0LjYgMTE0LjA1OSAxMjQuODUyIDExNC4xNDZDMTI1LjExIDExNC4yMzQgMTI1LjQzMiAxMTQuMzE5IDEyNS44MTkgMTE0LjQwMUMxMjYuNTQ2IDExNC41NTQgMTI3LjE3IDExNC43NSAxMjcuNjkxIDExNC45OUMxMjguMjE5IDExNS4yMjUgMTI4LjYyMyAxMTUuNTI5IDEyOC45MDQgMTE1LjkwNEMxMjkuMTg1IDExNi4yNzMgMTI5LjMyNiAxMTYuNzQyIDEyOS4zMjYgMTE3LjMxMUMxMjkuMzI2IDExNy43MzIgMTI5LjIzNSAxMTguMTE5IDEyOS4wNTQgMTE4LjQ3MUMxMjguODc4IDExOC44MTYgMTI4LjYyIDExOS4xMTggMTI4LjI4IDExOS4zNzZDMTI3Ljk0IDExOS42MjggMTI3LjUzMyAxMTkuODI0IDEyNy4wNTggMTE5Ljk2NUMxMjYuNTkgMTIwLjEwNSAxMjYuMDYyIDEyMC4xNzYgMTI1LjQ3NiAxMjAuMTc2QzEyNC42MTUgMTIwLjE3NiAxMjMuODg2IDEyMC4wMjMgMTIzLjI4OCAxMTkuNzE5QzEyMi42OSAxMTkuNDA4IDEyMi4yMzYgMTE5LjAxMyAxMjEuOTI2IDExOC41MzJDMTIxLjYyMSAxMTguMDQ2IDEyMS40NjkgMTE3LjU0MiAxMjEuNDY5IDExNy4wMjFIMTIzLjUxNkMxMjMuNTQgMTE3LjQxMyAxMjMuNjQ4IDExNy43MjcgMTIzLjg0MiAxMTcuOTYxQzEyNC4wNDEgMTE4LjE4OSAxMjQuMjg3IDExOC4zNTYgMTI0LjU4IDExOC40NjJDMTI0Ljg3OSAxMTguNTYyIDEyNS4xODYgMTE4LjYxMSAxMjUuNTAzIDExOC42MTFDMTI1Ljg4NCAxMTguNjExIDEyNi4yMDMgMTE4LjU2MiAxMjYuNDYxIDExOC40NjJDMTI2LjcxOSAxMTguMzU2IDEyNi45MTUgMTE4LjIxNiAxMjcuMDUgMTE4LjA0QzEyNy4xODQgMTE3Ljg1OCAxMjcuMjUyIDExNy42NTMgMTI3LjI1MiAxMTcuNDI1Wk0xMzUuNTIzIDEyMC4xNzZDMTM0LjgyIDEyMC4xNzYgMTM0LjE4NCAxMjAuMDYyIDEzMy42MTYgMTE5LjgzM0MxMzMuMDUzIDExOS41OTkgMTMyLjU3MyAxMTkuMjczIDEzMi4xNzQgMTE4Ljg1N0MxMzEuNzgyIDExOC40NDEgMTMxLjQ4IDExNy45NTIgMTMxLjI2OSAxMTcuMzlDMTMxLjA1OCAxMTYuODI3IDEzMC45NTMgMTE2LjIyMSAxMzAuOTUzIDExNS41N1YxMTUuMjE5QzEzMC45NTMgMTE0LjQ3NSAxMzEuMDYxIDExMy44MDEgMTMxLjI3OCAxMTMuMTk3QzEzMS40OTUgMTEyLjU5NCAxMzEuNzk2IDExMi4wNzggMTMyLjE4MyAxMTEuNjVDMTMyLjU3IDExMS4yMTcgMTMzLjAyNyAxMTAuODg2IDEzMy41NTQgMTEwLjY1N0MxMzQuMDgxIDExMC40MjkgMTM0LjY1MyAxMTAuMzE0IDEzNS4yNjggMTEwLjMxNEMxMzUuOTQ4IDExMC4zMTQgMTM2LjU0MiAxMTAuNDI5IDEzNy4wNTIgMTEwLjY1N0MxMzcuNTYyIDExMC44ODYgMTM3Ljk4NCAxMTEuMjA4IDEzOC4zMTggMTExLjYyNEMxMzguNjU4IDExMi4wMzQgMTM4LjkxIDExMi41MjMgMTM5LjA3NCAxMTMuMDkyQzEzOS4yNDQgMTEzLjY2IDEzOS4zMjkgMTE0LjI4NyAxMzkuMzI5IDExNC45NzNWMTE1Ljg3OEgxMzEuOTgxVjExNC4zNTdIMTM3LjIzN1YxMTQuMTlDMTM3LjIyNSAxMTMuODEgMTM3LjE0OSAxMTMuNDUyIDEzNy4wMDggMTEzLjExOEMxMzYuODczIDExMi43ODQgMTM2LjY2NSAxMTIuNTE1IDEzNi4zODQgMTEyLjMxQzEzNi4xMDMgMTEyLjEwNCAxMzUuNzI4IDExMi4wMDIgMTM1LjI1OSAxMTIuMDAyQzEzNC45MDggMTEyLjAwMiAxMzQuNTk0IDExMi4wNzggMTM0LjMxOSAxMTIuMjNDMTM0LjA0OSAxMTIuMzc3IDEzMy44MjQgMTEyLjU5MSAxMzMuNjQyIDExMi44NzJDMTMzLjQ2IDExMy4xNTMgMTMzLjMyIDExMy40OTMgMTMzLjIyIDExMy44OTJDMTMzLjEyNiAxMTQuMjg0IDEzMy4wNzkgMTE0LjcyNyAxMzMuMDc5IDExNS4yMTlWMTE1LjU3QzEzMy4wNzkgMTE1Ljk4NiAxMzMuMTM1IDExNi4zNzMgMTMzLjI0NiAxMTYuNzNDMTMzLjM2NCAxMTcuMDgyIDEzMy41MzQgMTE3LjM5IDEzMy43NTYgMTE3LjY1M0MxMzMuOTc5IDExNy45MTcgMTM0LjI0OCAxMTguMTI1IDEzNC41NjUgMTE4LjI3N0MxMzQuODgxIDExOC40MjQgMTM1LjI0MiAxMTguNDk3IDEzNS42NDYgMTE4LjQ5N0MxMzYuMTU2IDExOC40OTcgMTM2LjYxIDExOC4zOTUgMTM3LjAwOCAxMTguMTg5QzEzNy40MDcgMTE3Ljk4NCAxMzcuNzUyIDExNy42OTQgMTM4LjA0NSAxMTcuMzE5TDEzOS4xNjIgMTE4LjRDMTM4Ljk1NiAxMTguNjk5IDEzOC42OSAxMTguOTg2IDEzOC4zNjIgMTE5LjI2MkMxMzguMDM0IDExOS41MzEgMTM3LjYzMiAxMTkuNzUxIDEzNy4xNTggMTE5LjkyMUMxMzYuNjg5IDEyMC4wOTEgMTM2LjE0NCAxMjAuMTc2IDEzNS41MjMgMTIwLjE3NlpNMTQ2LjkzMiAxMTguMDMxVjEwNi41SDE0OS4wNTlWMTIwSDE0Ny4xMzRMMTQ2LjkzMiAxMTguMDMxWk0xNDAuNzQ0IDExNS4zNTFWMTE1LjE2NkMxNDAuNzQ0IDExNC40NDUgMTQwLjgyOSAxMTMuNzg5IDE0MC45OTkgMTEzLjE5N0MxNDEuMTY5IDExMi42IDE0MS40MTUgMTEyLjA4NyAxNDEuNzM3IDExMS42NTlDMTQyLjA2IDExMS4yMjYgMTQyLjQ1MiAxMTAuODk1IDE0Mi45MTUgMTEwLjY2NkMxNDMuMzc4IDExMC40MzIgMTQzLjg5OSAxMTAuMzE0IDE0NC40NzkgMTEwLjMxNEMxNDUuMDU0IDExMC4zMTQgMTQ1LjU1OCAxMTAuNDI2IDE0NS45OTEgMTEwLjY0OEMxNDYuNDI1IDExMC44NzEgMTQ2Ljc5NCAxMTEuMTkgMTQ3LjA5OSAxMTEuNjA2QzE0Ny40MDMgMTEyLjAxNyAxNDcuNjQ2IDExMi41MDkgMTQ3LjgyOCAxMTMuMDgzQzE0OC4wMSAxMTMuNjUxIDE0OC4xMzkgMTE0LjI4NCAxNDguMjE1IDExNC45ODFWMTE1LjU3QzE0OC4xMzkgMTE2LjI1IDE0OC4wMSAxMTYuODcxIDE0Ny44MjggMTE3LjQzNEMxNDcuNjQ2IDExNy45OTYgMTQ3LjQwMyAxMTguNDgyIDE0Ny4wOTkgMTE4Ljg5M0MxNDYuNzk0IDExOS4zMDMgMTQ2LjQyMiAxMTkuNjE5IDE0NS45ODIgMTE5Ljg0MkMxNDUuNTQ5IDEyMC4wNjQgMTQ1LjA0MiAxMjAuMTc2IDE0NC40NjIgMTIwLjE3NkMxNDMuODg4IDEyMC4xNzYgMTQzLjM2OSAxMjAuMDU2IDE0Mi45MDYgMTE5LjgxNUMxNDIuNDQ5IDExOS41NzUgMTQyLjA2IDExOS4yMzggMTQxLjczNyAxMTguODA1QzE0MS40MTUgMTE4LjM3MSAxNDEuMTY5IDExNy44NjEgMTQwLjk5OSAxMTcuMjc1QzE0MC44MjkgMTE2LjY4NCAxNDAuNzQ0IDExNi4wNDIgMTQwLjc0NCAxMTUuMzUxWk0xNDIuODYyIDExNS4xNjZWMTE1LjM1MUMxNDIuODYyIDExNS43ODQgMTQyLjkgMTE2LjE4OCAxNDIuOTc3IDExNi41NjNDMTQzLjA1OSAxMTYuOTM4IDE0My4xODUgMTE3LjI3IDE0My4zNTQgMTE3LjU1N0MxNDMuNTI0IDExNy44MzggMTQzLjc0NCAxMTguMDYxIDE0NC4wMTQgMTE4LjIyNUMxNDQuMjg5IDExOC4zODMgMTQ0LjYxNyAxMTguNDYyIDE0NC45OTggMTE4LjQ2MkMxNDUuNDc5IDExOC40NjIgMTQ1Ljg3NCAxMTguMzU2IDE0Ni4xODUgMTE4LjE0NkMxNDYuNDk1IDExNy45MzUgMTQ2LjczOCAxMTcuNjUgMTQ2LjkxNCAxMTcuMjkzQzE0Ny4wOTYgMTE2LjkzIDE0Ny4yMTkgMTE2LjUyNSAxNDcuMjgzIDExNi4wOFYxMTQuNDg5QzE0Ny4yNDggMTE0LjE0NCAxNDcuMTc1IDExMy44MjEgMTQ3LjA2MyAxMTMuNTIyQzE0Ni45NTggMTEzLjIyNCAxNDYuODE0IDExMi45NjMgMTQ2LjYzMyAxMTIuNzRDMTQ2LjQ1MSAxMTIuNTEyIDE0Ni4yMjYgMTEyLjMzNiAxNDUuOTU2IDExMi4yMTNDMTQ1LjY5MiAxMTIuMDg0IDE0NS4zNzkgMTEyLjAyIDE0NS4wMTYgMTEyLjAyQzE0NC42MjkgMTEyLjAyIDE0NC4zMDEgMTEyLjEwMiAxNDQuMDMxIDExMi4yNjZDMTQzLjc2MiAxMTIuNDMgMTQzLjUzOSAxMTIuNjU1IDE0My4zNjMgMTEyLjk0MkMxNDMuMTkzIDExMy4yMjkgMTQzLjA2NyAxMTMuNTYzIDE0Mi45ODUgMTEzLjk0NEMxNDIuOTAzIDExNC4zMjUgMTQyLjg2MiAxMTQuNzMyIDE0Mi44NjIgMTE1LjE2NloiIGZpbGw9IndoaXRlIi8+CjxkZWZzPgo8ZmlsdGVyIGlkPSJmaWx0ZXIwX2RfNDY5N18zNjcxMyIgeD0iMCIgeT0iMTIiIHdpZHRoPSIyMTYiIGhlaWdodD0iNzYiIGZpbHRlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzPSJzUkdCIj4KPGZlRmxvb2QgZmxvb2Qtb3BhY2l0eT0iMCIgcmVzdWx0PSJCYWNrZ3JvdW5kSW1hZ2VGaXgiLz4KPGZlQ29sb3JNYXRyaXggaW49IlNvdXJjZUFscGhhIiB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMTI3IDAiIHJlc3VsdD0iaGFyZEFscGhhIi8+CjxmZU9mZnNldCBkeT0iNCIvPgo8ZmVHYXVzc2lhbkJsdXIgc3RkRGV2aWF0aW9uPSI0Ii8+CjxmZUNvbXBvc2l0ZSBpbjI9ImhhcmRBbHBoYSIgb3BlcmF0b3I9Im91dCIvPgo8ZmVDb2xvck1hdHJpeCB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMC4wOCAwIi8+CjxmZUJsZW5kIG1vZGU9Im5vcm1hbCIgaW4yPSJCYWNrZ3JvdW5kSW1hZ2VGaXgiIHJlc3VsdD0iZWZmZWN0MV9kcm9wU2hhZG93XzQ2OTdfMzY3MTMiLz4KPGZlQmxlbmQgbW9kZT0ibm9ybWFsIiBpbj0iU291cmNlR3JhcGhpYyIgaW4yPSJlZmZlY3QxX2Ryb3BTaGFkb3dfNDY5N18zNjcxMyIgcmVzdWx0PSJzaGFwZSIvPgo8L2ZpbHRlcj4KPC9kZWZzPgo8L3N2Zz4K", + "description": "Sends the command to the device or updates attribute/time series when the user toggles the button. Widget settings will enable you to configure behavior how to fetch the initial state and what to trigger when button checked/unchecked states.", "descriptor": { "type": "rpc", "sizeX": 4, @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n self.ctx.$scope.actionWidget.onInit();\n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '300px',\n previewHeight: '150px',\n embedTitlePanel: true,\n displayRpcMessageToast: false\n };\n};\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.actionWidget.onInit();\n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '300px',\n previewHeight: '150px',\n embedTitlePanel: true,\n overflowVisible: true,\n displayRpcMessageToast: false\n };\n};\n\nself.onDestroy = function() {\n}\n", "settingsSchema": "", "dataKeySettingsSchema": "{}\n", "settingsDirective": "tb-toggle-button-widget-settings", diff --git a/application/src/main/data/json/system/widget_types/unread_notifications.json b/application/src/main/data/json/system/widget_types/unread_notifications.json new file mode 100644 index 0000000000..ed7a8492ad --- /dev/null +++ b/application/src/main/data/json/system/widget_types/unread_notifications.json @@ -0,0 +1,23 @@ +{ + "fqn": "unread_notifications", + "name": "Unread notifications", + "deprecated": false, + "image": "tb-image:dW5yZWFkX25vdGlmaWNhdGlvbl9zeXN0ZW1fd2lkZ2V0X2ltYWdlLnBuZw==:IlVucmVhZCBub3RpZmljYXRpb24iIHN5c3RlbSB3aWRnZXQgaW1hZ2U=;data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAYAAABJ/yOpAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAB1QSURBVHgB7Z1tjB3Vecefu37BTjDBlkLwSwHZEIrXSUwwL01sQkptiQo7UY0gUClgPhS+VKYfQKqKo0pGQYJKvORD7H7AULW8BaTwEpBwEQg7BVNbuImNRcBuIHiNQvEbhDX2erfzmzn/vefOzr079+6u9+76+WlHd/bMmTNnZp7nPOflOWcq3RvsnAmTJm0wsyvMcZyUvkrll71Hj/7DxI7JE+/r+MaaKzr+/O+tMul0cxzHrPc3a394/LdrT68c/fdJfZP+9qg5jhNx9KAd+8UZ1mGO4wxkclabcgVxnAa4gjhOA1xBHKcBriCO0wBXEMdpgCuI4zTAFcRxGuAK4jgNGBYF6erqsrvvvtueffZZc5zxxJAV5MiRI6liHDhwwDZv3mw7d+40x2kEsvLkk0+Wjo9MdXd322gwJAXZunWr/fSnP7U9e/b0hz3yyCPpzbdyQ4cPH7YPP/xwQBjbSMM1HnjggQHXH25eeukle/vtt9P9kb4WcK1WrhPncyRBhoBaCJvCNm7cmCoSYeSFwjd/XhxPafC/0ty9e3cqh2wquHWsLC0riEoBLEgeZbxZOOfyyy+3N954oz9sw4YNdsstt9hIc6IUhHvctWtXep38vQ4HpMczE7feemsqYM1CPk9EwZRXEApbZGvRokWpUsyYMSPd5s2bN+C86dOnp+GbNm1KlYCazJIlS9J0OF6pVGzbtm2pckge48K8DC0ryGAPnZuTZjfLHXfc0fDlIFzx8VioY4vTyPpwzmDKUBRH/xf9xtdtlPbs2bMLr5VPJ76novCi6yIIKIjSW79+vV1zzTUD8pR/NmWex4lg5syZqbBzH52dnTZlyhSbOnVqqgx5UA7CKaT37duX/k98lARFIK39+/enCkNaKMqsWbOsGVpWkDKaiIlrhWnTpqWleR5e4PXXX29XX311WvoqDqWkSk2sjcLXrl2bbnmUxg033JD+FgleHIdrScDY5xi/wK/iLVy4ML0+/2vLs2rVKps/f/6A+yIdzlU6Tz/9dHqMX45xj4SrJNQ5is/9Yz2Iz7Hbb7+9/3lwjOpSfK+cp2voXtn0vIryOVTU7kCgEeQYVckRZqzHtddem1qGsqAMqqLxO3fu3FSxKKSxJKTJs8tbosFoy27eNWvW9L/wGAR/7969tn37dnv++ef7q0SXXnppKgC8/C1btvQLEVUZjsUQb86cOfbaa6+lafB//jqkSRyOsx06dKimynjZZZeleRA33XRTmt7SpUvTa3LOo48+mqadr8cjdKeddlrhfS9btqw/HVlo8nLPPfekaSK0Tz31VBqOAnBvxOdaPAvOJw55f+yxxwZct6+vr79Nwi/XQUl4pqTDRvo8j0b5bBVKcZQEwaeUBwT2iSeeSC0AVSlKeKpHVJckzAh7vg0SWwKUA2Ugfc5DQRYsWNB/TTYsDekQtxkmWhuCAPKieensC1UBKPEECsGLplqmfYRUwhmfD7x4NgRMpemnn35aEwcBIw6lKXHyxxHMWHiIr7SJzzEdb6Yer6oX5+peuR8KCzYEWXHYp+qk5xUrbBGkSXwUXc+AfPOMyaPaedwrzzH/3IYDCWsM95dnxYoVNf9T+teLg+BL2YrixWH5dMvQlgoCq1evTk0+JVwsOGwoj1A1gFL+oYcespUrV/a/eAlBDOlxjFKX45QqeRAilINSmziqTo0GVK24p3pWVcjqNQJhJD0KDqye4JnyvMVg6ZxMtO1IOi/tzjvvrGk4UnJTuqlEV7uDuAgyx6hmIAgIeb56BXGDtqidA1RFSJM4qtOPFig++UCouSfdO0qjtgXKTJsClO8iRcIqcG+xhSAd7g+rC9zvcFetxjItWRDqiGV6qKg3YlKpH5aBxnks1Ag7L1Av7Oabb04bXBLs+Bj7qjfz8kmHsDyEIVR33XVXqkhcg+uCqk6cjxARR+kgWHEcEf+PpYsbtvm4eeLj+XS0f++999qDDz7Y356SsmJF2adqSdx169b13x/tF/IuCxj3mmEpqJ7JSnCvtHFQDBQufqaOWdOLNtAAuv/++0vHp7H04x//2BxnrHHsPyZbS6ua5K0H/9PfDPQu5C1GUR+247Q7KEhLVay8wGNV6L5DOW677TZznNHg6NGj9tlnn6W/p556aroNlWFbF6to8MdxTgQoxVtvvWUffPBBqhwCBWEQ9bzzzrNWaNmCFOHK4YwGVO1ffPFFmzx5sl144YV21llnpeEozbvvvpt2FH300Uf9YyXN0rbjII4zGCgBynH22WfbJZdckiqJwHqceeaZaS8qceiVLOr2HwyfUeiMWXBZQSkWL15coxwxVLmuvPLKtGsfS9IsbacgODhy42XHWpqFNJudE9DKNYomjtULH4znnntuRJ7FWAbrMVjVCTl6+eWXUx8vrAlVrmZpKwVBeBAGuonx7mTy1XDPJIvbSlxvJKYJcw0JNNeIvVJbEXQG9pxaPvnkk/QXwS+CZ06j/aqrrkqtC/FasSBt1QbBemAu5WCGouDlib8Ux5j8wj7H+Z/jdC1rxhjhCKAEktKFLul4Uo6c42RJ4lKduKSnmWzsc528ExzHaPxp7gHn6ZqM9Mt7VBN3yB9pUZKpi5xeP87hWronID5TCYjHsyjrhXCyoa5coKH+/vvvp410iJWDZw6tdvm2lQXRLDIJLv8jPPxPOMKtaZUIokp/BJxwzsF1GgGVGzVwvuYZIJjxbDTNVuMhS1EaTQbTTEqEl/OwcqAJYuRXSqe0cfVgi5XxmWeeSX9JhxeKUrChkNwnSpV38XaqYBU05sG7w5uZrt4i5QDi1munNKKtFIRS9sYbb0yFA98iKYBKVISHm+YXQUTYZT001RIQRML5X1U0judnk2EBtKE8sjRYLVmBIuvBMdLiN3ahzl8jTj/fDc49oQjEx7NW8xW43o4dO9J8e7ujPprXwbs699xz02ePkhQpB7z33nsDwsrQVlUsBB0h4WbZUJL8zEVVawABpdqFIEmBsCyiGf9/TevEOjWalplvExW5y5chnsuvahcKyjNAcchPs/OnTyawBijGm2++mSoL+6A57DE8UyyIqmDN0FYWBAFXtULWASSElK7xlEn+pzpENQVQGMwtceJ2TFlUxdO55CFfisuCAcdUxWoWXqoUAEvJS5Siqx2UhzBXmioSeMY5UACUJK8c9FyhRMRtpR0yYc3fTPjnCd9cY+0Ao6DvvPOOvfLKK/b666/bxRdfbN/61rdSgcF0UrojIAgwgs/28ccf9/8/adKk9AG98MILaWnMPmlSLTr//PPT4wj9wYMH0/8RRuZNIHhaAIDzZHnIC1uslCrtEWpVk3CXZ5+86hrHjh1LB7DIF0rc09NjX/va1/rDSZP7efXVV/sVmvySH8Jl0Tim/JMXlJN9J7MiPEueC1UoCsdTTjmlvwuYwUHGP1AOXE6apfe3a82/URhBKc7GggHtCEqJIjW7Msd4h4Y6CoK1kFc5ykOBg3LU6woejJbd3ccjWojsuuuua1v3fHcIHRyUJe4CHgquII7TABTEfbEcpwGuII7TAFcQx2nAmFSQwbxx5f802uQ9d+MVzBshf7KheDRr0FH7ZZDvmFNlzClIGcEnTtGq84PBoB+uC8MBgpb3RtZS/I1ASDlvqB7NcrfRvn4bfZejlWc23mm7GYXyZmX0XGtq6QMq8saNX6S+D6HFinWOXM4ZWJOwyPM2fx5jC3J4pORevnx5mkY+Dshrl+MM4hV5++o+yKucLPMQzlpbpBOfz1gHXc3yNeJelQbX1qAo+dWouzydY+/iPNw/5xNXzpryhtZzanZh55OBtrIgsS9SXNpJeOWNKyFFcHjx8vKNqxRyE0HIEXAEQd69cmfhPCmkls6XgslREjcWubfrXI2810MlNufGVRZ57ALpk8d8Olq2X3BcVo1rq3DQM+H+mUMjx0fdY4xG5lmjTKPzsTc0ykFacph0qrSdgqgE5IXHn0/g5eZHkPF6xR2F+FrNOw/CIFeOWHDlRg+Mvuo7FPLdkgXgmuQpFnRcUbhmkbevzlVJz7WL/Kd0j2UGJT///PO6x7gG6+3yLLi/Rm0WXUuKIMuq5+4MpK2qWPkPpZRxNGxlZFklJ0JRr949lIlKCBzny5Jh8cqWzNw/Qqvrs9/ITVsOk1QLiddMIxslpmpGHn1xv2Labj6Iqk8y+Y3Q57egmWmpCBUWByGU7w6oYQzxTER+iwS8yNtX1TUW0KNk5zeelzIYKK2+RaIPpMpbWV9TYlO+Jdya9DUYcYHAObE3tDOQtlIQNWaZB4KQ0FgFBCC2FKq382J54awVHFdjUDRVmfL1eV0HwXj44YdrlFACg0BzbdLUotAscq20BVVAqjYxaivF6JNgqpbFecmjabb33Xef/fznP685h6rd448/nrYzNIWX++M48bmG8keYLI+UW2mpbUe4lMspZkz7YiGM8UQnKOoxGsuogT0SpbymKGPp2oe+5K832Y7bsFPpyLaSdmFYV1YcDdQVq0lS47GhOVIKj7WJrfTokyjG8aSg7vuCD7TYiFKZkOjIKck2afCo7s3rjD6Jtej508grRh6UZEKyWaXwsHvzOm3AKCkH9CbWqqdx54kriDO69Hw+PMpx7HC2NUvfsURHv6h72BXEGT16j2YN8oi+P/3Bjj35VTu+/c6a8J7nv209r/ywblLHf7fOju+8x1rLBwpSrKSuIM7o0Vu/7du798XEIhxK9/v++Gvr+/wPNccJ6937gtmfsvAJ51xvHef8aOBxQbxk6yMspBulVrfXzBXEGT0adOV2nN7ZL+C9v3+8RvixLliLvkSJel79QRb2+8fs+LvrBx7/1bdTheB4z0tX1LcyvceK82GOMxrkqlZ5Os67xXr/94lUuHs//i+rnPHdmmMTOu9Iwr6TVsli69J3cEdy3uM28fvP2IRLfmY26bTUmqScsdgmLns1CftKQX6K8+Ef0HFGh0ql8fFTz+oX9o5EEWIJ7sUKJKdXvvqd4qQnR5+xnnR60g4PVarJDT5vXSc7bkGcUaJidaUyMOHrf5dWlzrO/lFNOO2TjllXWeVLZw1M9ct/Zn1HD6fVsr4DO1Ilq3y1jBdCsSq4BXFGD0ayCxrqWIZKUjWqfD2pZiVCnlavug5ZZXo2pWHiJQ8mvVbrk/+/kSpKTfUoqT5NvOKXiWL9U7pPNQulqXz5rCTNrzTOSwE+ku6MHn092SDhUEnaIalCJEowYeFd1jQdkxNzNXB6g4+kO6NLZWJw9RgavR//Oh1rpPHefB46MpeTOngVyxldOpjGEBwVW00i6QKOu4FLQ0fBhC8FD99iXEGc0aeD6k1H4cj6iIFH7yDKAa4gTntANacyOfONSgftRsJ5sRKqVJOy6l0JXEGc9oEqD0rS0fy3BEcKb6Q7TgMyBTl60BzHGUiiIH2vHn/nZ+Y4TuDYQev9zVrrq/T9stK9Yco5Eyf23NdXqfzQHMcJJIbj2MRV5jiO4ziO4ziO4ziO4ziO4ziO4ziO4ziO4ziO4wwZFiY6Ndn48syZ5jiO+CDZtqAgVybbJ8n2drL5+j+Ok7Ew2WYyYYrl6babK4fjxGAwzvQpt45TTGowXEEcpwGuII7TAFcQx2mAK4jjNMAVxHEa4AriOA1wBXGcBriCOE4Dhrp4NR93wI+rM9n0iZ6uZNuZbFvNccY4Q1EQvoy4zLJ16lGGfck2PdlmJdu1yXZRsj2ZbAfMccoz1zK52mrVQnapZbIFT1p5OG+jDYEJyXahZb5YzYByrEi215ONr7f/zjLLsSfZ/seyG8OyLAr7PdYcc5KNb/YeLhF3ZYi711qD5SW/SLb/s8bXIE97rByzk+1mG34HUKW7xcYvKEK3ZTKDTM1MtvMtUwyOUair0KXW8hfh+AzLajSLwvEjIYw0qOGcYZkDoo6V4cJW2iBkEuVAM5+LwleEDFrIxDqrVsHKggA8lmzPh+01y0qBRlyTbJdZ66Ag823wayyz8qBMq5PtKza8KN3BWB3ijkUohGIBRgmkEBJ6gXzx7jZZZnmovVAgS2YWhV81AeJjpWhFQRD4/TbQdMXtEOCmNltmbaZYORBElORyy7Sda9ybi8OLn1Zw7jQrFop68YvC5+TSKzovvta0OuHW4LxpdfIwpyCsTLpzCtJEQS4dJN54QRaB3y6rX6XfHY5NtyZoRUHmWdYILwMKMjWcUwaqVFSX9CIfSDZ9uhTFwaI8alk1Ll55myrQr8JxlbCkIWtECXNnLp3nw/HTQvicEC5B/Emy/asN5IKQnvKxMoQvjcLXWzE359Lkesuia8ty/ipcJ5/umuhc3ce63P09Gn6lJPnnUMYCtRO0bWeGfazEfjuBtKIgWIN9JeN2h21qyfhPW2Y1eJG8VIRD9e1/CftYl+ut9kUr/HarKk5sja62TDgvDeEWwm+15rk35JHzUWAJ7eoo/HZrjTsss5y0p34Swkj/6ZDu01Hc+eH/q8P1uL9pIZ6FMJ7LbZY9h4Uh7mobvEo52iAz+6N95I2OH7VzY7rC7/4QF2RF9hYc67ImaLUXq6jKhFVB4B62WgVCObqtHFgQXiyCh6CrFERwKFGfCvF48Quj8z6MfmUR1GaIS/M5YdsSrqWtGSRcKPA0q1q8+SHfuo9W0Hnc5+qQNvl9KYS/HcV9I1xT+QDaPJ9a/fyKC3JptRv5Gkq9nihV42FbQXy1kf8zOvacNUErCqJegc258Gctq+fdFH7pdZgbjpU1iwgFQk7JuNYyIUDA7yiIO8eqilHEofB7fxS2y4bWoBcbQ94EQqnq4XCgHrzD0f95sAwUHjdYJvCP1UnrUEjnoShslzmlaKWKRfWHNsXcgmNo/t1W7b+mFwHlKFsl40VSpaBef2n4lRJssMyqXBDiPDpIWhtDXLUpqIIwZoNgLw3pL42OSyBvio4XscWqPSEIrhRuV8gf5y1rcH8XhDhFbYGVIe1VVhXiLVa1pCujuFKa2XWud1k4tjGcq/h6Dk4JWhkHwayhIAg/CnGkThxeND1ej1j5wULy8bFl7QSEgbEJhAPBQmCoLlBi8rJvDeGE7Q7HCT/XMgu0K6SFsH3PMkHZEsJPCeEsWkGd9o3wuztce36IT/qvRdfYHsLnh/ypHbA3pD0vpEtajH88ZbXjIITPsaoCKP1PQxiFwfKQb6zmFyHd74Xr7Q1xdX/fC89je7gX5RmWRvn9ouA5OIODblgr32Gjq+wfw7bIarvOsCwI7z3WZJ/zSYwGIcfq2MV4ZVWrjXQsAnV7FODaEIYlUeOdl73Oyo88O05bMhRfLHqmaJjTw8IIpkY4u6zJrjQnrVrNNWeoTLZsIUR+PwvbkBiqNy9gOfaYWwtn9EApaC+wxtvkKBwFeSvZ3rMWGQ4FcZzRhJrLVZZ1hqAMH4RwlIYOGzqKGInfZC3gCuKMZVAClOP9ZHvTansMsR4fWTYgShy6upvuvfMZhc5YBuuAUmy2+tMKqHK9bFmXfNMLtLebBVlUEOaN/pGFZ77DsnEUeiXpeJHHdlmn1NEA64HAv9ggDvdBu+QXllmTc8NvadrVguTHVk4mNIX5RCFnUrro5TU708o7mI4W6jWtJ/BSDhQI6xJ7BZem3SyIXFTo8mTkWj1jmng1PYQRjxeI2ZwRwneGsLlRHAvHFod9TDFjOHRLM+p9egibF87tC/+TBkraHZ2z2Kr+Z/FUzjhcLAppdIV8aLbbnijf2wquMz0ckzfrviiO0joSrnkkutf9IU583xblT+fJWmhfz3Esoq5c4PmebVkjHWLlkB9gS12+Y6UNcp1lN6oJWAgGAscsxt0hnAHL6WFfAjo9hKM8COQt4TwU5LuWCVR3iH96+F9z6lGAA1YdCF0Q0qMUWhqlny/tCb8onM+LWxKuuTQ6LoXMX0fVyT1WVWTN3pxq1dmZK6LnsdyqzqOdBfmZEdLRs5gVwv8q/I5Va41V0JgH94h3N129RcphIW7T05/HQi8WgsELlDszwrIo/GoMxsI+JSKCxYOZEc6N5xDQo7Eg7BMW17FlKRC+56zqSn1xuD4lb2eUh3khTr6efsCqk8Q2WdUPjTr+lJDG1ijPC0Lam6MwbbIcpIUiL7Gq5dI97Qt5kHLNyuVpd7jmvujZxeePVVS1oh2icQ412vPKAedZeafZfsaCBcnPPSk7twSmtnBu0fUIQ+hU+iNonVatCsagEDhoUl270arVO+IhnAiwLBfxPrfMuiytkxflGYV51ppHVlGKOSPcw1hflglFeNey7lusCEpCgVSkHNw7FqTZxUnGhIKoRFY1ACHbXfJchFpVoyklz0VwLgr7nEfVa1/IB8IlgZXg5UulRWHD4iHQciHBQuCWrh45qmrLQzwpnJCSklctNrDDWoP8ynodsKqijgfPBwk84xwoAEpSZDkusax90nQ7pF2rWPEUScDxkXYILxlhQagQ3ljYu6Jz9kdpIKQrQvgzlglJt9V2He+1WsGnhNV03H+L4m2NztthxRZpa7iezn8i/HaHa2tEd1/Ybg3HtN7TznCv3eE+Z0Rx8tWrfN7rTUz77yhOvoCIz4+nr57Qud8tgsBjMa4KG9Wud8MxFAbloAqGcjRtPYCSBXf3DeaMNGokrzdnuKGKxRgHCqHuX6pgKDnK0dTYR8SqsdBIHw9QncIqNLMqoFMelOHtsE222i7gIeEKcmKgKnW/OSeCozaMq1m6L5bjNMAVxHEa4AriOA1oNwWhf/5EOurBjTa8MLZxsjpajjvaTUEQrBPtPDdrkOOMaTTjBTq7IAylb2aVe6dNaMdeLI0XaGBM627pIypFHquxl+rccO5BG+jtKx8lOfXlR5M1PVPXkDvJFKs6FcoRMs4HSoZLSdG8lVnhHNLYG84r8haOyXvrxl7Oi6JnM9WqvlfTc/vE3WbOkGjHNggvWYqhEXD9xt68MQiNLA+j4JTiCCRjD/L2XR7Cpof9TVbrd9VpVUWQIGrJfIROXr+dIc6s6Lqkp1H2vLXZH9KJnQnloRt7C8dwvK8g39da1QHyxtwz6ozSOtHV1HFLOyoIwogAxP5JF1nV/Vu/ZSAuJTCCKU9ehBrB22e18zi47u4QR75fCKBWqC/yrp1rVYullV3yvllHbKCH7rNW9Rb+khW3WbaFa+2zqjKyPzukM8uq7itTQp40x2Se+Sozw8JYGSiM2yVy7W6W2G+qaLlULJPc6vusflsoXmK/K8Qr+0kvmJJLuxnv5DiuVimnarkkHGN/nlUdE50h0q5VLMBqSBHkHk41Ju/IaOF/lcJxaayqSezJu9tqvXXj68ra5C2UBJpzZ4R8yOkPhe2MrlOvByv20O2Mro+3cBlh3h3i5z17uf6S8LsnpO1z+IeJVj/iOVIgAHzr4i8tq0rgu0TpjHCwWrlm571jtR8GZbHnv7ZMOHpC/CPhHNL7vmULVP/Oqm7ruJ7r+3ek1xXS0Ecg/2jV6hJx3w/nzwvpLQhpfhquvzKEce72XP66wz1hsVl6ZlZIk/iP2EALFM/XoE3zQbL9Ifz/A8s+WvmOVT8/xgw6efpyDZRlWHyRTnJaXrx6LKAFth2nVVaN95H0shOrHKeQ8awgVD/cvdwZEu6L5TgNcAVxnAaMZwUpWrPKcZpivFsQ96p1hkS7jaTHy3jiMnHAih0E42VH5eeUX5rUrHYArsjZsZ5ToOOktJsFiT8v/YPwi3IwWBY7CGopTzkAMugmz9jFIX5cxYqdHeWIqPDYKdAtjlNDuykInqpy22DFQVkPSvl5IY4USF62HNtvVbd0FkfIOwxqrdwuq37GWcROga4gTg3tpiDyJYpdLRDaeGGzomrQZquu0q4FqmNiV468s6Dj1KUdG+m4pVP10eQmrScbOwjmyU+wmpc7jrWR5Vlg7f1hGKeNaEd3dy3pqVJf7QMtv6kFnGOPVRTjurCPEqEAM6M4LP9Jm2WZVSdQQb0lPB2nn/HqrOg4Q2XcOys6zpBwBXGcBriCOE4DXEEcpwGuII7TACnIZHMcZwAs2sB4AWsztfoVHscZb2AwvplsR/kEG99y4yOHZ5vjOAKDsen/Afw9o8LDd+tnAAAAAElFTkSuQmCC", + "description": null, + "descriptor": { + "type": "static", + "sizeX": 5.5, + "sizeY": 5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.unreadNotificationWidget.onInit();\n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '400px',\n previewHeight: '300px',\n embedTitlePanel: true\n };\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-unread-notification-widget-settings", + "hasBasicMode": true, + "basicModeDirective": "tb-unread-notification-basic-config", + "defaultConfig": "{\"datasources\":[{\"type\":\"static\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0\",\"settings\":{\"cardHtml\":\"
HTML code here
\",\"cardCss\":\".card {\\n font-weight: bold;\\n font-size: 32px;\\n color: #999;\\n width: 100%;\\n height: 100%;\\n display: flex;\\n align-items: center;\\n justify-content: center;\\n}\",\"maxNotificationDisplay\":6,\"showCounter\":true,\"counterValueFont\":{\"family\":\"Roboto\",\"size\":12,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"600\",\"lineHeight\":\"\"},\"counterValueColor\":\"#fff\",\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"enableViewAll\":true,\"enableFilter\":true,\"enableMarkAsRead\":true},\"title\":\"Unread notification\",\"dropShadow\":true,\"configMode\":\"basic\",\"titleFont\":{\"size\":16,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"500\",\"style\":\"normal\",\"lineHeight\":\"24px\"},\"titleColor\":\"#000000\",\"showTitleIcon\":true,\"iconSize\":\"22px\",\"titleIcon\":\"notifications\",\"iconColor\":\"#000000\",\"actions\":{},\"enableFullscreen\":false,\"borderRadius\":\"4px\",\"titleTooltip\":\"\",\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\"}" + }, + "tags": null +} diff --git a/application/src/main/data/json/system/widget_types/update_json_attribute.json b/application/src/main/data/json/system/widget_types/update_json_attribute.json index 3ca5f234ee..7bdfbb51ce 100644 --- a/application/src/main/data/json/system/widget_types/update_json_attribute.json +++ b/application/src/main/data/json/system/widget_types/update_json_attribute.json @@ -3,7 +3,7 @@ "name": "Update JSON attribute", "deprecated": true, "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAIAAADGnbT+AAAABmJLR0QA/wD/AP+gvaeTAAATZElEQVR42u2dB1sUVxeA/Sum99h7b9FEE2OiBrvGGrtijyV27L2g2Hv3U2JXRFQQQVCsEVEDqCAqig3Lfi9742QzsyzDMrvsLuc8+/DM3J3Czn3n3HPPuffcUjab7dWrVw9ERCyS3NxcoCqlqHr79q1NRKTIAkjgBFSlhCoRT7CVB5Y8CxFrJQ+shw8fyoMQsVaASsASEbBEBCwRAUvAsrhP9Pz585ycnCceEy7OLXR9+eK6r4DlJeHRP/GKcCNfuK+A5SXxqM7Q6Q9fuK+A5SV54kXxhfsKWAJWAIG1bdu28PBwAUvAci4hISHffvstkUXHwqdPn9avX3/BggUuTmzTpk3v3r2LUk+bNm0aOXKkgBWYYJ04caJ06dLHjh1zLNy1axeFiYmJHgJLjb5YtWpVnz592Hjz5o2AFWhgUanVqlULDg52LOzSpUu9evW0XfTZtWvX7ty54xQsrvDo0SNHncfuy5cvtd3Xr1/fuHEjOztb88rUqVOnZ8+egwcP5iITJkyoWrWqX8TLXdTH33///ZeDZGRk8DczM1M74Pz58+fOnbMcrKysLG7E01O71Di79+1ClRmPv3r1qnawx20sWsMvv/xS82GAxUcffbR48WK1u3nz5rJly1KCDmvWrBlPUAfW3bt3+erQoUOaNmKXszSNWL169dJ24RQeBIVJSUnz58//4osv3n///REjRnCuI4j+CNb+/ftXrFgxadKkuXPnsnHhwoU///zzypUr2gH79u2jHbAcrIsXL44aNerw4cNq9+TJk+zGxcWB1549e4zH79y5MyUlxUtgoU6odR6N2t24cSP1nZ6eznZCQsJ77723du1a1BIANWrUqFu3bubBSk5O/uSTT6ZPnw61/J66dev27dtXHcbtqlSp0qBBg9WrVweMjbV8+fJTp05pdczPZyMqKmr79u3r168HrNjYWFQXhRx2+fJlS8CaPHny7Nmz1e6SJUvYBSyeNq/048ePwSsmJmbr1q2KcmweVIP3eoU//PCDMncUMR06dFDbNH88F80GmjVrVuXKlc2DNXbsWDoBWqCAQjQfLSN6q0yZMrB19uzZzz77jNcr8MBi+8yZM9TxjBkzeD9pAQArOjp66dKl1DfVb15zuAZrgV1o+FJTU6mgZcuWcdP4+Hgg40YoMKqG/ruCD4XKKd4Da82aNVQwncF79+598MEHu3fv1r66desWZhCo0Q6iY8qVK2cerJ9++gn7qfc7CQoK4qu0tDS+ogLU8UePHgW1QAULmGgTtaYQ+2b8+PEYW3PmzLGqKYQYlBNKkRvRJi5atEgHFqYeWopKLAawOPLjjz+Gp7CwMOytZ8+eqfLr169/9dVXWNmoUOyG33//vVBgNW/eHBwX/1f8d1yrG2BR2Zg17KIzlI1Fm4htcODAAavAQiNiqk+dOpXL0sKgvXwILIRuGvbTjz/+OGzYMK2QFwuVo+3OmzfPCBZ3gSTNWeoI1qBBg77//vuS4yA1goW+p9XbsGHDtGnTFFioKyr79u3bFoLFBpbxypUr2fA5sA4ePPjhhx9iqmPraYUomE8//ZT2m22sv5o1a6LPjH4saEOrYUthjXGKBhbNHNv0ktRhaDV1WKCCBS44GrRt5W5Ao/Po+IsNwC4Pk7q30N1A34sNbBhuwcbNmzeVuwGm2cV+xdEDCaongWHHKV4FC0dU+fLlQcex4vmf8MtjdVWsWPHrr7/u3r075OGP0IGFD10dg2OC5o9tzd0AZxjsmPy1a9eGUbonJTmkQ5cQlwRGRcmKFYIz75muEMsaHRYREcHVUK08FGx848G8HygkvuV43FSOhhQvLuoQ7eXvQfGiVxvvJLpEgtAiEtIRsAQsAUvAErBE/hUZmixgeURkMoWA5RGR6V8CloinRMASEbBEBCwRAUvAEhGwRAIPLMa60NvkYMkKXDKFqsfjYHIcbyGmf7mNlLy+ASD4rhibqcZsmZngaRYsdJXbpEutBBJeDOmxMqRTlBZQ6iOQhKmdJoExBVZR2mapjABTWmbqVMASKbT4KFh0K44fP85odzNNtYiAlSfMMCEhlmuwmOHEfC8BS8AyC9bp06eZHkieBddgde7cmckUUj0ClimwmGPDXFbmQxYIVseOHZlAJ9UjYBWiKUQVFQhWq1atmAQm1SNgWQbWixcvmOLctWtXWcVOwLISLPqD5Cn45ptvtGwiIgKWZU0hqY78JdOViE+A1b9//1q1an3++eckJo2MjBTjXcDyqoO0R48eNIhSPQKWxWChzEg1QxY/UupIJQlYEisUCTiw6EWSKIwNkvsEkp4jI7J6+CTX84tM48UDFgkzyX9MRl5SkloIFhm/8alycRIzt2vXjqxaNvuIVnIZFteTteruDRs2JGyqMruSONjkWaSZnDJlCqnqiHbkV5KfsDIAIwD8CSxevk6dOpGUF7bI2m0hWCx5guuLNG79+vXTkoEzRpaVMooLLEvujlePVLPka2XSOo+OlMYmXzNywJKiHJJatGjhtCQ/IeBBwkQtBb+fNYU0VawlQZtlFVjoBvz1tBSkqiZDKyXkYCWTIjkpZ9hFtZI8NVJYDx8+XHU5SQ1PwtaZM2eSeJdyZgGQZ1ANrJg4caI6xXgWQiidPLMDBw4cMmSIWr6FtwVPCgtFcU2ndyehKIsnqNPJpKp+svE6unuR/TA0NJSB5OvWrdu7d6/2ew/apcDHwok8Z8fh58YS3fHoe/Ln+itY5JHm/fOo8Y7CIN8kuUxP2kUlVsQxS31QZ6zAQ458NAHJYUkMDgRUJ/nySVrJ+8qSUgzsqVGjhkrtqjuLi/PcyRlM0mIKVVoVvG54dDmMFKkM9THeHW2tqYrGjRtzI+N1nN7Lqfzyyy80/QU+BJIr6xAxljgKi8cwRIC7+yVYPDjWmOBBe7pXqGuMtmzZwmicfXZhAx0DWBSiDLA8KAQv6ptaV8ejNsi0azxLAQEujvdC1fG7OAaTSGWG1t09P7Acr+P0Xk4F9aYWknEhqEP+GcfDjCWOwjg5sgyjzPwSLExI3kVSh3vB3aCrWhYIad++feg7Ye0QBRYvMRrUCBZN2FK76M4yAsH/TyCBpPu0ceg5GDLeHdq0PPX5geX0Xu4J3Rf+JXpILkp0wrIUYAfQuA/Ru0VcmMirYJEWvEmTJkeOHPGOH4us4Fg5WgYwXkSqVuVpVqLAUtsaWDRGvLWcy/Ol1o1nGYFgvaTWrVur7ZYtWyqwdHenNaRlVAkU8wPL6b2cCmYQija/bxkY0rRpU8fQhbHEeB2q+LZdMOawtLRKIbaLxVnYtWS8CtalS5fIz17vnbCalKcdpNhP3Ig1MrCC2V24cCHZ57FO4JuKdwoWyxrQweQs7Hr1le4sIxCqy8aKLLzuJLhXTaHx7jhZiJOCYIUKFdSSesYm1Xgvp0KQnrrP71saNZ5zg3fCGjPGEhfX0TWFmJ54OlhbQDzv+gYRA8ixA0+fVK3LahTVFKIzHE8p8CxNJxmP0d1dWeiu334z93pmF0u8yq6vo2a18G74tMby/ZCOo40losIYuL74W3JDOiI+JQEbK6QpYbUZ9y6V36horHLNsnZj5LSfRv38ACyqnBX3cEXi6cYY9Gis0O1GDd833TfVa9OJ1pszHmMmMuhG1A9//WYHUf1NAUsvPCaWumPVrqFDh+I79mis0G2wsK/xITlNU66BZTymwMig21E/za3F9VkLU8ByJbjp6HJbCJYxVggBePlYiXPMmDGE69Xbr4vWGWOFOERUdM8xnIKjksMInFO1XNZ4jDEyiMdBc2Tgt8PRVcSoH7+LmUvSFLoS4iQ8I9Yb9qjxDgE4wXHJ7NixA88QPiFjUMUYK2RICY4lBq+CgqYzqlevTt2zRrJq/ozHGCODuIJZTlZ10YmTEGnI7/80E/XDnvvuu+/cNhlLClhEOvHLMSXawtENrh0HjRo1Qos4BUsXK1TfMpVDg4ZvR48erWsKdcc4bQpp74CMLgVcunACmYn60W4S6hHj/YGZ2A4RBvy/3gELnqDKGK0zxgqN0NB+MZDGDbDQlDTErNkZHBxcxN/CaBxtmVkBy3mvUG2w5DCxDlZb9SZYxmidMaSjtllHmA6m2qYxpRlS7mlHsByPsRkigzb72Ca6gb169XJtp7uO+ikhbO93CQe8ChavOIGqoKAgQmbYNJ62sXRg2QzRuvzAwqJC/RDHUDE1rHsGaWEJsRC6ivEZj7EZIoMIq89j3rkOhriO+tnsKwyWKVPGTK7YEt0UoqtwMvG3uDzvZqJ1yt1Ke63t0t80xu90x9gMkUEG8S1ZsqTAGwVkMgEJ6XhE6PmiLwcMGBDwHnYBS6Rkg8WAcXpADNUo7DgNEQErz4oPDw93ARZu9JCQEKkeAasQYOFBpq/kepYOIRq6S1I9ApZZsOhbEcSgH+4aLGZTue6EiwhY/xG82GFhYcRlXYOFP/Dnn3+W6hGwTIHFuADcjLiRCgTLZk+RhaVFSlKpJAGrAGH0NFNVaeMYGlCpUiVCv/mBxcgqIseEU6RvKGAVDBZhlgt2od+HpeU4iFR3Kaart23bVqpHwCqcu6HAplCMdwHLHbAK9Lxbkj5ARMD695/AH8HcdsYImBwVLiJgSaxQwBKwRPwLLFI0rXwnDPcWsAQsa8BilCZuTwFLwLIYLOYFMHlBmkIBy2KwmPWl1uSl9ydgCViWgcVUO4btMncFh4IjW1ITApY1vULy3zFMVMASsKwBS00tZJYO0ejo6GgBS8CyACzmIjPzjvAzk/vGjRsnNpaAZeW8QkY3ODoaBCwBy0obS8ASsAQskcIJeZesBItjBCwRmz0jgUlgTIFF+goBSwR1RWIf5odaBhbj091WWlIfgYEUugqqwMBMehyzYNnsyYNBtShtoohfC1UPACaTLhUCLBER8yJgiQhYIgKWiIAlYIkIWCIClkiBcud+9uCZm6t3nFym1Tjf//B/Dpy+KSXtvoDl61TV7hLiF0g5fmp1nsp/LmD5rqCr/I4q9Rkya4uA5bviLy2gszZxioDlu+KnVKmPgCVgCVgCloAlImAJWAKWgCVgCVgiApaAJWAJWMUtJApg9XIvrHpalHqt1mFyhaDxApb3hEkf5CNhHSitJDMzkxLmZ5u8AgsBk1HcC4sbuFGd5Vr/sWDTkaxHOfZf+ibi7NUmvWcLWF4CCyxYhEwrIaESJWlpaQEA1tz1hzhxb2Ti4BmbZ609mPPsZUpqZvniUF0C1n/ASk9PZ7YJU01o7FJTUx3poQVkcQ3+6sBiwhPlrEPOSi3FDlb85VvPX+SWbf3P7qItx7JznrcfuYztSm0njJy3fenWiInL9tbsPJWSoOFLJ4Tuqdt1mjq4f8iGsYt2qe1m/ebNWXdo5poDLYMXC1gWgEWbSHY4VqLnL4VxcXHqGFZhYTcyMpKvOEYDKzk5me2oqCi+YkX7jIyM4gUr/ETe75q+an+lthN1I6Wu3br74uUryHv6/OW9rMf1uk3vPn41B08JC+cADLLHOc8j466xPWDaxpe5r2lPMx48fvX6zbA5WwUsa8BSi4QnJSVRzjYLzR89ejQhIYE5mZzOwhkKLBQbGxymLks5eFnYRLpRnY16zrx2615etT1+umLnCc3A6vbHqujzyb0mrmW7z5T1HIDewiCDsJMJ1ylUkI2av6NimwkPsp+e/ysVNGlD4y7d5BhNBQpY7oN17tw5VU4DRzmpA9QBmjbSmkLWVWSDplCV03Sya+Gzcq8NApf+0zaeTkzmNXjz5i3NmSqv2n7SiHnbF24+uuNInhpesjWCwrV7T6Oc0Gf2jVcMd+k0OoxvD8dcopXkExF7hd2GPWYKWAUIbgIdWGTtcg0WX7GhpQLQwGIBMzY000pdx8LWsIjmc4tBCy/dSAevpn3nNv5tNoonPePRxn0xjmC1HRHKNpb+zfT7B09f1PRZWsbDS8np2qf5gPkCVsHCQj0kqtR2FUDqNzoFKysriw0UkipnRU8Flo4kZW9xfHGBVaPTFDCKOveXVjJvw2GuQzOHD4KNlsGLFHAaWHxu38mKu3xL4cVu84EL2F65O0q7iHudypIIFsvWQQB/6eLRm1N2lXJ4OgWLr6LsAkNoL+x3BRZC4alTp7gObjDOjY2NLV7j/cCpJNgCi98mraXhu5P56MnTFwycn7F6PxcM3X68x4Q1tJJsL98RqU4J3Xac3ZxnLyq3y7P3MafOJKVg5k9eHv7ruJWb9p+JvZgiNpbZ1vD69euKD2WVY56rr5yCpXwKMMQuCCYmJmq9wuzs7JiYmCN2iY+Pt3atYTfAqtJ+0vrwaGD65xVKudNlzApVfiL+mv23v6Up5K9q+DQV9b+IBO0i9bvPOBJzGf8q5XQMg2dtEY1VOKHHV6jIjOotGuWVXXwnpIPvgO4h1rpx/kwVQ2F+H7qH2Ox0BcSPFWgiQWgRAUvAErAELAFLwBIRsAQsAUvAErAELBEBS8DyB/HfpCBELQUs3xWSmPkpWENnbxWwfFdIjVfLPobYvz61fw25K4nXfFxIjUcSM8bf+UtaLHSVGaoELBFPiYAlImCJCFgiApaAJSJgifgRWLIIqojlAlR5YDH3Q56FiFWiVrovlZubK2yJWEsV05xK2ewTUdS6v9kiIkUQRZGaPPd/pUO/DVg1Lm4AAAAASUVORK5CYII=", - "description": "Simple form to input new JSON value for pre-defined attribute/timeseries key.\nThe widget is deprecated. Use \"Update Multiple Attributes\" widget. JSON value type can be selected in widgets data key configuration.", + "description": "Simple form to input new JSON value for pre-defined attribute/time series key.\nThe widget is deprecated. Use \"Update Multiple Attributes\" widget. JSON value type can be selected in widgets data key configuration.", "descriptor": { "type": "latest", "sizeX": 7.5, diff --git a/application/src/main/data/json/system/widget_types/update_server_string_attribute.json b/application/src/main/data/json/system/widget_types/update_server_string_attribute.json index 193300cb06..d8c33bffcc 100644 --- a/application/src/main/data/json/system/widget_types/update_server_string_attribute.json +++ b/application/src/main/data/json/system/widget_types/update_server_string_attribute.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n
\n
\n \n
\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\n
\n
\n
\n
", "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", - "controllerScript": "let $scope;\nlet settings;\nlet attributeService;\nlet utils;\nlet translate;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n $scope = self.ctx.$scope;\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n settings.isRequired = utils.defaultValue(settings.isRequired, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false;\n\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-attribute-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n \n var validators = [$scope.validators.minLength(settings.minLength),\n $scope.validators.maxLength(settings.maxLength)];\n \n if (settings.isRequired) {\n validators.push($scope.validators.required);\n }\n \n $scope.attributeUpdateFormGroup = $scope.fb.group({\n currentValue: [undefined, validators]\n });\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n\n $scope.entityDetected = true;\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"attribute\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n \n var value = $scope.attributeUpdateFormGroup.get('currentValue').value;\n \n if (!$scope.attributeUpdateFormGroup.get('currentValue').value.length) {\n value = null;\n }\n\n attributeService.saveEntityAttributes(\n datasource.entity.id,\n 'SERVER_SCOPE',\n [\n {\n key: $scope.currentKey,\n value\n }\n ]\n ).subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n}\n\nself.onDataUpdated = function() {\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue($scope.originalValue);\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n\n}", + "controllerScript": "let $scope;\nlet settings;\nlet attributeService;\nlet utils;\nlet translate;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n $scope = self.ctx.$scope;\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n settings.isRequired = utils.defaultValue(settings.isRequired, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false;\n\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-attribute-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n \n var validators = [];\n if (utils.isDefinedAndNotNull(settings.minLength)) {\n validators.push($scope.validators.minLength(settings.minLength));\n }\n if (utils.isDefinedAndNotNull(settings.maxLength)) {\n validators.push($scope.validators.maxLength(settings.maxLength));\n }\n if (settings.isRequired) {\n validators.push($scope.validators.required);\n }\n \n $scope.attributeUpdateFormGroup = $scope.fb.group({\n currentValue: [undefined, validators]\n });\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n\n $scope.entityDetected = true;\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"attribute\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n \n var value = $scope.attributeUpdateFormGroup.get('currentValue').value;\n \n if (!$scope.attributeUpdateFormGroup.get('currentValue').value.length) {\n value = null;\n }\n\n attributeService.saveEntityAttributes(\n datasource.entity.id,\n 'SERVER_SCOPE',\n [\n {\n key: $scope.currentKey,\n value\n }\n ]\n ).subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n}\n\nself.onDataUpdated = function() {\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue($scope.originalValue);\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n\n}", "settingsSchema": "", "dataKeySettingsSchema": "{}\n", "settingsDirective": "tb-update-string-attribute-widget-settings", diff --git a/application/src/main/data/json/system/widget_types/update_shared_string_attribute.json b/application/src/main/data/json/system/widget_types/update_shared_string_attribute.json index 4c5333ec59..7578838066 100644 --- a/application/src/main/data/json/system/widget_types/update_shared_string_attribute.json +++ b/application/src/main/data/json/system/widget_types/update_shared_string_attribute.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n
\n
\n \n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\n
\n
\n
\n
", "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", - "controllerScript": "let $scope;\nlet settings;\nlet attributeService;\nlet utils;\nlet translate;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n\n $scope = self.ctx.$scope;\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n settings.isRequired = utils.defaultValue(settings.isRequired, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false; \n $scope.message = translate.instant('widgets.input-widgets.no-entity-selected');\n \n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-attribute-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n \n var validators = [$scope.validators.minLength(settings.minLength),\n $scope.validators.maxLength(settings.maxLength)];\n \n if (settings.isRequired) {\n validators.push($scope.validators.required);\n }\n \n $scope.attributeUpdateFormGroup = $scope.fb.group({\n currentValue: [undefined, validators]\n });\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType === 'DEVICE') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n \n $scope.entityDetected = true;\n }\n } else {\n $scope.message = translate.instant('widgets.input-widgets.not-allowed-entity');\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"attribute\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n var value = $scope.attributeUpdateFormGroup.get('currentValue').value;\n \n if (!$scope.attributeUpdateFormGroup.get('currentValue').value.length) {\n value = null;\n }\n\n attributeService.saveEntityAttributes(\n datasource.entity.id,\n 'SHARED_SCOPE',\n [\n {\n key: $scope.currentKey,\n value\n }\n ]\n ).subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n}\n\nself.onDataUpdated = function() {\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue($scope.originalValue);\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n\n}", + "controllerScript": "let $scope;\nlet settings;\nlet attributeService;\nlet utils;\nlet translate;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n\n $scope = self.ctx.$scope;\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n settings.isRequired = utils.defaultValue(settings.isRequired, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false; \n $scope.message = translate.instant('widgets.input-widgets.no-entity-selected');\n \n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-attribute-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n \n var validators = [];\n if (utils.isDefinedAndNotNull(settings.minLength)) {\n validators.push($scope.validators.minLength(settings.minLength));\n }\n if (utils.isDefinedAndNotNull(settings.maxLength)) {\n validators.push($scope.validators.maxLength(settings.maxLength));\n }\n if (settings.isRequired) {\n validators.push($scope.validators.required);\n }\n \n $scope.attributeUpdateFormGroup = $scope.fb.group({\n currentValue: [undefined, validators]\n });\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType === 'DEVICE') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n \n $scope.entityDetected = true;\n }\n } else {\n $scope.message = translate.instant('widgets.input-widgets.not-allowed-entity');\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"attribute\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n var value = $scope.attributeUpdateFormGroup.get('currentValue').value;\n \n if (!$scope.attributeUpdateFormGroup.get('currentValue').value.length) {\n value = null;\n }\n\n attributeService.saveEntityAttributes(\n datasource.entity.id,\n 'SHARED_SCOPE',\n [\n {\n key: $scope.currentKey,\n value\n }\n ]\n ).subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n}\n\nself.onDataUpdated = function() {\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue($scope.originalValue);\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n\n}", "settingsSchema": "", "dataKeySettingsSchema": "{}\n", "settingsDirective": "tb-update-string-attribute-widget-settings", diff --git a/application/src/main/data/json/system/widget_types/update_string_timeseries.json b/application/src/main/data/json/system/widget_types/update_string_timeseries.json index 43a96d6f69..d54224ef36 100644 --- a/application/src/main/data/json/system/widget_types/update_string_timeseries.json +++ b/application/src/main/data/json/system/widget_types/update_string_timeseries.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n
\n
\n \n
\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-timeseries-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.attribute-not-allowed' | translate }}\n
\n
\n
\n
", "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", - "controllerScript": "let $scope;\nlet settings;\nlet utils;\nlet translate;\nlet http;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n\n $scope = self.ctx.$scope;\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n http = $scope.$injector.get(self.ctx.servicesMap.get('http'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false;\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-timeseries-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n\n $scope.attributeUpdateFormGroup = $scope.fb.group(\n {currentValue: [undefined, [$scope.validators.required,\n $scope.validators.minLength(settings.minLength),\n $scope.validators.maxLength(settings.maxLength)]]}\n );\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n\n $scope.entityDetected = true;\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"timeseries\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n\n let observable = saveEntityTimeseries(\n datasource.entityType,\n datasource.entityId,\n [\n {\n key: $scope.currentKey,\n value: $scope.attributeUpdateFormGroup.get('currentValue').value\n }\n ]\n );\n if (observable) {\n observable.subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n\n function saveEntityTimeseries(entityType, entityId, telemetries) {\n var telemetriesData = {};\n for (var a = 0; a < telemetries.length; a++) {\n if (typeof telemetries[a].value !== 'undefined' && telemetries[a].value !== null) {\n telemetriesData[telemetries[a].key] = telemetries[a].value;\n }\n }\n if (Object.keys(telemetriesData).length) {\n var url = '/api/plugins/telemetry/' + entityType + '/' + entityId + '/timeseries/scope';\n return http.post(url, telemetriesData);\n }\n return null;\n }\n}\n\nself.onDataUpdated = function() {\n\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue($scope.originalValue);\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n\n}\n", + "controllerScript": "let $scope;\nlet settings;\nlet utils;\nlet translate;\nlet http;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n\n $scope = self.ctx.$scope;\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n http = $scope.$injector.get(self.ctx.servicesMap.get('http'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false;\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-timeseries-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n \n var validators = [];\n if (utils.isDefinedAndNotNull(settings.minLength)) {\n validators.push($scope.validators.minLength(settings.minLength));\n }\n if (utils.isDefinedAndNotNull(settings.maxLength)) {\n validators.push($scope.validators.maxLength(settings.maxLength));\n }\n if (settings.isRequired) {\n validators.push($scope.validators.required);\n }\n\n $scope.attributeUpdateFormGroup = $scope.fb.group(\n {currentValue: [undefined, validators]}\n );\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n\n $scope.entityDetected = true;\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"timeseries\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n\n let observable = saveEntityTimeseries(\n datasource.entityType,\n datasource.entityId,\n [\n {\n key: $scope.currentKey,\n value: $scope.attributeUpdateFormGroup.get('currentValue').value\n }\n ]\n );\n if (observable) {\n observable.subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n\n function saveEntityTimeseries(entityType, entityId, telemetries) {\n var telemetriesData = {};\n for (var a = 0; a < telemetries.length; a++) {\n if (typeof telemetries[a].value !== 'undefined' && telemetries[a].value !== null) {\n telemetriesData[telemetries[a].key] = telemetries[a].value;\n }\n }\n if (Object.keys(telemetriesData).length) {\n var url = '/api/plugins/telemetry/' + entityType + '/' + entityId + '/timeseries/scope';\n return http.post(url, telemetriesData);\n }\n return null;\n }\n}\n\nself.onDataUpdated = function() {\n\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue($scope.originalValue);\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n\n}\n", "settingsSchema": "", "dataKeySettingsSchema": "{}\n", "settingsDirective": "tb-update-string-attribute-widget-settings", diff --git a/application/src/main/data/json/tenant/dashboards/gateways.json b/application/src/main/data/json/tenant/dashboards/gateways.json index 11813ae878..a62550ed95 100644 --- a/application/src/main/data/json/tenant/dashboards/gateways.json +++ b/application/src/main/data/json/tenant/dashboards/gateways.json @@ -880,7 +880,7 @@ "isConnectorLog": true, "connectorLogState": "connector_logs" }, - "title": "${entityName} Logs", + "title": "${connectorName} logs", "showTitleIcon": false, "dropShadow": true, "enableFullscreen": true, @@ -1085,13 +1085,14 @@ "showLegend": false, "actions": {}, "datasources": [], - "targetDeviceAliasIds": [ - "a2f01c66-96cf-49c5-303f-e6f21c559ee8" - ], "showTitleIcon": false, "widgetCss": "", "pageSize": 1024, - "noDataDisplayMessage": "" + "noDataDisplayMessage": "", + "targetDevice": { + "type": "entity", + "entityAliasId": "a2f01c66-96cf-49c5-303f-e6f21c559ee8" + } }, "row": 0, "col": 0, @@ -1494,15 +1495,16 @@ "showLegend": false, "actions": {}, "datasources": [], - "targetDeviceAliasIds": [ - "a2f01c66-96cf-49c5-303f-e6f21c559ee8" - ], "showTitleIcon": false, "titleTooltip": "", "widgetCss": "", "pageSize": 1024, "noDataDisplayMessage": "", - "borderRadius": "4px" + "borderRadius": "4px", + "targetDevice": { + "type": "entity", + "entityAliasId": "a2f01c66-96cf-49c5-303f-e6f21c559ee8" + } }, "row": 0, "col": 0, @@ -1557,9 +1559,6 @@ }, "title": "Service RPC", "datasources": [], - "targetDeviceAliasIds": [ - "a2f01c66-96cf-49c5-303f-e6f21c559ee8" - ], "showTitleIcon": false, "titleTooltip": "", "dropShadow": true, @@ -1574,7 +1573,11 @@ "noDataDisplayMessage": "", "enableDataExport": false, "showLegend": false, - "borderRadius": "4px" + "borderRadius": "4px", + "targetDevice": { + "type": "entity", + "entityAliasId": "a2f01c66-96cf-49c5-303f-e6f21c559ee8" + } }, "row": 0, "col": 0, @@ -1596,9 +1599,6 @@ }, "title": "Service RPC", "datasources": [], - "targetDeviceAliasIds": [ - "a2f01c66-96cf-49c5-303f-e6f21c559ee8" - ], "showTitleIcon": false, "titleTooltip": "", "dropShadow": true, @@ -1613,7 +1613,11 @@ "noDataDisplayMessage": "", "enableDataExport": false, "showLegend": false, - "borderRadius": "4px" + "borderRadius": "4px", + "targetDevice": { + "type": "entity", + "entityAliasId": "a2f01c66-96cf-49c5-303f-e6f21c559ee8" + } }, "row": 0, "col": 0, @@ -1802,15 +1806,16 @@ "showLegend": false, "actions": {}, "datasources": [], - "targetDeviceAliasIds": [ - "a2f01c66-96cf-49c5-303f-e6f21c559ee8" - ], "showTitleIcon": false, "titleTooltip": "", "widgetCss": "", "pageSize": 1024, "noDataDisplayMessage": "", - "borderRadius": "4px" + "borderRadius": "4px", + "targetDevice": { + "type": "entity", + "entityAliasId": "a2f01c66-96cf-49c5-303f-e6f21c559ee8" + } }, "row": 0, "col": 0, @@ -2102,6 +2107,448 @@ "ACTIVE" ] } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "bd9176e1-9e04-3e9b-d5a5-07b72bb9ae90", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "mqttCount", + "color": "#4caf50", + "settings": {}, + "_hash": 0.9590451878027946, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entity", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "44038462-1bae-e075-7b31-283341cb2295", + "dataKeys": [ + { + "name": "count", + "type": "entityField", + "label": "modbusCount", + "color": "#4caf50", + "settings": {}, + "_hash": 0.9300660062254784, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "884e9c34-7534-a483-99be-81b56cd91185", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "grcpCount", + "color": "#f44336", + "settings": {}, + "_hash": 0.16110429492126088, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "e91ca0e9-1653-4fbc-5f3d-3da021b1b415", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "opcuaCount", + "color": "#ffc107", + "settings": {}, + "_hash": 0.13973521146913304, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "3f74cbaa-6353-5e88-a7e8-708fc0e18039", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "bleCount", + "color": "#607d8b", + "settings": {}, + "_hash": 0.7205723252294653, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "c08eee84-64ee-73c4-8d96-c0df813a92cd", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "requestCount", + "color": "#9c27b0", + "settings": {}, + "_hash": 0.6993292961463216, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "2f0af7f5-22ea-c0d5-3aef-7f2bb1b534ec", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "canCount", + "color": "#8bc34a", + "settings": {}, + "_hash": 0.4850065031079176, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "92a7d208-c143-ea20-5162-1da584532830", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "bacnetCount", + "color": "#3f51b5", + "settings": {}, + "_hash": 0.36987531665233075, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "498f090c-b1e5-df74-35d1-3ecf89d33f1c", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "odbcCount", + "color": "#e91e63", + "settings": {}, + "_hash": 0.8279345016611896, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "9175179d-a8db-848b-0762-e78da150e768", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "restCount", + "color": "#ffeb3b", + "settings": {}, + "_hash": 0.07245826389852739, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "9175179d-a8db-848b-0762-e78da150e768", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "restCount", + "color": "#03a9f4", + "settings": {}, + "_hash": 0.4321492578560704, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "1b70460b-428b-2aed-f23a-65927d3e67bb", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "snmpCount", + "color": "#ff9800", + "settings": {}, + "_hash": 0.7395574625078822, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "ae357478-b4c2-eee8-dde6-a6942fe6202f", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "ftpCount", + "color": "#673ab7", + "settings": {}, + "_hash": 0.9999791065203374, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "6eef4979-369f-c2cc-4894-96a84b6a668a", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "socketCount", + "color": "#cddc39", + "settings": {}, + "_hash": 0.47563823619478685, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "9c8e3a63-01a1-64b5-fe44-4f58f8350340", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "xmppCount", + "color": "#009688", + "settings": {}, + "_hash": 0.5679285702280172, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "c6501413-d823-29c4-992f-9ae6e8e25549", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "ocppCount", + "color": "#795548", + "settings": {}, + "_hash": 0.38390880166484287, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + }, + { + "type": "entityCount", + "entityAliasId": "a75d9031-ba51-8da4-81be-de65061b72f4", + "filterId": "2f04d6e5-8438-857a-ca78-ae78cc8b0c03", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "customCount", + "color": "#00bcd4", + "settings": {}, + "_hash": 0.9282529984749979, + "aggregationType": null, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } } ], "timewindow": { @@ -2134,7 +2581,7 @@ "padding": "0px", "settings": { "useMarkdownTextFunction": false, - "markdownTextPattern": "
\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n
\r\n", + "markdownTextPattern": "
\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n
\r\n", "applyDefaultMarkdownStyle": false, "markdownCss": ".mat-mdc-form-field-subscript-wrapper {\n display: none !important;\n}" }, diff --git a/application/src/main/data/upgrade/3.7.0/schema_update.sql b/application/src/main/data/upgrade/3.7.0/schema_update.sql new file mode 100644 index 0000000000..51e78a504e --- /dev/null +++ b/application/src/main/data/upgrade/3.7.0/schema_update.sql @@ -0,0 +1,101 @@ +-- +-- 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. +-- + +-- UPDATE RESOURCE SUB TYPE START + +DO +$$ + BEGIN + IF NOT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_name = 'resource' AND column_name = 'resource_sub_type' + ) THEN + ALTER TABLE resource ADD COLUMN resource_sub_type varchar(32); + UPDATE resource SET resource_sub_type = 'IMAGE' WHERE resource_type = 'IMAGE'; + END IF; + END; +$$; + +-- UPDATE RESOURCE SUB TYPE END + +-- UPDATE WIDGETS BUNDLE START + +DO +$$ + BEGIN + IF NOT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_name = 'widgets_bundle' AND column_name = 'scada' + ) THEN + ALTER TABLE widgets_bundle ADD COLUMN scada boolean NOT NULL DEFAULT false; + END IF; + END; +$$; + +-- UPDATE WIDGETS BUNDLE END + +-- UPDATE WIDGET TYPE START + +DO +$$ + BEGIN + IF NOT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_name = 'widget_type' AND column_name = 'scada' + ) THEN + ALTER TABLE widget_type ADD COLUMN scada boolean NOT NULL DEFAULT false; + END IF; + END; +$$; + +-- UPDATE WIDGET TYPE END + +-- KV VERSIONING UPDATE START + +CREATE SEQUENCE IF NOT EXISTS attribute_kv_version_seq cache 1; +CREATE SEQUENCE IF NOT EXISTS ts_kv_latest_version_seq cache 1; + +ALTER TABLE attribute_kv ADD COLUMN IF NOT EXISTS version bigint default 0; +ALTER TABLE ts_kv_latest ADD COLUMN IF NOT EXISTS version bigint default 0; + +-- KV VERSIONING UPDATE END + +-- RELATION VERSIONING UPDATE START + +CREATE SEQUENCE IF NOT EXISTS relation_version_seq cache 1; +ALTER TABLE relation ADD COLUMN IF NOT EXISTS version bigint default 0; + +-- RELATION VERSIONING UPDATE END + + +-- ENTITIES VERSIONING UPDATE START + +ALTER TABLE device ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; +ALTER TABLE device_profile ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; +ALTER TABLE device_credentials ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; +ALTER TABLE asset ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; +ALTER TABLE asset_profile ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; +ALTER TABLE entity_view ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; +ALTER TABLE tb_user ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; +ALTER TABLE customer ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; +ALTER TABLE edge ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; +ALTER TABLE rule_chain ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; +ALTER TABLE dashboard ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; +ALTER TABLE widget_type ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; +ALTER TABLE widgets_bundle ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; +ALTER TABLE tenant ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; + +-- ENTITIES VERSIONING UPDATE END diff --git a/application/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java b/application/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java new file mode 100644 index 0000000000..158f29a03a --- /dev/null +++ b/application/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java @@ -0,0 +1,68 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.http.converter.xml; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.util.Assert; + +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; + +/** + * RestTemplate firstly uses MappingJackson2XmlHttpMessageConverter converter instead of MappingJackson2HttpMessageConverter. + * It produces error UnsupportedMediaType, so this converter had to be shadowed for read and write operations to use the correct converter + */ +public class MappingJackson2XmlHttpMessageConverter extends AbstractJackson2HttpMessageConverter { + private static final List problemDetailMediaTypes; + + public MappingJackson2XmlHttpMessageConverter() { + this(Jackson2ObjectMapperBuilder.xml().build()); + } + + public MappingJackson2XmlHttpMessageConverter(ObjectMapper objectMapper) { + super(objectMapper, new MediaType[]{new MediaType("application", "xml", StandardCharsets.UTF_8), new MediaType("text", "xml", StandardCharsets.UTF_8), new MediaType("application", "*+xml", StandardCharsets.UTF_8)}); + Assert.isInstanceOf(XmlMapper.class, objectMapper, "XmlMapper required"); + } + + public void setObjectMapper(ObjectMapper objectMapper) { + Assert.isInstanceOf(XmlMapper.class, objectMapper, "XmlMapper required"); + super.setObjectMapper(objectMapper); + } + + protected List getMediaTypesForProblemDetail() { + return problemDetailMediaTypes; + } + + static { + problemDetailMediaTypes = Collections.singletonList(MediaType.APPLICATION_PROBLEM_XML); + } + + @Override + public boolean canRead(Type type, Class contextClass, MediaType mediaType) { + return false; + } + + @Override + public boolean canWrite(Class clazz, MediaType mediaType) { + return false; + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java index a2ac9d0cdc..4536cb6228 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java @@ -16,9 +16,6 @@ package org.thingsboard.server.actors.device; import lombok.extern.slf4j.Slf4j; -import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg; -import org.thingsboard.server.common.msg.rule.engine.DeviceEdgeUpdateMsg; -import org.thingsboard.server.common.msg.rule.engine.DeviceNameOrTypeUpdateMsg; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.TbActorCtx; import org.thingsboard.server.actors.TbActorException; @@ -26,10 +23,13 @@ import org.thingsboard.server.actors.service.ContextAwareActor; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.timeout.DeviceActorServerSideRpcTimeoutMsg; import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponseActorMsg; import org.thingsboard.server.common.msg.rpc.RemoveRpcActorMsg; import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequestActorMsg; +import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg; +import org.thingsboard.server.common.msg.rule.engine.DeviceEdgeUpdateMsg; +import org.thingsboard.server.common.msg.rule.engine.DeviceNameOrTypeUpdateMsg; +import org.thingsboard.server.common.msg.timeout.DeviceActorServerSideRpcTimeoutMsg; import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; @Slf4j diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index 7432038096..b34ddca1af 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -74,6 +74,7 @@ import org.thingsboard.server.gen.transport.TransportProtos.DeviceSessionsCacheE import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.SessionCloseNotificationProto; +import org.thingsboard.server.gen.transport.TransportProtos.SessionCloseReason; import org.thingsboard.server.gen.transport.TransportProtos.SessionEvent; import org.thingsboard.server.gen.transport.TransportProtos.SessionEventMsg; import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; @@ -90,7 +91,6 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToTransportUpdateCre import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; import org.thingsboard.server.gen.transport.TransportProtos.UplinkNotificationMsg; -import org.thingsboard.server.gen.transport.TransportProtos.SessionCloseReason; import org.thingsboard.server.service.rpc.RpcSubmitStrategy; import org.thingsboard.server.service.state.DefaultDeviceStateService; import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index 0dc9d68984..ca404fc78a 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -106,10 +106,8 @@ import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetsBundleService; import org.thingsboard.server.gen.transport.TransportProtos; -import org.thingsboard.server.queue.TbQueueCallback; -import org.thingsboard.server.queue.TbQueueMsgMetadata; -import org.thingsboard.server.service.executors.PubSubRuleNodeExecutorProvider; import org.thingsboard.server.queue.common.SimpleTbQueueCallback; +import org.thingsboard.server.service.executors.PubSubRuleNodeExecutorProvider; import org.thingsboard.server.service.script.RuleNodeJsScriptEngine; import org.thingsboard.server.service.script.RuleNodeTbelScriptEngine; diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleEngineComponentActor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleEngineComponentActor.java index bae9216446..f05fdb1fb9 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleEngineComponentActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleEngineComponentActor.java @@ -50,6 +50,9 @@ public abstract class RuleEngineComponentActor extends Abstract } public void scheduleStatsPersistTick(TbActorCtx context, long statsPersistFrequency) { - schedulePeriodicMsgWithDelay(context, new StatsPersistTick(), statsPersistFrequency, statsPersistFrequency); + schedulePeriodicMsgWithDelay(context, StatsPersistTick.INSTANCE, statsPersistFrequency, statsPersistFrequency); } protected boolean checkMsgValid(TbMsg tbMsg) { diff --git a/application/src/main/java/org/thingsboard/server/actors/stats/StatsPersistTick.java b/application/src/main/java/org/thingsboard/server/actors/stats/StatsPersistTick.java index 541da7494f..25271262b5 100644 --- a/application/src/main/java/org/thingsboard/server/actors/stats/StatsPersistTick.java +++ b/application/src/main/java/org/thingsboard/server/actors/stats/StatsPersistTick.java @@ -18,7 +18,9 @@ package org.thingsboard.server.actors.stats; import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.TbActorMsg; -public final class StatsPersistTick implements TbActorMsg { +public enum StatsPersistTick implements TbActorMsg { + INSTANCE; + @Override public MsgType getMsgType() { return MsgType.STATS_PERSIST_TICK_MSG; diff --git a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java index f7f6723fe7..d55e08143b 100644 --- a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java @@ -270,14 +270,16 @@ public class TenantActor extends RuleChainManagerActor { initRuleChains(); } } - if (msg.getEntityId().getEntityType() == EntityType.EDGE) { + if (msg.getEntityId().getEntityType() == EntityType.EDGE && isCore) { EdgeId edgeId = new EdgeId(msg.getEntityId().getId()); EdgeRpcService edgeRpcService = systemContext.getEdgeRpcService(); - if (msg.getEvent() == ComponentLifecycleEvent.DELETED) { - edgeRpcService.deleteEdge(tenantId, edgeId); - } else if (msg.getEvent() == ComponentLifecycleEvent.UPDATED) { - Edge edge = systemContext.getEdgeService().findEdgeById(tenantId, edgeId); - edgeRpcService.updateEdge(tenantId, edge); + if (edgeRpcService != null) { + if (msg.getEvent() == ComponentLifecycleEvent.DELETED) { + edgeRpcService.deleteEdge(tenantId, edgeId); + } else if (msg.getEvent() == ComponentLifecycleEvent.UPDATED) { + Edge edge = systemContext.getEdgeService().findEdgeById(tenantId, edgeId); + edgeRpcService.updateEdge(tenantId, edge); + } } } if (msg.getEntityId().getEntityType() == EntityType.DEVICE && ComponentLifecycleEvent.DELETED == msg.getEvent() && isMyPartition(msg.getEntityId())) { diff --git a/application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java b/application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java index 73209c1ed6..2aebc8bd06 100644 --- a/application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java +++ b/application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java @@ -15,6 +15,10 @@ */ package org.thingsboard.server.config; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.BadCredentialsException; @@ -22,18 +26,14 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.exception.TenantProfileNotFoundException; import org.thingsboard.server.common.data.limit.LimitedApi; import org.thingsboard.server.common.msg.tools.TbRateLimitsException; -import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; import org.thingsboard.server.service.security.model.SecurityUser; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @Slf4j diff --git a/application/src/main/java/org/thingsboard/server/config/RequestSizeFilter.java b/application/src/main/java/org/thingsboard/server/config/RequestSizeFilter.java new file mode 100644 index 0000000000..c2be2ed027 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/RequestSizeFilter.java @@ -0,0 +1,78 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; +import org.thingsboard.server.common.msg.tools.MaxPayloadSizeExceededException; +import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; + +import java.io.IOException; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RequestSizeFilter extends OncePerRequestFilter { + + private final List urls = List.of("/api/plugins/rpc/**", "/api/rpc/**"); + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + private final ThingsboardErrorResponseHandler errorResponseHandler; + + @Value("${transport.http.max_payload_size:65536}") + private int maxPayloadSize; + + @Override + public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + if (request.getContentLength() > maxPayloadSize) { + if (log.isDebugEnabled()) { + log.debug("Too large payload size. Url: {}, client ip: {}, content length: {}", request.getRequestURL(), + request.getRemoteAddr(), request.getContentLength()); + } + errorResponseHandler.handle(new MaxPayloadSizeExceededException(), response); + return; + } + chain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + for (String url : urls) { + if (pathMatcher.match(url, request.getRequestURI())) { + return false; + } + } + return true; + } + + @Override + protected boolean shouldNotFilterAsyncDispatch() { + return false; + } + + @Override + protected boolean shouldNotFilterErrorDispatch() { + return false; + } +} diff --git a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java index 963205c08b..c32533bd04 100644 --- a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java @@ -48,6 +48,7 @@ import org.springdoc.core.models.GroupedOpenApi; import org.springdoc.core.properties.SpringDocConfigProperties; import org.springdoc.core.properties.SwaggerUiConfigProperties; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; @@ -62,7 +63,6 @@ import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.exception.ThingsboardCredentialsExpiredResponse; import org.thingsboard.server.exception.ThingsboardErrorResponse; -import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.rest.LoginRequest; import org.thingsboard.server.service.security.auth.rest.LoginResponse; @@ -79,7 +79,7 @@ import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; @Slf4j @Configuration -@TbCoreComponent +@ConditionalOnExpression("('${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core') && '${springdoc.api-docs.enabled:true}'=='true'") @Profile("!test") public class SwaggerConfiguration { @@ -113,7 +113,8 @@ public class SwaggerConfiguration { private String version; @Value("${app.version:unknown}") private String appVersion; - + @Value("${swagger.group_name:thingsboard}") + private String groupName; @Bean public OpenAPI thingsboardApi() { @@ -212,7 +213,7 @@ public class SwaggerConfiguration { @Bean public GroupedOpenApi groupedApi(SpringDocParameterNameDiscoverer localSpringDocParameterNameDiscoverer) { return GroupedOpenApi.builder() - .group("thingsboard") + .group(groupName) .pathsToMatch(apiPath) .addRouterOperationCustomizer(routerOperationCustomizer(localSpringDocParameterNameDiscoverer)) .addOperationCustomizer(operationCustomizer()) diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java index a3111cb137..77ad1a1b9e 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -124,6 +124,9 @@ public class ThingsboardSecurityConfiguration { @Autowired private RateLimitProcessingFilter rateLimitProcessingFilter; + @Autowired + private RequestSizeFilter requestSizeFilter; + @Bean protected FilterRegistrationBean buildEtagFilter() throws Exception { ShallowEtagHeaderFilter etagFilter = new ShallowEtagHeaderFilter(); @@ -225,6 +228,7 @@ public class ThingsboardSecurityConfiguration { .addFilterBefore(buildRestPublicLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildJwtTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(requestSizeFilter, UsernamePasswordAuthenticationFilter.class) .addFilterAfter(rateLimitProcessingFilter, UsernamePasswordAuthenticationFilter.class); if (oauth2Configuration != null) { http.oauth2Login(login -> login diff --git a/application/src/main/java/org/thingsboard/server/config/WebConfig.java b/application/src/main/java/org/thingsboard/server/config/WebConfig.java index 70afc8b99c..5d4830c552 100644 --- a/application/src/main/java/org/thingsboard/server/config/WebConfig.java +++ b/application/src/main/java/org/thingsboard/server/config/WebConfig.java @@ -15,12 +15,12 @@ */ package org.thingsboard.server.config; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.thingsboard.server.utils.MiscUtils; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @Controller diff --git a/application/src/main/java/org/thingsboard/server/controller/AdminController.java b/application/src/main/java/org/thingsboard/server/controller/AdminController.java index 818a2a15a6..e4e6418168 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AdminController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AdminController.java @@ -29,16 +29,13 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Lazy; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -120,6 +117,9 @@ public class AdminController extends BaseController { private final SystemInfoService systemInfoService; private final AuditLogService auditLogService; + @Value("${queue.vc.request-timeout:180000}") + private int vcRequestTimeout; + @ApiOperation(value = "Get the Administration Settings object using key (getAdminSettings)", notes = "Get the Administration Settings object using specified string key. Referencing non-existing key will cause an error." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") @@ -137,7 +137,7 @@ public class AdminController extends BaseController { return adminSettings; } - @ApiOperation(value = "Get the Administration Settings object using key (getAdminSettings)", + @ApiOperation(value = "Creates or Updates the Administration Settings (saveAdminSettings)", notes = "Creates or Updates the Administration Settings. Platform generates random Administration Settings Id during settings creation. " + "The Administration Settings Id will be present in the response. Specify the Administration Settings Id when you would like to update the Administration Settings. " + "Referencing non-existing Administration Settings Id will cause an error." + SYSTEM_AUTHORITY_PARAGRAPH) @@ -160,7 +160,7 @@ public class AdminController extends BaseController { return adminSettings; } - @ApiOperation(value = "Get the Security Settings object", + @ApiOperation(value = "Get the Security Settings object (getSecuritySettings)", notes = "Get the Security Settings object that contains password policy, etc." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") @RequestMapping(value = "/securitySettings", method = RequestMethod.GET) @@ -237,7 +237,7 @@ public class AdminController extends BaseController { } } - @ApiOperation(value = "Send test sms (sendTestMail)", + @ApiOperation(value = "Send test sms (sendTestSms)", notes = "Attempts to send test sms to the System Administrator User using SMS Settings and phone number provided as a parameters of the request. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") @@ -301,13 +301,14 @@ public class AdminController extends BaseController { @PostMapping("/repositorySettings") public DeferredResult saveRepositorySettings(@RequestBody RepositorySettings settings) throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.WRITE); + settings.setLocalOnly(false); // only to be used in tests ListenableFuture future = versionControlService.saveVersionControlSettings(getTenantId(), settings); return wrapFuture(Futures.transform(future, savedSettings -> { savedSettings.setPassword(null); savedSettings.setPrivateKey(null); savedSettings.setPrivateKeyPassword(null); return savedSettings; - }, MoreExecutors.directExecutor())); + }, MoreExecutors.directExecutor()), vcRequestTimeout); } @ApiOperation(value = "Delete repository settings (deleteRepositorySettings)", @@ -318,7 +319,7 @@ public class AdminController extends BaseController { @ResponseStatus(value = HttpStatus.OK) public DeferredResult deleteRepositorySettings() throws Exception { accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.DELETE); - return wrapFuture(versionControlService.deleteVersionControlSettings(getTenantId())); + return wrapFuture(versionControlService.deleteVersionControlSettings(getTenantId()), vcRequestTimeout); } @ApiOperation(value = "Check repository access (checkRepositoryAccess)", @@ -329,8 +330,8 @@ public class AdminController extends BaseController { @Parameter(description = "A JSON value representing the Repository Settings.") @RequestBody RepositorySettings settings) throws Exception { accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.READ); - settings = checkNotNull(settings); - return wrapFuture(versionControlService.checkVersionControlAccess(getTenantId(), settings)); + settings.setLocalOnly(false); // only to be used in tests + return wrapFuture(versionControlService.checkVersionControlAccess(getTenantId(), settings), vcRequestTimeout); } @ApiOperation(value = "Get auto commit settings (getAutoCommitSettings)", @@ -482,4 +483,5 @@ public class AdminController extends BaseController { adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminSettings); response.sendRedirect(prevUri); } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index 15918f0a08..5865c96d33 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -16,11 +16,8 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java index 535700335d..9b786a8d3e 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java @@ -17,12 +17,9 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java index 7b4621db28..c54f5b0013 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -18,13 +18,10 @@ package org.thingsboard.server.controller; import com.google.common.util.concurrent.ListenableFuture; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetProfileController.java b/application/src/main/java/org/thingsboard/server/controller/AssetProfileController.java index a4dc7a1cb0..4b40cf3854 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetProfileController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetProfileController.java @@ -16,13 +16,10 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; @@ -40,8 +37,8 @@ import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.asset.profile.TbAssetProfileService; import org.thingsboard.server.service.security.model.SecurityUser; diff --git a/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java b/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java index c8ea4073b1..3db2813086 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java @@ -16,10 +16,7 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 0942329f40..29b9db10ab 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -22,6 +22,7 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.ConstraintViolation; import lombok.Getter; import org.apache.commons.lang3.exception.ExceptionUtils; +import org.hibernate.exception.ConstraintViolationException; import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -64,6 +65,7 @@ import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeInfo; +import org.thingsboard.server.common.data.exception.EntityVersionMismatchException; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.AlarmCommentId; @@ -140,6 +142,7 @@ import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetsBundleService; import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.action.EntityActionService; @@ -313,6 +316,9 @@ public abstract class BaseController { @Autowired protected ExportableEntitiesService entitiesService; + @Autowired + protected TbServiceInfoProvider serviceInfoProvider; + @Value("${server.log_controller_error_stack_trace}") @Getter private boolean logControllerErrorStackTrace; @@ -358,29 +364,35 @@ public abstract class BaseController { private ThingsboardException handleException(Exception exception, boolean logException) { if (logException && logControllerErrorStackTrace) { - log.error("Error [{}]", exception.getMessage(), exception); - } - - String cause = ""; - if (exception.getCause() != null) { - cause = exception.getCause().getClass().getCanonicalName(); + try { + SecurityUser user = getCurrentUser(); + log.error("[{}][{}] Error", user.getTenantId(), user.getId(), exception); + } catch (Exception e) { + log.error("Error", exception); + } } + Throwable cause = exception.getCause(); if (exception instanceof ThingsboardException) { return (ThingsboardException) exception; } else if (exception instanceof IllegalArgumentException || exception instanceof IncorrectParameterException - || exception instanceof DataValidationException || cause.contains("IncorrectParameterException")) { + || exception instanceof DataValidationException || cause instanceof IncorrectParameterException) { return new ThingsboardException(exception.getMessage(), ThingsboardErrorCode.BAD_REQUEST_PARAMS); } else if (exception instanceof MessagingException) { return new ThingsboardException("Unable to send mail: " + exception.getMessage(), ThingsboardErrorCode.GENERAL); } else if (exception instanceof AsyncRequestTimeoutException) { return new ThingsboardException("Request timeout", ThingsboardErrorCode.GENERAL); } else if (exception instanceof DataAccessException) { - String errorType = exception.getClass().getSimpleName(); if (!logControllerErrorStackTrace) { // not to log the error twice - log.warn("Database error: {} - {}", errorType, ExceptionUtils.getRootCauseMessage(exception)); + log.warn("Database error: {} - {}", exception.getClass().getSimpleName(), ExceptionUtils.getRootCauseMessage(exception)); + } + if (cause instanceof ConstraintViolationException) { + return new ThingsboardException(ExceptionUtils.getRootCause(exception).getMessage(), ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } else { + return new ThingsboardException("Database error", ThingsboardErrorCode.GENERAL); } - return new ThingsboardException("Database error", ThingsboardErrorCode.GENERAL); + } else if (exception instanceof EntityVersionMismatchException) { + return new ThingsboardException(exception.getMessage(), exception, ThingsboardErrorCode.VERSION_CONFLICT); } return new ThingsboardException(exception.getMessage(), exception, ThingsboardErrorCode.GENERAL); } diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index 918021ab95..4d83d4debb 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -117,6 +117,8 @@ public class ControllerConstants { protected static final String RESOURCE_INFO_DESCRIPTION = "Resource Info is a lightweight object that includes main information about the Resource excluding the heavyweight data. "; protected static final String RESOURCE_DESCRIPTION = "Resource is a heavyweight object that includes main information about the Resource and also data. "; + protected static final String RESOURCE_IMAGE_SUB_TYPE_DESCRIPTION = "A string value representing resource sub-type."; + protected static final String RESOURCE_INCLUDE_SYSTEM_IMAGES_DESCRIPTION = "Use 'true' to include system images. Disabled by default. Ignored for requests by users with system administrator authority."; protected static final String RESOURCE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the resource title."; @@ -791,7 +793,7 @@ public class ControllerConstants { " * 'SHARED_ATTRIBUTE' - used for shared attributes; \n" + " * 'SERVER_ATTRIBUTE' - used for server attributes; \n" + " * 'ATTRIBUTE' - used for any of the above; \n" + - " * 'TIME_SERIES' - used for time-series values; \n" + + " * 'TIME_SERIES' - used for time series values; \n" + " * 'ENTITY_FIELD' - used for accessing entity fields like 'name', 'label', etc. The list of available fields depends on the entity type; \n" + " * 'ALARM_FIELD' - similar to entity field, but is used in alarm queries only; \n" + "\n\n Let's review the example:\n\n" + @@ -902,7 +904,7 @@ public class ControllerConstants { protected static final String KEY_FILTERS = "\n\n # Key Filters" + - "\nKey Filter allows you to define complex logical expressions over entity field, attribute or latest time-series value. The filter is defined using 'key', 'valueType' and 'predicate' objects. " + + "\nKey Filter allows you to define complex logical expressions over entity field, attribute or latest time series value. The filter is defined using 'key', 'valueType' and 'predicate' objects. " + "Single Entity Query may have zero, one or multiple predicates. If multiple filters are defined, they are evaluated using logical 'AND'. " + "The example below checks that temperature of the entity is above 20 degrees:" + "\n\n" + MARKDOWN_CODE_BLOCK_START + @@ -933,7 +935,7 @@ public class ControllerConstants { "For example, \"find all devices with profile 'Moisture Sensor'\" or \"Find all devices related to asset 'Building A'\"" + "\n\nOptional **key filters** allow to filter results of the entity filter by complex criteria against " + "main entity fields (name, label, type, etc), attributes and telemetry. " + - "For example, \"temperature > 20 or temperature< 10\" or \"name starts with 'T', and attribute 'model' is 'T1000', and timeseries field 'batteryLevel' > 40\"." + + "For example, \"temperature > 20 or temperature< 10\" or \"name starts with 'T', and attribute 'model' is 'T1000', and time series field 'batteryLevel' > 40\"." + "\n\nLet's review the example:" + "\n\n" + MARKDOWN_CODE_BLOCK_START + "{\n" + @@ -968,13 +970,13 @@ public class ControllerConstants { protected static final String ENTITY_DATA_QUERY_DESCRIPTION = "Allows to run complex queries over platform entities (devices, assets, customers, etc) " + "based on the combination of main entity filter and multiple key filters. " + - "Returns the paginated result of the query that contains requested entity fields and latest values of requested attributes and time-series data.\n\n" + + "Returns the paginated result of the query that contains requested entity fields and latest values of requested attributes and time series data.\n\n" + "# Query Definition\n\n" + "\n\nMain **entity filter** is mandatory and defines generic search criteria. " + "For example, \"find all devices with profile 'Moisture Sensor'\" or \"Find all devices related to asset 'Building A'\"" + "\n\nOptional **key filters** allow to filter results of the **entity filter** by complex criteria against " + "main entity fields (name, label, type, etc), attributes and telemetry. " + - "For example, \"temperature > 20 or temperature< 10\" or \"name starts with 'T', and attribute 'model' is 'T1000', and timeseries field 'batteryLevel' > 40\"." + + "For example, \"temperature > 20 or temperature< 10\" or \"name starts with 'T', and attribute 'model' is 'T1000', and time series field 'batteryLevel' > 40\"." + "\n\nThe **entity fields** and **latest values** contains list of entity fields and latest attribute/telemetry fields to fetch for each entity." + "\n\nThe **page link** contains information about the page to fetch and the sort ordering." + "\n\nLet's review the example:" + @@ -1052,7 +1054,7 @@ public class ControllerConstants { protected static final String ALARM_DATA_QUERY_DESCRIPTION = "This method description defines how Alarm Data Query extends the Entity Data Query. " + "See method 'Find Entity Data by Query' first to get the info about 'Entity Data Query'." + "\n\n The platform will first search the entities that match the entity and key filters. Then, the platform will use 'Alarm Page Link' to filter the alarms related to those entities. " + - "Finally, platform fetch the properties of alarm that are defined in the **'alarmFields'** and combine them with the other entity, attribute and latest time-series fields to return the result. " + + "Finally, platform fetch the properties of alarm that are defined in the **'alarmFields'** and combine them with the other entity, attribute and latest time series fields to return the result. " + "\n\n See example of the alarm query below. The query will search first 100 active alarms with type 'Temperature Alarm' or 'Fire Alarm' for any device with current temperature > 0. " + "The query will return combination of the entity fields: name of the device, device model and latest temperature reading and alarms fields: createdTime, type, severity and status: " + "\n\n" + MARKDOWN_CODE_BLOCK_START + @@ -1173,7 +1175,7 @@ public class ControllerConstants { protected static final String ALARM_FILTER_KEY = "## Alarm Filter Key" + NEW_LINE + "Filter Key defines either entity field, attribute, telemetry or constant. It is a JSON object that consists the key name and type. The following filter key types are supported:\n" + " * 'ATTRIBUTE' - used for attributes values;\n" + - " * 'TIME_SERIES' - used for time-series values;\n" + + " * 'TIME_SERIES' - used for time series values;\n" + " * 'ENTITY_FIELD' - used for accessing entity fields like 'name', 'label', etc. The list of available fields depends on the entity type;\n" + " * 'CONSTANT' - constant value specified." + NEW_LINE + "Let's review the example:" + NEW_LINE + MARKDOWN_CODE_BLOCK_START + @@ -1291,7 +1293,7 @@ public class ControllerConstants { protected static final String KEY_FILTERS_DESCRIPTION = "# Key Filters" + NEW_LINE + "Key filter objects are created under the **'condition'** array. They allow you to define complex logical expressions over entity field, " + - "attribute, latest time-series value or constant. The filter is defined using 'key', 'valueType', " + + "attribute, latest time series value or constant. The filter is defined using 'key', 'valueType', " + "'value' (refers to the value of the 'CONSTANT' alarm filter key type) and 'predicate' objects. Let's review each object:" + NEW_LINE + ALARM_FILTER_KEY + FILTER_VALUE_TYPE + NEW_LINE + DEVICE_PROFILE_FILTER_PREDICATE + NEW_LINE; @@ -1604,7 +1606,7 @@ public class ControllerConstants { protected static final String ATTRIBUTES_JSON_REQUEST_DESCRIPTION = "A string value representing the json object. For example, '{\"key\":\"value\"}'. See API call description for more details."; protected static final String TELEMETRY_KEYS_BASE_DESCRIPTION = "A string value representing the comma-separated list of telemetry keys."; - protected static final String TELEMETRY_KEYS_DESCRIPTION = TELEMETRY_KEYS_BASE_DESCRIPTION + " If keys are not selected, the result will return all latest timeseries. For example, 'temperature,humidity'."; + protected static final String TELEMETRY_KEYS_DESCRIPTION = TELEMETRY_KEYS_BASE_DESCRIPTION + " If keys are not selected, the result will return all latest time series. For example, 'temperature,humidity'."; protected static final String TELEMETRY_SCOPE_DESCRIPTION = "Value is deprecated, reserved for backward compatibility and not used in the API call implementation. Specify any scope for compatibility"; protected static final String TELEMETRY_JSON_REQUEST_DESCRIPTION = "A JSON with the telemetry values. See API call description for more details."; @@ -1620,11 +1622,11 @@ public class ControllerConstants { protected static final String SAVE_ENTITY_ATTRIBUTES_STATUS_UNAUTHORIZED = "User is not authorized to save entity attributes for selected entity. Most likely, User belongs to different Customer or Tenant."; protected static final String SAVE_ENTITY_ATTRIBUTES_STATUS_INTERNAL_SERVER_ERROR = "The exception was thrown during processing the request. " + "Platform creates an audit log event about entity attributes updates with action type 'ATTRIBUTES_UPDATED' that includes an error stacktrace."; - protected static final String SAVE_ENTITY_TIMESERIES_STATUS_OK = "Timeseries from the request was created or updated. " + - "Platform creates an audit log event about entity timeseries updates with action type 'TIMESERIES_UPDATED'."; - protected static final String SAVE_ENTITY_TIMESERIES_STATUS_UNAUTHORIZED = "User is not authorized to save entity timeseries for selected entity. Most likely, User belongs to different Customer or Tenant."; + protected static final String SAVE_ENTITY_TIMESERIES_STATUS_OK = "Time series from the request was created or updated. " + + "Platform creates an audit log event about entity time series updates with action type 'TIMESERIES_UPDATED'."; + protected static final String SAVE_ENTITY_TIMESERIES_STATUS_UNAUTHORIZED = "User is not authorized to save entity time series for selected entity. Most likely, User belongs to different Customer or Tenant."; protected static final String SAVE_ENTITY_TIMESERIES_STATUS_INTERNAL_SERVER_ERROR = "The exception was thrown during processing the request. " + - "Platform creates an audit log event about entity timeseries updates with action type 'TIMESERIES_UPDATED' that includes an error stacktrace."; + "Platform creates an audit log event about entity time series updates with action type 'TIMESERIES_UPDATED' that includes an error stacktrace."; protected static final String ENTITY_ATTRIBUTE_SCOPES_TEMPLATE = " List of possible attribute scopes depends on the entity type: " + "\n\n * SERVER_SCOPE - supported for all entity types;" + @@ -1712,4 +1714,6 @@ public class ControllerConstants { MARKDOWN_CODE_BLOCK_START + "[{\"ts\":1634712287000,\"values\":{\"temperature\":26, \"humidity\":87}}, {\"ts\":1634712588000,\"values\":{\"temperature\":25, \"humidity\":88}}]" + MARKDOWN_CODE_BLOCK_END ; + + protected static final String SECURITY_WRITE_CHECK = " Security check is performed to verify that the user has 'WRITE' permission for the entity (entities)."; } diff --git a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java index 013575361d..2949df35a7 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java @@ -25,7 +25,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; @@ -51,8 +50,8 @@ import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.dashboard.TbDashboardService; import org.thingsboard.server.service.security.model.SecurityUser; diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java index 416991a996..02baf7f411 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java @@ -21,14 +21,12 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.annotation.Nullable; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; @@ -79,7 +77,6 @@ import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; -import jakarta.validation.Valid; import java.util.ArrayList; import java.util.List; import java.util.UUID; diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java index b84e801d31..e7915fc22c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java @@ -16,14 +16,11 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; @@ -42,8 +39,8 @@ import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.device.profile.TbDeviceProfileService; @@ -128,8 +125,8 @@ public class DeviceProfileController extends BaseController { return checkNotNull(deviceProfileService.findDefaultDeviceProfileInfo(getTenantId())); } - @ApiOperation(value = "Get time-series keys (getTimeseriesKeys)", - notes = "Get a set of unique time-series keys used by devices that belong to specified profile. " + + @ApiOperation(value = "Get time series keys (getTimeseriesKeys)", + notes = "Get a set of unique time series keys used by devices that belong to specified profile. " + "If profile is not set returns a list of unique keys among all profiles. " + "The call is used for auto-complete in the UI forms. " + "The implementation limits the number of devices that participate in search to 100 as a trade of between accurate results and time-consuming queries. " + diff --git a/application/src/main/java/org/thingsboard/server/controller/EdgeController.java b/application/src/main/java/org/thingsboard/server/controller/EdgeController.java index 030cb902a6..26c30f85d1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EdgeController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EdgeController.java @@ -18,24 +18,20 @@ package org.thingsboard.server.controller; import com.google.common.util.concurrent.ListenableFuture; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; -import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.async.DeferredResult; import org.thingsboard.rule.engine.flow.TbRuleChainInputNode; @@ -114,8 +110,7 @@ public class EdgeController extends BaseController { @ApiOperation(value = "Is edges support enabled (isEdgesSupportEnabled)", notes = "Returns 'true' if edges support enabled on server, 'false' - otherwise.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/edges/enabled", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edges/enabled") public boolean isEdgesSupportEnabled() { return edgesEnabled; } @@ -123,8 +118,7 @@ public class EdgeController extends BaseController { @ApiOperation(value = "Get Edge (getEdgeById)", notes = "Get the Edge object based on the provided Edge Id. " + EDGE_SECURITY_CHECK + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/edge/{edgeId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edge/{edgeId}") public Edge getEdgeById(@Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId) throws ThingsboardException { checkParameter(EDGE_ID, strEdgeId); @@ -135,8 +129,7 @@ public class EdgeController extends BaseController { @ApiOperation(value = "Get Edge Info (getEdgeInfoById)", notes = "Get the Edge Info object based on the provided Edge Id. " + EDGE_SECURITY_CHECK + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/edge/info/{edgeId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edge/info/{edgeId}") public EdgeInfo getEdgeInfoById(@Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId) throws ThingsboardException { checkParameter(EDGE_ID, strEdgeId); @@ -153,8 +146,7 @@ public class EdgeController extends BaseController { "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new Edge entity. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/edge") public Edge saveEdge(@Parameter(description = "A JSON value representing the edge.", required = true) @RequestBody Edge edge) throws Exception { TenantId tenantId = getTenantId(); @@ -179,8 +171,7 @@ public class EdgeController extends BaseController { @ApiOperation(value = "Delete edge (deleteEdge)", notes = "Deletes the edge. Referencing non-existing edge Id will cause an error." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/{edgeId}", method = RequestMethod.DELETE) - @ResponseStatus(value = HttpStatus.OK) + @DeleteMapping(value = "/edge/{edgeId}") public void deleteEdge(@Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId) throws ThingsboardException { checkParameter(EDGE_ID, strEdgeId); @@ -193,8 +184,7 @@ public class EdgeController extends BaseController { notes = "Returns a page of edges owned by tenant. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edges", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edges", params = {"pageSize", "page"}) public PageData getEdges(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) @@ -213,8 +203,7 @@ public class EdgeController extends BaseController { @ApiOperation(value = "Assign edge to customer (assignEdgeToCustomer)", notes = "Creates assignment of the edge to customer. Customer will be able to query edge afterwards." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customer/{customerId}/edge/{edgeId}", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/customer/{customerId}/edge/{edgeId}") public Edge assignEdgeToCustomer(@Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable("customerId") String strCustomerId, @Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @@ -231,8 +220,7 @@ public class EdgeController extends BaseController { @ApiOperation(value = "Unassign edge from customer (unassignEdgeFromCustomer)", notes = "Clears assignment of the edge to customer. Customer will not be able to query edge afterwards." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customer/edge/{edgeId}", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping(value = "/customer/edge/{edgeId}") public Edge unassignEdgeFromCustomer(@Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId) throws ThingsboardException { checkParameter(EDGE_ID, strEdgeId); @@ -251,8 +239,7 @@ public class EdgeController extends BaseController { "This is useful to create dashboards that you plan to share/embed on a publicly available website. " + "However, users that are logged-in and belong to different tenant will not be able to access the edge." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customer/public/edge/{edgeId}", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/customer/public/edge/{edgeId}") public Edge assignEdgeToPublicCustomer(@Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId) throws ThingsboardException { checkParameter(EDGE_ID, strEdgeId); @@ -265,8 +252,7 @@ public class EdgeController extends BaseController { notes = "Returns a page of edges owned by tenant. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/edges", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/edges", params = {"pageSize", "page"}) public PageData getTenantEdges( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -293,8 +279,7 @@ public class EdgeController extends BaseController { notes = "Returns a page of edges info objects owned by tenant. " + PAGE_DATA_PARAMETERS + EDGE_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/edgeInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/edgeInfos", params = {"pageSize", "page"}) public PageData getTenantEdgeInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -321,8 +306,7 @@ public class EdgeController extends BaseController { notes = "Requested edge must be owned by tenant or customer that the user belongs to. " + "Edge name is an unique property of edge. So it can be used to identify the edge." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/edges", params = {"edgeName"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/edges", params = {"edgeName"}) public Edge getTenantEdge(@Parameter(description = "Unique name of the edge", required = true) @RequestParam String edgeName) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); @@ -333,8 +317,7 @@ public class EdgeController extends BaseController { notes = "Change root rule chain of the edge to the new provided rule chain. \n" + "This operation will send a notification to update root rule chain on remote edge service." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/{edgeId}/{ruleChainId}/root", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/edge/{edgeId}/{ruleChainId}/root") public Edge setEdgeRootRuleChain(@Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId, @Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION, required = true) @@ -353,8 +336,7 @@ public class EdgeController extends BaseController { notes = "Returns a page of edges objects assigned to customer. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/customer/{customerId}/edges", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customer/{customerId}/edges", params = {"pageSize", "page"}) public PageData getCustomerEdges( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable("customerId") String strCustomerId, @@ -389,8 +371,7 @@ public class EdgeController extends BaseController { notes = "Returns a page of edges info objects assigned to customer. " + PAGE_DATA_PARAMETERS + EDGE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/customer/{customerId}/edgeInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customer/{customerId}/edgeInfos", params = {"pageSize", "page"}) public PageData getCustomerEdgeInfos( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable("customerId") String strCustomerId, @@ -424,8 +405,7 @@ public class EdgeController extends BaseController { @ApiOperation(value = "Get Edges By Ids (getEdgesByIds)", notes = "Requested edges must be owned by tenant or assigned to customer which user is performing the request." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/edges", params = {"edgeIds"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edges", params = {"edgeIds"}) public List getEdgesByIds( @Parameter(description = "A list of edges ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("edgeIds") String[] strEdgeIds) throws ThingsboardException, ExecutionException, InterruptedException { @@ -452,8 +432,7 @@ public class EdgeController extends BaseController { "The entity id, relation type, edge types, depth of the search, and other query parameters defined using complex 'EdgeSearchQuery' object. " + "See 'Model' tab of the Parameters for more info." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/edges", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/edges") public List findByQuery(@RequestBody EdgeSearchQuery query) throws ThingsboardException, ExecutionException, InterruptedException { checkNotNull(query); checkNotNull(query.getParameters()); @@ -477,8 +456,7 @@ public class EdgeController extends BaseController { notes = "Returns a set of unique edge types based on edges that are either owned by the tenant or assigned to the customer which user is performing the request." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/edge/types", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edge/types") public List getEdgeTypes() throws ThingsboardException, ExecutionException, InterruptedException { SecurityUser user = getCurrentUser(); TenantId tenantId = user.getTenantId(); @@ -490,7 +468,7 @@ public class EdgeController extends BaseController { notes = "Starts synchronization process between edge and cloud. \n" + "All entities that are assigned to particular edge are going to be send to remote edge service." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/sync/{edgeId}", method = RequestMethod.POST) + @PostMapping(value = "/edge/sync/{edgeId}") public DeferredResult syncEdge(@Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable("edgeId") String strEdgeId) throws ThingsboardException { checkParameter("edgeId", strEdgeId); @@ -512,15 +490,14 @@ public class EdgeController extends BaseController { if (fromEdgeSyncResponse.isSuccess()) { response.setResult(new ResponseEntity<>(HttpStatus.OK)); } else { - response.setErrorResult(new ThingsboardException("Edge is not connected", ThingsboardErrorCode.GENERAL)); + response.setErrorResult(new ThingsboardException(fromEdgeSyncResponse.getError(), ThingsboardErrorCode.GENERAL)); } } @ApiOperation(value = "Find missing rule chains (findMissingToRelatedRuleChains)", notes = "Returns list of rule chains ids that are not assigned to particular edge, but these rule chains are present in the already assigned rule chains to edge." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/missingToRelatedRuleChains/{edgeId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edge/missingToRelatedRuleChains/{edgeId}") public String findMissingToRelatedRuleChains(@Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable("edgeId") String strEdgeId) throws ThingsboardException { EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); @@ -547,8 +524,7 @@ public class EdgeController extends BaseController { @ApiOperation(value = "Get Edge Install Instructions (getEdgeInstallInstructions)", notes = "Get an install instructions for provided edge id." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/instructions/install/{edgeId}/{method}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edge/instructions/install/{edgeId}/{method}") public EdgeInstructions getEdgeInstallInstructions( @Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable("edgeId") String strEdgeId, @@ -568,8 +544,7 @@ public class EdgeController extends BaseController { @ApiOperation(value = "Get Edge Upgrade Instructions (getEdgeUpgradeInstructions)", notes = "Get an upgrade instructions for provided edge version." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/instructions/upgrade/{edgeVersion}/{method}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edge/instructions/upgrade/{edgeVersion}/{method}") public EdgeInstructions getEdgeUpgradeInstructions( @Parameter(description = "Edge version", required = true) @PathVariable("edgeVersion") String edgeVersion, @@ -585,8 +560,7 @@ public class EdgeController extends BaseController { @ApiOperation(value = "Is edge upgrade enabled (isEdgeUpgradeAvailable)", notes = "Returns 'true' if upgrade available for connected edge, 'false' - otherwise.") @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/{edgeId}/upgrade/available", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edge/{edgeId}/upgrade/available") public boolean isEdgeUpgradeAvailable( @Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable("edgeId") String strEdgeId) throws Exception { @@ -599,4 +573,5 @@ public class EdgeController extends BaseController { throw new ThingsboardException("Edges support disabled", ThingsboardErrorCode.GENERAL); } } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/EdgeEventController.java b/application/src/main/java/org/thingsboard/server/controller/EdgeEventController.java index d1912ba38f..fb846984c6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EdgeEventController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EdgeEventController.java @@ -16,18 +16,14 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -62,8 +58,7 @@ public class EdgeEventController extends BaseController { notes = "Returns a page of edge events for the requested edge. " + PAGE_DATA_PARAMETERS) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/{edgeId}/events", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edge/{edgeId}/events") public PageData getEdgeEvents( @Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId, @@ -88,4 +83,5 @@ public class EdgeEventController extends BaseController { TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); return checkNotNull(edgeEventService.findEdgeEvents(tenantId, edgeId, 0L, null, pageLink)); } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java b/application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java index 0cd31a729b..ccf29301d8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java @@ -16,11 +16,8 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -82,7 +79,23 @@ public class EntityRelationController extends BaseController { @RequestMapping(value = "/relation", method = RequestMethod.POST) @ResponseStatus(value = HttpStatus.OK) public void saveRelation(@Parameter(description = "A JSON value representing the relation.", required = true) - @RequestBody EntityRelation relation) throws ThingsboardException { + @RequestBody EntityRelation relation) throws ThingsboardException { + doSave(relation); + } + + @ApiOperation(value = "Create Relation (saveRelationV2)", + notes = "Creates or updates a relation between two entities in the platform. " + + "Relations unique key is a combination of from/to entity id and relation type group and relation type. " + + SECURITY_CHECKS_ENTITIES_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/v2/relation", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + public EntityRelation saveRelationV2(@Parameter(description = "A JSON value representing the relation.", required = true) + @RequestBody EntityRelation relation) throws ThingsboardException { + return doSave(relation); + } + + private EntityRelation doSave(EntityRelation relation) throws ThingsboardException { checkNotNull(relation); checkCanCreateRelation(relation.getFrom()); checkCanCreateRelation(relation.getTo()); @@ -90,7 +103,7 @@ public class EntityRelationController extends BaseController { relation.setTypeGroup(RelationTypeGroup.COMMON); } - tbEntityRelationService.save(getTenantId(), getCurrentUser().getCustomerId(), relation, getCurrentUser()); + return tbEntityRelationService.save(getTenantId(), getCurrentUser().getCustomerId(), relation, getCurrentUser()); } @ApiOperation(value = "Delete Relation (deleteRelation)", @@ -104,6 +117,24 @@ public class EntityRelationController extends BaseController { @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(TO_TYPE) String strToType) throws ThingsboardException { + doDelete(strFromId, strFromType, strRelationType, strRelationTypeGroup, strToId, strToType); + } + + @ApiOperation(value = "Delete Relation (deleteRelationV2)", + notes = "Deletes a relation between two entities in the platform. " + SECURITY_CHECKS_ENTITIES_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/v2/relation", method = RequestMethod.DELETE, params = {FROM_ID, FROM_TYPE, RELATION_TYPE, TO_ID, TO_TYPE}) + @ResponseStatus(value = HttpStatus.OK) + public EntityRelation deleteRelationV2(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, + @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, + @Parameter(description = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(RELATION_TYPE) String strRelationType, + @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, + @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(TO_TYPE) String strToType) throws ThingsboardException { + return doDelete(strFromId, strFromType, strRelationType, strRelationTypeGroup, strToId, strToType); + } + + private EntityRelation doDelete(String strFromId, String strFromType, String strRelationType, String strRelationTypeGroup, String strToId, String strToType) throws ThingsboardException { checkParameter(FROM_ID, strFromId); checkParameter(FROM_TYPE, strFromType); checkParameter(RELATION_TYPE, strRelationType); @@ -116,7 +147,7 @@ public class EntityRelationController extends BaseController { RelationTypeGroup relationTypeGroup = parseRelationTypeGroup(strRelationTypeGroup, RelationTypeGroup.COMMON); EntityRelation relation = new EntityRelation(fromId, toId, strRelationType, relationTypeGroup); - tbEntityRelationService.delete(getTenantId(), getCurrentUser().getCustomerId(), relation, getCurrentUser()); + return tbEntityRelationService.delete(getTenantId(), getCurrentUser().getCustomerId(), relation, getCurrentUser()); } @ApiOperation(value = "Delete common relations (deleteCommonRelations)", diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index 783b451a6d..73ba0a053d 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -17,13 +17,10 @@ package org.thingsboard.server.controller; import com.google.common.util.concurrent.ListenableFuture; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; diff --git a/application/src/main/java/org/thingsboard/server/controller/EventController.java b/application/src/main/java/org/thingsboard/server/controller/EventController.java index 372d724912..6945278064 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EventController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EventController.java @@ -16,12 +16,9 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; diff --git a/application/src/main/java/org/thingsboard/server/controller/ImageController.java b/application/src/main/java/org/thingsboard/server/controller/ImageController.java index e8e7ca1d8f..6ebb6fffe8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ImageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ImageController.java @@ -41,6 +41,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.thingsboard.server.common.data.ImageDescriptor; import org.thingsboard.server.common.data.ImageExportData; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbImageDeleteResult; import org.thingsboard.server.common.data.TbResource; @@ -65,6 +66,7 @@ import java.util.concurrent.TimeUnit; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_IMAGE_SUB_TYPE_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_INCLUDE_SYSTEM_IMAGES_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; @@ -96,7 +98,8 @@ public class ImageController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @PostMapping(value = "/api/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public TbResourceInfo uploadImage(@RequestPart MultipartFile file, - @RequestPart(required = false) String title) throws Exception { + @RequestPart(required = false) String title, + @RequestPart(required = false) String imageSubType) throws Exception { SecurityUser user = getCurrentUser(); TbResource image = new TbResource(); image.setTenantId(user.getTenantId()); @@ -109,7 +112,14 @@ public class ImageController extends BaseController { } else { image.setTitle(file.getOriginalFilename()); } + + ResourceSubType subType = ResourceSubType.IMAGE; + if (StringUtils.isNotEmpty(imageSubType)) { + subType = ResourceSubType.valueOf(imageSubType); + } + image.setResourceType(ResourceType.IMAGE); + image.setResourceSubType(subType); ImageDescriptor descriptor = new ImageDescriptor(); descriptor.setMediaType(file.getContentType()); image.setDescriptorValue(descriptor); @@ -194,6 +204,7 @@ public class ImageController extends BaseController { .mediaType(descriptor.getMediaType()) .fileName(imageInfo.getFileName()) .title(imageInfo.getTitle()) + .subType(imageInfo.getResourceSubType().name()) .resourceKey(imageInfo.getResourceKey()) .isPublic(imageInfo.isPublic()) .publicResourceKey(imageInfo.getPublicResourceKey()) @@ -215,6 +226,11 @@ public class ImageController extends BaseController { } else { image.setTitle(imageData.getFileName()); } + if (StringUtils.isNotEmpty(imageData.getSubType())) { + image.setResourceSubType(ResourceSubType.valueOf(imageData.getSubType())); + } else { + image.setResourceSubType(ResourceSubType.IMAGE); + } image.setResourceType(ResourceType.IMAGE); image.setResourceKey(imageData.getResourceKey()); image.setPublic(imageData.isPublic()); @@ -251,6 +267,8 @@ public class ImageController extends BaseController { @RequestParam int pageSize, @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) @RequestParam int page, + @Parameter(description = RESOURCE_IMAGE_SUB_TYPE_DESCRIPTION, schema = @Schema(allowableValues = {"IMAGE", "SCADA_SYMBOL"})) + @RequestParam(required = false) String imageSubType, @Parameter(description = RESOURCE_INCLUDE_SYSTEM_IMAGES_DESCRIPTION) @RequestParam(required = false) boolean includeSystemImages, @Parameter(description = RESOURCE_TEXT_SEARCH_DESCRIPTION) @@ -262,10 +280,14 @@ public class ImageController extends BaseController { // PE: generic permission PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); TenantId tenantId = getTenantId(); + ResourceSubType subType = ResourceSubType.IMAGE; + if (StringUtils.isNotEmpty(imageSubType)) { + subType = ResourceSubType.valueOf(imageSubType); + } if (getCurrentUser().getAuthority() == Authority.SYS_ADMIN || !includeSystemImages) { - return checkNotNull(imageService.getImagesByTenantId(tenantId, pageLink)); + return checkNotNull(imageService.getImagesByTenantId(tenantId, subType, pageLink)); } else { - return checkNotNull(imageService.getAllImagesByTenantId(tenantId, pageLink)); + return checkNotNull(imageService.getAllImagesByTenantId(tenantId, subType, pageLink)); } } diff --git a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java index 25e49eb198..719767ab43 100644 --- a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java +++ b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java @@ -16,12 +16,9 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; -import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; diff --git a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java index fb32ebd30b..c1248a1fbf 100644 --- a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java @@ -300,9 +300,15 @@ public class NotificationController extends BaseController { request.setOriginatorEntityId(user.getId()); List targets = request.getTargets().stream() .map(NotificationTargetId::new) - .map(targetId -> notificationTargetService.findNotificationTargetById(user.getTenantId(), targetId)) + .map(targetId -> { + NotificationTarget target = notificationTargetService.findNotificationTargetById(user.getTenantId(), targetId); + if (target == null) { + throw new IllegalArgumentException("Notification target for id " + targetId + " not found"); + } + return target; + }) .sorted(Comparator.comparing(target -> target.getConfiguration().getType())) - .collect(Collectors.toList()); + .toList(); NotificationRequestPreview preview = new NotificationRequestPreview(); diff --git a/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java b/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java index ee7bfcc0dc..58e8b3138d 100644 --- a/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java @@ -18,7 +18,6 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.ByteArrayResource; @@ -52,7 +51,6 @@ import org.thingsboard.server.service.security.permission.Resource; import java.io.IOException; -import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; import static org.thingsboard.server.controller.ControllerConstants.DEVICE_PROFILE_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.OTA_PACKAGE_DESCRIPTION; diff --git a/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java b/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java index 6c08f4a2e2..67ff1b3fc5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java +++ b/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java @@ -46,10 +46,10 @@ import org.thingsboard.server.common.data.rpc.Rpc; import org.thingsboard.server.common.data.rpc.RpcStatus; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.rpc.RemoveRpcActorMsg; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.exception.ToErrorResponseEntity; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.common.msg.rpc.RemoveRpcActorMsg; import org.thingsboard.server.service.security.permission.Operation; import java.util.UUID; @@ -118,6 +118,7 @@ public class RpcV2Controller extends AbstractRpcController { @ApiResponse(responseCode = "200", description = "Persistent RPC request was saved to the database or lightweight RPC request was sent to the device."), @ApiResponse(responseCode = "400", description = "Invalid structure of the request."), @ApiResponse(responseCode = "401", description = "User is not authorized to send the RPC request. Most likely, User belongs to different Customer or Tenant."), + @ApiResponse(responseCode = "413", description = "Request payload is too large"), @ApiResponse(responseCode = "504", description = "Timeout to process the RPC call. Most likely, device is offline."), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @@ -136,6 +137,7 @@ public class RpcV2Controller extends AbstractRpcController { @ApiResponse(responseCode = "200", description = "Persistent RPC request was saved to the database or lightweight RPC response received."), @ApiResponse(responseCode = "400", description = "Invalid structure of the request."), @ApiResponse(responseCode = "401", description = "User is not authorized to send the RPC request. Most likely, User belongs to different Customer or Tenant."), + @ApiResponse(responseCode = "413", description = "Request payload is too large"), @ApiResponse(responseCode = "504", description = "Timeout to process the RPC call. Most likely, device is offline."), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") diff --git a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java new file mode 100644 index 0000000000..8cda3d2212 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java @@ -0,0 +1,237 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.google.common.util.concurrent.FutureCallback; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.exception.ToErrorResponseEntity; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.ruleengine.RuleEngineCallService; +import org.thingsboard.server.service.security.AccessValidator; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.Operation; + +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.TimeoutException; + +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_PARAM_DESCRIPTION; + +@RestController +@TbCoreComponent +@RequestMapping(TbUrlConstants.RULE_ENGINE_URL_PREFIX) +@Slf4j +public class RuleEngineController extends BaseController { + public static final int DEFAULT_TIMEOUT = 10000; + private static final String MSG_DESCRIPTION_PREFIX = "Creates the Message with type 'REST_API_REQUEST' and payload taken from the request body. "; + private static final String MSG_DESCRIPTION = "This method allows you to extend the regular platform API with the power of Rule Engine. You may use default and custom rule nodes to handle the message. " + + "The generated message contains two important metadata fields:\n\n" + + " * **'serviceId'** to identify the platform server that received the request;\n" + + " * **'requestUUID'** to identify the request and route possible response from the Rule Engine;\n\n" + + "Use **'rest call reply'** rule node to push the reply from rule engine back as a REST API call response. "; + + @Autowired + private RuleEngineCallService ruleEngineCallService; + @Autowired + private AccessValidator accessValidator; + + @ApiOperation(value = "Push user message to the rule engine (handleRuleEngineRequest)", + notes = MSG_DESCRIPTION_PREFIX + + "Uses current User Id ( the one which credentials is used to perform the request) as the Rule Engine message originator. " + + MSG_DESCRIPTION + + "The default timeout of the request processing is 10 seconds." + + "\n\n" + ControllerConstants.SECURITY_WRITE_CHECK) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/", method = RequestMethod.POST) + @ResponseBody + public DeferredResult handleRuleEngineRequest( + @Parameter(description = "A JSON value representing the message.", required = true) + @RequestBody String requestBody) throws ThingsboardException { + return handleRuleEngineRequest(null, null, null, DEFAULT_TIMEOUT, requestBody); + } + + @ApiOperation(value = "Push entity message to the rule engine (handleRuleEngineRequest)", + notes = MSG_DESCRIPTION_PREFIX + + "Uses specified Entity Id as the Rule Engine message originator. " + + MSG_DESCRIPTION + + "The default timeout of the request processing is 10 seconds." + + "\n\n" + ControllerConstants.SECURITY_WRITE_CHECK) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}", method = RequestMethod.POST) + @ResponseBody + public DeferredResult handleRuleEngineRequest( + @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) + @PathVariable("entityType") String entityType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("entityId") String entityIdStr, + @Parameter(description = "A JSON value representing the message.", required = true) + @RequestBody String requestBody) throws ThingsboardException { + return handleRuleEngineRequest(entityType, entityIdStr, null, DEFAULT_TIMEOUT, requestBody); + } + + @ApiOperation(value = "Push entity message with timeout to the rule engine (handleRuleEngineRequest)", + notes = MSG_DESCRIPTION_PREFIX + + "Uses specified Entity Id as the Rule Engine message originator. " + + MSG_DESCRIPTION + + "The platform expects the timeout value in milliseconds." + + "\n\n" + ControllerConstants.SECURITY_WRITE_CHECK) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/{timeout}", method = RequestMethod.POST) + @ResponseBody + public DeferredResult handleRuleEngineRequest( + @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) + @PathVariable("entityType") String entityType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("entityId") String entityIdStr, + @Parameter(description = "Timeout to process the request in milliseconds", required = true) + @PathVariable("timeout") int timeout, + @Parameter(description = "A JSON value representing the message.", required = true) + @RequestBody String requestBody) throws ThingsboardException { + return handleRuleEngineRequest(entityType, entityIdStr, null, timeout, requestBody); + } + + @ApiOperation(value = "Push entity message with timeout and specified queue to the rule engine (handleRuleEngineRequest)", + notes = MSG_DESCRIPTION_PREFIX + + "Uses specified Entity Id as the Rule Engine message originator. " + + MSG_DESCRIPTION + + "If request sent for Device/Device Profile or Asset/Asset Profile entity, specified queue will be used instead of the queue selected in the device or asset profile. " + + "The platform expects the timeout value in milliseconds." + + "\n\n" + ControllerConstants.SECURITY_WRITE_CHECK) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/{queueName}/{timeout}", method = RequestMethod.POST) + @ResponseBody + public DeferredResult handleRuleEngineRequest( + @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) + @PathVariable("entityType") String entityType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("entityId") String entityIdStr, + @Parameter(description = "Queue name to process the request in the rule engine", required = true) + @PathVariable("queueName") String queueName, + @Parameter(description = "Timeout to process the request in milliseconds", required = true) + @PathVariable("timeout") int timeout, + @Parameter(description = "A JSON value representing the message.", required = true) + @RequestBody String requestBody) throws ThingsboardException { + try { + SecurityUser currentUser = getCurrentUser(); + EntityId entityId; + if (StringUtils.isEmpty(entityType) || StringUtils.isEmpty(entityIdStr)) { + entityId = currentUser.getId(); + } else { + entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); + } + //Check that this is a valid JSON + JacksonUtil.toJsonNode(requestBody); + final DeferredResult response = new DeferredResult<>(); + accessValidator.validate(currentUser, Operation.WRITE, entityId, new HttpValidationCallback(response, new FutureCallback>() { + @Override + public void onSuccess(@Nullable DeferredResult result) { + long expTime = System.currentTimeMillis() + timeout; + HashMap metaData = new HashMap<>(); + UUID requestId = UUID.randomUUID(); + metaData.put("serviceId", serviceInfoProvider.getServiceId()); + metaData.put("requestUUID", requestId.toString()); + metaData.put("expirationTime", Long.toString(expTime)); + TbMsg msg = TbMsg.newMsg(queueName, TbMsgType.REST_API_REQUEST, entityId, currentUser.getCustomerId(), new TbMsgMetaData(metaData), requestBody); + ruleEngineCallService.processRestApiCallToRuleEngine(currentUser.getTenantId(), requestId, msg, queueName != null, + reply -> reply(new LocalRequestMetaData(msg, currentUser, result), reply)); + } + + @Override + public void onFailure(Throwable e) { + ResponseEntity entity; + if (e instanceof ToErrorResponseEntity) { + entity = ((ToErrorResponseEntity) e).toErrorResponseEntity(); + } else { + entity = new ResponseEntity(HttpStatus.UNAUTHORIZED); + } + logRuleEngineCall(currentUser, entityId, requestBody, null, e); + response.setResult(entity); + } + })); + return response; + } catch (IllegalArgumentException iae) { + throw new ThingsboardException("Invalid request body", iae, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } + + private void reply(LocalRequestMetaData rpcRequest, TbMsg response) { + DeferredResult responseWriter = rpcRequest.responseWriter(); + if (response == null) { + logRuleEngineCall(rpcRequest, null, new TimeoutException("Processing timeout detected!")); + responseWriter.setResult(new ResponseEntity<>(HttpStatus.REQUEST_TIMEOUT)); + } else { + String responseData = response.getData(); + if (!StringUtils.isEmpty(responseData)) { + try { + logRuleEngineCall(rpcRequest, response, null); + responseWriter.setResult(new ResponseEntity<>(JacksonUtil.toJsonNode(responseData), HttpStatus.OK)); + } catch (IllegalArgumentException e) { + log.debug("Failed to decode device response: {}", responseData, e); + logRuleEngineCall(rpcRequest, response, e); + responseWriter.setResult(new ResponseEntity<>(HttpStatus.NOT_ACCEPTABLE)); + } + } else { + logRuleEngineCall(rpcRequest, response, null); + responseWriter.setResult(new ResponseEntity<>(HttpStatus.OK)); + } + } + } + + private void logRuleEngineCall(LocalRequestMetaData rpcRequest, TbMsg response, Throwable e) { + logRuleEngineCall(rpcRequest.user(), rpcRequest.request().getOriginator(), rpcRequest.request().getData(), response, e); + } + + private void logRuleEngineCall(SecurityUser user, EntityId entityId, String request, TbMsg response, Throwable e) { + auditLogService.logEntityAction( + user.getTenantId(), + user.getCustomerId(), + user.getId(), + user.getName(), + entityId, + null, + ActionType.REST_API_RULE_ENGINE_CALL, + BaseController.toException(e), + request, + response != null ? response.getData() : ""); + } + + private record LocalRequestMetaData(TbMsg request, SecurityUser user, DeferredResult responseWriter) {} +} diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java index 74bc087abf..8feb898a65 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java @@ -17,9 +17,7 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; diff --git a/application/src/main/java/org/thingsboard/server/controller/TbUrlConstants.java b/application/src/main/java/org/thingsboard/server/controller/TbUrlConstants.java index 99d94522ac..74575fd9d5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbUrlConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/TbUrlConstants.java @@ -22,4 +22,5 @@ public class TbUrlConstants { public static final String TELEMETRY_URL_PREFIX = "/api/plugins/telemetry"; public static final String RPC_V1_URL_PREFIX = "/api/plugins/rpc"; public static final String RPC_V2_URL_PREFIX = "/api/rpc"; + public static final String RULE_ENGINE_URL_PREFIX = "/api/rule-engine/"; } diff --git a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java index 6833a7ddfb..b1733a9af0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -243,8 +243,8 @@ public class TelemetryController extends BaseController { (result, tenantId, entityId) -> getAttributeValuesCallback(result, user, entityId, scope, keysStr)); } - @ApiOperation(value = "Get time-series keys (getTimeseriesKeys)", - notes = "Returns a set of unique time-series key names for the selected entity. " + + @ApiOperation(value = "Get time series keys (getTimeseriesKeys)", + notes = "Returns a set of unique time series key names for the selected entity. " + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/{entityType}/{entityId}/keys/timeseries", method = RequestMethod.GET) @@ -256,10 +256,10 @@ public class TelemetryController extends BaseController { (result, tenantId, entityId) -> Futures.addCallback(tsService.findAllLatest(tenantId, entityId), getTsKeysToResponseCallback(result), MoreExecutors.directExecutor())); } - @ApiOperation(value = "Get latest time-series value (getLatestTimeseries)", - notes = "Returns all time-series that belong to specified entity. Use optional 'keys' parameter to return specific time-series." + + @ApiOperation(value = "Get latest time series value (getLatestTimeseries)", + notes = "Returns all time series that belong to specified entity. Use optional 'keys' parameter to return specific time series." + " The result is a JSON object. The format of the values depends on the 'useStrictDataTypes' parameter." + - " By default, all time-series values are converted to strings: \n\n" + " By default, all time series values are converted to strings: \n\n" + MARKDOWN_CODE_BLOCK_START + LATEST_TS_NON_STRICT_DATA_EXAMPLE + MARKDOWN_CODE_BLOCK_END @@ -282,8 +282,8 @@ public class TelemetryController extends BaseController { (result, tenantId, entityId) -> getLatestTimeseriesValuesCallback(result, user, entityId, keysStr, useStrictDataTypes)); } - @ApiOperation(value = "Get time-series data (getTimeseries)", - notes = "Returns a range of time-series values for specified entity. " + + @ApiOperation(value = "Get time series data (getTimeseries)", + notes = "Returns a range of time series values for specified entity. " + "Returns not aggregated data by default. " + "Use aggregation function ('agg') and aggregation interval ('interval') to enable aggregation of the results on the database / server side. " + "The aggregation is generally more efficient then fetching all records. \n\n" @@ -308,7 +308,7 @@ public class TelemetryController extends BaseController { @RequestParam(name = "interval", defaultValue = "0") Long interval, @Parameter(description = "A string value representing the timezone that will be used to calculate exact timestamps for 'WEEK', 'WEEK_ISO', 'MONTH' and 'QUARTER' interval types.") @RequestParam(name = "timeZone", required = false) String timeZone, - @Parameter(description = "An integer value that represents a max number of timeseries data points to fetch." + + @Parameter(description = "An integer value that represents a max number of time series data points to fetch." + " This parameter is used only in the case if 'agg' parameter is set to 'NONE'.", schema = @Schema(defaultValue = "100")) @RequestParam(name = "limit", defaultValue = "100") Integer limit, @Parameter(description = "A string value representing the aggregation function. " + @@ -406,8 +406,8 @@ public class TelemetryController extends BaseController { } - @ApiOperation(value = "Save or update time-series data (saveEntityTelemetry)", - notes = "Creates or updates the entity time-series data based on the Entity Id and request payload." + + @ApiOperation(value = "Save or update time series data (saveEntityTelemetry)", + notes = "Creates or updates the entity time series data based on the Entity Id and request payload." + SAVE_TIMESERIES_REQUEST_PAYLOAD + "\n\n The scope parameter is not used in the API call implementation but should be specified whatever value because it is used as a path variable. " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @@ -429,8 +429,8 @@ public class TelemetryController extends BaseController { return saveTelemetry(getTenantId(), entityId, requestBody, 0L); } - @ApiOperation(value = "Save or update time-series data with TTL (saveEntityTelemetryWithTTL)", - notes = "Creates or updates the entity time-series data based on the Entity Id and request payload." + + @ApiOperation(value = "Save or update time series data with TTL (saveEntityTelemetryWithTTL)", + notes = "Creates or updates the entity time series data based on the Entity Id and request payload." + SAVE_TIMESERIES_REQUEST_PAYLOAD + "\n\n The scope parameter is not used in the API call implementation but should be specified whatever value because it is used as a path variable. " + "\n\nThe ttl parameter takes affect only in case of Cassandra DB." @@ -454,21 +454,21 @@ public class TelemetryController extends BaseController { return saveTelemetry(getTenantId(), entityId, requestBody, ttl); } - @ApiOperation(value = "Delete entity time-series data (deleteEntityTimeseries)", - notes = "Delete time-series for selected entity based on entity id, entity type and keys." + - " Use 'deleteAllDataForKeys' to delete all time-series data." + + @ApiOperation(value = "Delete entity time series data (deleteEntityTimeseries)", + notes = "Delete time series for selected entity based on entity id, entity type and keys." + + " Use 'deleteAllDataForKeys' to delete all time series data." + " Use 'startTs' and 'endTs' to specify time-range instead. " + " Use 'deleteLatest' to delete latest value (stored in separate table for performance) if the value's timestamp matches the time-range. " + " Use 'rewriteLatestIfDeleted' to rewrite latest value (stored in separate table for performance) if the value's timestamp matches the time-range and 'deleteLatest' param is true." + - " The replacement value will be fetched from the 'time-series' table, and its timestamp will be the most recent one before the defined time-range. " + + " The replacement value will be fetched from the 'time series' table, and its timestamp will be the most recent one before the defined time-range. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Timeseries for the selected keys in the request was removed. " + - "Platform creates an audit log event about entity timeseries removal with action type 'TIMESERIES_DELETED'."), + @ApiResponse(responseCode = "200", description = "Time series for the selected keys in the request was removed. " + + "Platform creates an audit log event about entity time series removal with action type 'TIMESERIES_DELETED'."), @ApiResponse(responseCode = "400", description = "Platform returns a bad request in case if keys list is empty or start and end timestamp values is empty when deleteAllDataForKeys is set to false."), - @ApiResponse(responseCode = "401", description = "User is not authorized to delete entity timeseries for selected entity. Most likely, User belongs to different Customer or Tenant."), + @ApiResponse(responseCode = "401", description = "User is not authorized to delete entity time series for selected entity. Most likely, User belongs to different Customer or Tenant."), @ApiResponse(responseCode = "500", description = "The exception was thrown during processing the request. " + - "Platform creates an audit log event about entity timeseries removal with action type 'TIMESERIES_DELETED' that includes an error stacktrace."), + "Platform creates an audit log event about entity time series removal with action type 'TIMESERIES_DELETED' that includes an error stacktrace."), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/{entityType}/{entityId}/timeseries/delete", method = RequestMethod.DELETE) @@ -649,12 +649,12 @@ public class TelemetryController extends BaseController { try { telemetryJson = JsonParser.parseString(requestBody); } catch (Exception e) { - return getImmediateDeferredResult("Unable to parse timeseries payload: Invalid JSON body!", HttpStatus.BAD_REQUEST); + return getImmediateDeferredResult("Unable to parse time series payload: Invalid JSON body!", HttpStatus.BAD_REQUEST); } try { telemetryRequest = JsonConverter.convertToTelemetry(telemetryJson, System.currentTimeMillis()); } catch (Exception e) { - return getImmediateDeferredResult("Unable to parse timeseries payload. Invalid JSON body: " + e.getMessage(), HttpStatus.BAD_REQUEST); + return getImmediateDeferredResult("Unable to parse time series payload. Invalid JSON body: " + e.getMessage(), HttpStatus.BAD_REQUEST); } List entries = new ArrayList<>(); for (Map.Entry> entry : telemetryRequest.entrySet()) { @@ -663,7 +663,7 @@ public class TelemetryController extends BaseController { } } if (entries.isEmpty()) { - return getImmediateDeferredResult("No timeseries data found in request body!", HttpStatus.BAD_REQUEST); + return getImmediateDeferredResult("No time series data found in request body!", HttpStatus.BAD_REQUEST); } SecurityUser user = getCurrentUser(); return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.WRITE_TELEMETRY, entityIdSrc, (result, tenantId, entityId) -> { diff --git a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java index 4c48dbd434..03f1c52faf 100644 --- a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java +++ b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java @@ -19,7 +19,6 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; @@ -42,8 +41,10 @@ import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.widget.DeprecatedFilter; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetTypeFilter; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.common.data.widget.WidgetsBundleFilter; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.resource.ImageService; @@ -84,6 +85,7 @@ public class WidgetTypeController extends AutoCommitController { private static final String WIDGET_TYPE_INFO_DESCRIPTION = "Widget Type Info is a lightweight object that represents Widget Type but does not contain the heavyweight widget descriptor JSON"; private static final String TENANT_ONLY_PARAM_DESCRIPTION = "Optional boolean parameter indicating whether only tenant widget types should be returned"; private static final String FULL_SEARCH_PARAM_DESCRIPTION = "Optional boolean parameter indicating whether search widgets by description not only by name"; + private static final String SCADA_FIRST_PARAM_DESCRIPTION = "Optional boolean parameter indicating whether to fetch SCADA symbol widgets first"; private static final String DEPRECATED_FILTER_PARAM_DESCRIPTION = "Optional string parameter indicating whether to include deprecated widgets"; private static final String UPDATE_EXISTING_BY_FQN_PARAM_DESCRIPTION = "Optional boolean parameter indicating whether to update existing widget type by FQN if present instead of creating new one"; private static final String WIDGET_TYPE_ARRAY_DESCRIPTION = "A list of string values separated by comma ',' representing one of the widget type value"; @@ -187,18 +189,26 @@ public class WidgetTypeController extends AutoCommitController { @Parameter(description = DEPRECATED_FILTER_PARAM_DESCRIPTION, schema = @Schema(allowableValues = {"ALL", "ACTUAL", "DEPRECATED"})) @RequestParam(required = false) String deprecatedFilter, @Parameter(description = WIDGET_TYPE_ARRAY_DESCRIPTION, array = @ArraySchema(schema = @Schema(type = "string", allowableValues = {"timeseries", "latest", "control", "alarm", "static"}))) - @RequestParam(required = false) String[] widgetTypeList) throws ThingsboardException { + @RequestParam(required = false) String[] widgetTypeList, + @Parameter(description = SCADA_FIRST_PARAM_DESCRIPTION) + @RequestParam(required = false) Boolean scadaFirst) throws ThingsboardException { PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); List widgetTypes = widgetTypeList != null ? Arrays.asList(widgetTypeList) : Collections.emptyList(); - boolean fullSearchBool = fullSearch != null && fullSearch; DeprecatedFilter widgetTypeDeprecatedFilter = StringUtils.isNotEmpty(deprecatedFilter) ? DeprecatedFilter.valueOf(deprecatedFilter) : DeprecatedFilter.ALL; + WidgetTypeFilter widgetTypeFilter = WidgetTypeFilter.builder() + .tenantId(getTenantId()) + .widgetTypes(widgetTypes) + .deprecatedFilter(widgetTypeDeprecatedFilter) + .fullSearch(fullSearch != null && fullSearch) + .scadaFirst(scadaFirst != null && scadaFirst) + .build(); if (Authority.SYS_ADMIN.equals(getCurrentUser().getAuthority())) { - return checkNotNull(widgetTypeService.findSystemWidgetTypesByPageLink(getTenantId(), fullSearchBool, widgetTypeDeprecatedFilter, widgetTypes, pageLink)); + return checkNotNull(widgetTypeService.findSystemWidgetTypesByPageLink(widgetTypeFilter, pageLink)); } else { if (tenantOnly != null && tenantOnly) { - return checkNotNull(widgetTypeService.findTenantWidgetTypesByTenantIdAndPageLink(getTenantId(), fullSearchBool, widgetTypeDeprecatedFilter, widgetTypes, pageLink)); + return checkNotNull(widgetTypeService.findTenantWidgetTypesByTenantIdAndPageLink(widgetTypeFilter, pageLink)); } else { - return checkNotNull(widgetTypeService.findAllTenantWidgetTypesByTenantIdAndPageLink(getTenantId(), fullSearchBool, widgetTypeDeprecatedFilter, widgetTypes, pageLink)); + return checkNotNull(widgetTypeService.findAllTenantWidgetTypesByTenantIdAndPageLink(widgetTypeFilter, pageLink)); } } } diff --git a/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java b/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java index f652e78b85..8d39e6c83d 100644 --- a/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java +++ b/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java @@ -36,8 +36,9 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.widget.WidgetsBundle; -import org.thingsboard.server.dao.resource.ImageService; +import org.thingsboard.server.common.data.widget.WidgetsBundleFilter; import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.widgets.bundle.TbWidgetsBundleService; import org.thingsboard.server.service.security.permission.Operation; @@ -72,6 +73,7 @@ public class WidgetsBundleController extends BaseController { private static final String WIDGET_BUNDLE_DESCRIPTION = "Widget Bundle represents a group(bundle) of widgets. Widgets are grouped into bundle by type or use case. "; private static final String FULL_SEARCH_PARAM_DESCRIPTION = "Optional boolean parameter indicating extended search of widget bundles by description and by name / description of related widget types"; + private static final String SCADA_FIRST_PARAM_DESCRIPTION = "Optional boolean parameter indicating whether to fetch widgets bundles with SCADA symbols first. Works only when fullSearch parameter is enabled"; private static final String TENANT_BUNDLES_ONLY_DESCRIPTION = "Optional boolean parameter to include only tenant-level bundles without system"; @ApiOperation(value = "Get Widget Bundle (getWidgetsBundleById)", @@ -198,16 +200,22 @@ public class WidgetsBundleController extends BaseController { @Parameter(description = TENANT_BUNDLES_ONLY_DESCRIPTION) @RequestParam(required = false) Boolean tenantOnly, @Parameter(description = FULL_SEARCH_PARAM_DESCRIPTION) - @RequestParam(required = false) Boolean fullSearch) throws ThingsboardException { + @RequestParam(required = false) Boolean fullSearch, + @Parameter(description = SCADA_FIRST_PARAM_DESCRIPTION) + @RequestParam(required = false) Boolean scadaFirst) throws ThingsboardException { PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + WidgetsBundleFilter widgetsBundleFilter = WidgetsBundleFilter.builder() + .tenantId(getTenantId()) + .fullSearch(fullSearch != null && fullSearch) + .scadaFirst(scadaFirst != null && scadaFirst) + .build(); if (Authority.SYS_ADMIN.equals(getCurrentUser().getAuthority())) { - return checkNotNull(widgetsBundleService.findSystemWidgetsBundlesByPageLink(getTenantId(), fullSearch != null && fullSearch, pageLink)); + return checkNotNull(widgetsBundleService.findSystemWidgetsBundlesByPageLink(widgetsBundleFilter, pageLink)); } else { - TenantId tenantId = getCurrentUser().getTenantId(); if (tenantOnly != null && tenantOnly) { - return checkNotNull(widgetsBundleService.findTenantWidgetsBundlesByTenantIdAndPageLink(tenantId, fullSearch != null && fullSearch, pageLink)); + return checkNotNull(widgetsBundleService.findTenantWidgetsBundlesByTenantIdAndPageLink(widgetsBundleFilter, pageLink)); } else { - return checkNotNull(widgetsBundleService.findAllTenantWidgetsBundlesByTenantIdAndPageLink(tenantId, fullSearch != null && fullSearch, pageLink)); + return checkNotNull(widgetsBundleService.findAllTenantWidgetsBundlesByTenantIdAndPageLink(widgetsBundleFilter, pageLink)); } } } diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java index f386cc8e42..c37381c585 100644 --- a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java +++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java @@ -39,6 +39,7 @@ import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.adapter.NativeWebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.id.CustomerId; @@ -48,7 +49,6 @@ import org.thingsboard.server.common.data.limit.LimitedApi; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.config.WebSocketConfiguration; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; -import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider; import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java index d097beb4c2..256302f30e 100644 --- a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java @@ -46,6 +46,7 @@ import org.springframework.web.util.WebUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.msg.tools.MaxPayloadSizeExceededException; import org.thingsboard.server.common.msg.tools.TbRateLimitsException; import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException; import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; @@ -91,6 +92,7 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand errorCodeToStatusMap.put(ThingsboardErrorCode.TOO_MANY_REQUESTS, HttpStatus.TOO_MANY_REQUESTS); errorCodeToStatusMap.put(ThingsboardErrorCode.TOO_MANY_UPDATES, HttpStatus.TOO_MANY_REQUESTS); errorCodeToStatusMap.put(ThingsboardErrorCode.SUBSCRIPTION_VIOLATION, HttpStatus.FORBIDDEN); + errorCodeToStatusMap.put(ThingsboardErrorCode.VERSION_CONFLICT, HttpStatus.CONFLICT); } private static ThingsboardErrorCode statusToErrorCode(HttpStatus status) { @@ -146,6 +148,8 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand handleAccessDeniedException(response); } else if (exception instanceof AuthenticationException) { handleAuthenticationException((AuthenticationException) exception, response); + } else if (exception instanceof MaxPayloadSizeExceededException) { + handleMaxPayloadSizeExceededException(response, (MaxPayloadSizeExceededException) exception); } else { response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of(exception.getMessage(), @@ -184,6 +188,13 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand ThingsboardErrorCode.TOO_MANY_REQUESTS, HttpStatus.TOO_MANY_REQUESTS)); } + private void handleMaxPayloadSizeExceededException(HttpServletResponse response, MaxPayloadSizeExceededException exception) throws IOException { + response.setStatus(HttpStatus.PAYLOAD_TOO_LARGE.value()); + JacksonUtil.writeValue(response.getWriter(), + ThingsboardErrorResponse.of(exception.getMessage(), + ThingsboardErrorCode.BAD_REQUEST_PARAMS, HttpStatus.PAYLOAD_TOO_LARGE)); + } + private void handleSubscriptionException(ThingsboardException subscriptionException, HttpServletResponse response) throws IOException { response.setStatus(HttpStatus.FORBIDDEN.value()); JacksonUtil.writeValue(response.getWriter(), diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index 4bef0d420a..75ac97881c 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -137,6 +137,9 @@ public class ThingsboardInstallService { entityDatabaseSchemaService.createCustomerTitleUniqueConstraintIfNotExists(); systemDataLoaderService.updateDefaultNotificationConfigs(false); systemDataLoaderService.updateSecuritySettings(); + case "3.7.0": + log.info("Upgrading ThingsBoard from version 3.7.0 to 3.7.1 ..."); + databaseEntitiesUpgradeService.upgradeDatabase("3.7.0"); //TODO DON'T FORGET to update switch statement in the CacheCleanupService if you need to clear the cache break; default: diff --git a/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java index 63ee2cb9eb..67d1e40ffa 100644 --- a/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.component; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -41,7 +42,6 @@ import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.dao.component.ComponentDescriptorService; -import jakarta.annotation.PostConstruct; import java.lang.annotation.Annotation; import java.util.ArrayList; import java.util.Arrays; diff --git a/application/src/main/java/org/thingsboard/server/service/component/ComponentDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/component/ComponentDiscoveryService.java index 973ce17574..b3f9faa711 100644 --- a/application/src/main/java/org/thingsboard/server/service/component/ComponentDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/component/ComponentDiscoveryService.java @@ -19,9 +19,7 @@ import org.thingsboard.server.common.data.plugin.ComponentDescriptor; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.rule.RuleChainType; -import java.util.Collection; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.Set; diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java index bedf9c7537..f5b6411bf7 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java @@ -240,7 +240,7 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { return deviceCredentialsService.updateDeviceCredentials(tenantId, deviceCredentials); } - private ListenableFuture> saveProvisionStateAttribute(Device device) { + private ListenableFuture> saveProvisionStateAttribute(Device device) { return attributesService.save(device.getTenantId(), device.getId(), AttributeScope.SERVER_SCOPE, Collections.singletonList(new BaseAttributeKvEntry(new StringDataEntry(DEVICE_PROVISION_STATE, PROVISIONED_STATE), System.currentTimeMillis()))); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java index 651339e3a4..0eb128d0b7 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java @@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmApiCallResult; import org.thingsboard.server.common.data.alarm.AlarmComment; +import org.thingsboard.server.common.data.alarm.EntityAlarm; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.edge.EdgeEventType; @@ -181,7 +182,8 @@ public class EdgeEventSourcingListener { return false; } if (oldEntity != null) { - User oldUser = (User) oldEntity; + user = JacksonUtil.clone(user); + User oldUser = JacksonUtil.clone((User) oldEntity); cleanUpUserAdditionalInfo(oldUser); cleanUpUserAdditionalInfo(user); return !user.equals(oldUser); @@ -194,7 +196,7 @@ public class EdgeEventSourcingListener { } break; case ALARM: - if (entity instanceof AlarmApiCallResult || entity instanceof Alarm) { + if (entity instanceof AlarmApiCallResult || entity instanceof Alarm || entity instanceof EntityAlarm) { return false; } break; @@ -204,6 +206,9 @@ public class EdgeEventSourcingListener { return false; } } + if (entity instanceof OAuth2Info oAuth2Info) { + return oAuth2Info.isEdgeEnabled(); + } // Default: If the entity doesn't match any of the conditions, consider it as valid. return true; } @@ -222,6 +227,7 @@ public class EdgeEventSourcingListener { user.setAdditionalInfo(additionalInfo); } } + user.setVersion(null); } private EdgeEventType getEdgeEventTypeForEntityEvent(Object entity) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/instructions/DefaultEdgeUpgradeInstructionsService.java b/application/src/main/java/org/thingsboard/server/service/edge/instructions/DefaultEdgeUpgradeInstructionsService.java index 7113123a45..275de6bafe 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/instructions/DefaultEdgeUpgradeInstructionsService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/instructions/DefaultEdgeUpgradeInstructionsService.java @@ -15,9 +15,7 @@ */ package org.thingsboard.server.service.edge.instructions; -import lombok.Setter; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.AttributeScope; @@ -164,4 +162,5 @@ public class DefaultEdgeUpgradeInstructionsService extends BaseEdgeInstallUpgrad protected String getBaseDirName() { return UPGRADE_DIR; } + } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/instructions/EdgeInstallInstructionsService.java b/application/src/main/java/org/thingsboard/server/service/edge/instructions/EdgeInstallInstructionsService.java index 15b2383f21..f42e49c6e2 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/instructions/EdgeInstallInstructionsService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/instructions/EdgeInstallInstructionsService.java @@ -15,14 +15,14 @@ */ package org.thingsboard.server.service.edge.instructions; +import jakarta.servlet.http.HttpServletRequest; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeInstructions; -import jakarta.servlet.http.HttpServletRequest; - public interface EdgeInstallInstructionsService { EdgeInstructions getInstallInstructions(Edge edge, String installationMethod, HttpServletRequest request); void setAppVersion(String version); + } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index c5334f382b..46bf3f69a4 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -21,6 +21,9 @@ import com.google.common.util.concurrent.Futures; import io.grpc.Server; import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; import io.grpc.stub.StreamObserver; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -55,9 +58,6 @@ import org.thingsboard.server.service.edge.EdgeContextComponent; import org.thingsboard.server.service.state.DefaultDeviceStateService; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; -import jakarta.annotation.Nullable; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.io.IOException; import java.io.InputStream; import java.util.Collections; @@ -287,22 +287,31 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private void startSyncProcess(TenantId tenantId, EdgeId edgeId, UUID requestId) { EdgeGrpcSession session = sessions.get(edgeId); if (session != null) { - boolean success = false; - if (session.isConnected()) { - session.startSyncProcess(true); - success = true; + if (!session.isSyncCompleted()) { + clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, false, "Sync process is active at the moment")); + } else { + boolean success = false; + if (session.isConnected()) { + session.startSyncProcess(true); + success = true; + } + clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, success, "")); } - clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, success)); } } @Override public void processSyncRequest(ToEdgeSyncRequest request, Consumer responseConsumer) { - log.trace("[{}][{}] Processing sync edge request [{}]", request.getTenantId(), request.getId(), request.getEdgeId()); UUID requestId = request.getId(); - localSyncEdgeRequests.put(requestId, responseConsumer); - clusterService.pushEdgeSyncRequestToCore(request); - scheduleSyncRequestTimeout(request, requestId); + EdgeGrpcSession session = sessions.get(request.getEdgeId()); + if (session != null && !session.isSyncCompleted()) { + responseConsumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Sync process is active at the moment")); + } else { + log.trace("[{}][{}] Processing sync edge request [{}]", request.getTenantId(), request.getId(), request.getEdgeId()); + localSyncEdgeRequests.put(requestId, responseConsumer); + clusterService.pushEdgeSyncRequestToCore(request); + scheduleSyncRequestTimeout(request, requestId); + } } private void scheduleSyncRequestTimeout(ToEdgeSyncRequest request, UUID requestId) { @@ -312,7 +321,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i Consumer consumer = localSyncEdgeRequests.remove(requestId); if (consumer != null) { log.trace("[{}] timeout for processing sync edge request.", requestId); - consumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false)); + consumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Edge is not connected")); } }, 20, TimeUnit.SECONDS); } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java index 55e8e051ab..97b28c083f 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java @@ -247,6 +247,7 @@ public final class EdgeGrpcSession implements Closeable { } }, ctx.getGrpcCallbackExecutorService()); } else { + log.info("[{}][{}] sync process completed", this.tenantId, edge.getId()); DownlinkMsg syncCompleteDownlinkMsg = DownlinkMsg.newBuilder() .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) .setSyncCompletedMsg(SyncCompletedMsg.newBuilder().build()) @@ -325,7 +326,11 @@ public final class EdgeGrpcSession implements Closeable { } private void sendDownlinkMsg(ResponseMsg downlinkMsg) { - log.trace("[{}][{}] Sending downlink msg [{}]", this.tenantId, this.sessionId, downlinkMsg); + if (downlinkMsg.getDownlinkMsg().getWidgetTypeUpdateMsgCount() > 0) { + log.trace("[{}][{}] Sending downlink widgetTypeUpdateMsg, downlinkMsgId = {}", this.tenantId, this.sessionId, downlinkMsg.getDownlinkMsg().getDownlinkMsgId()); + } else { + log.trace("[{}][{}] Sending downlink msg [{}]", this.tenantId, this.sessionId, downlinkMsg); + } if (isConnected()) { downlinkMsgLock.lock(); try { @@ -337,7 +342,7 @@ public final class EdgeGrpcSession implements Closeable { } finally { downlinkMsgLock.unlock(); } - log.trace("[{}][{}] Response msg successfully sent [{}]", this.tenantId, this.sessionId, downlinkMsg); + log.trace("[{}][{}] Response msg successfully sent. downlinkMsgId = {}", this.tenantId, this.sessionId, downlinkMsg.getDownlinkMsg().getDownlinkMsgId()); } } @@ -371,10 +376,10 @@ public final class EdgeGrpcSession implements Closeable { @Override public void onSuccess(@Nullable Pair newStartTsAndSeqId) { if (newStartTsAndSeqId != null) { - ListenableFuture> updateFuture = updateQueueStartTsAndSeqId(newStartTsAndSeqId); + ListenableFuture> updateFuture = updateQueueStartTsAndSeqId(newStartTsAndSeqId); Futures.addCallback(updateFuture, new FutureCallback<>() { @Override - public void onSuccess(@Nullable List list) { + public void onSuccess(@Nullable List list) { log.debug("[{}][{}] queue offset was updated [{}]", tenantId, sessionId, newStartTsAndSeqId); if (fetcher.isSeqIdNewCycleStarted()) { seqIdEnd = fetcher.getSeqIdEnd(); @@ -551,9 +556,13 @@ public final class EdgeGrpcSession implements Closeable { DownlinkMsg downlinkMsg = null; try { switch (edgeEvent.getAction()) { - case UPDATED, ADDED, DELETED, ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE, ALARM_ACK, ALARM_CLEAR, ALARM_DELETE, CREDENTIALS_UPDATED, RELATION_ADD_OR_UPDATE, RELATION_DELETED, CREDENTIALS_REQUEST, RPC_CALL, ASSIGNED_TO_CUSTOMER, UNASSIGNED_FROM_CUSTOMER, ADDED_COMMENT, UPDATED_COMMENT, DELETED_COMMENT -> { + case UPDATED, ADDED, DELETED, ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE, ALARM_ACK, ALARM_CLEAR, ALARM_DELETE, CREDENTIALS_UPDATED, RELATION_ADD_OR_UPDATE, RELATION_DELETED, RPC_CALL, ASSIGNED_TO_CUSTOMER, UNASSIGNED_FROM_CUSTOMER, ADDED_COMMENT, UPDATED_COMMENT, DELETED_COMMENT -> { downlinkMsg = convertEntityEventToDownlink(edgeEvent); - log.trace("[{}][{}] entity message processed [{}]", this.tenantId, this.sessionId, downlinkMsg); + if (downlinkMsg != null && downlinkMsg.getWidgetTypeUpdateMsgCount() > 0) { + log.trace("[{}][{}] widgetTypeUpdateMsg message processed, downlinkMsgId = {}", this.tenantId, this.sessionId, downlinkMsg.getDownlinkMsgId()); + } else { + log.trace("[{}][{}] entity message processed [{}]", this.tenantId, this.sessionId, downlinkMsg); + } } case ATTRIBUTES_UPDATED, POST_ATTRIBUTES, ATTRIBUTES_DELETED, TIMESERIES_UPDATED -> downlinkMsg = ctx.getTelemetryProcessor().convertTelemetryEventToDownlink(edge, edgeEvent); @@ -626,7 +635,7 @@ public final class EdgeGrpcSession implements Closeable { return startSeqId; } - private ListenableFuture> updateQueueStartTsAndSeqId(Pair pair) { + private ListenableFuture> updateQueueStartTsAndSeqId(Pair pair) { this.newStartTs = pair.getFirst(); this.newStartSeqId = pair.getSecond(); log.trace("[{}] updateQueueStartTsAndSeqId [{}][{}][{}]", this.sessionId, edge.getId(), this.newStartTs, this.newStartSeqId); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java index 6321991586..125fd48463 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.edge.rpc; +import lombok.Getter; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.EntityId; @@ -53,6 +54,7 @@ public class EdgeSyncCursor { private final List fetchers = new LinkedList<>(); + @Getter private int currentIdx = 0; public EdgeSyncCursor(EdgeContextComponent ctx, Edge edge, boolean fullSync) { @@ -62,12 +64,12 @@ public class EdgeSyncCursor { fetchers.add(new RuleChainsEdgeEventFetcher(ctx.getRuleChainService())); fetchers.add(new AdminSettingsEdgeEventFetcher(ctx.getAdminSettingsService())); fetchers.add(new TenantAdminUsersEdgeEventFetcher(ctx.getUserService())); - Customer publicCustomer = ctx.getCustomerService().findOrCreatePublicCustomer(edge.getTenantId()); - fetchers.add(new CustomerEdgeEventFetcher(publicCustomer.getId())); - if (edge.getCustomerId() != null && !EntityId.NULL_UUID.equals(edge.getCustomerId().getId())) { - fetchers.add(new CustomerEdgeEventFetcher(edge.getCustomerId())); - fetchers.add(new CustomerUsersEdgeEventFetcher(ctx.getUserService(), edge.getCustomerId())); - } + } + Customer publicCustomer = ctx.getCustomerService().findOrCreatePublicCustomer(edge.getTenantId()); + fetchers.add(new CustomerEdgeEventFetcher(publicCustomer.getId())); + if (edge.getCustomerId() != null && !EntityId.NULL_UUID.equals(edge.getCustomerId().getId())) { + fetchers.add(new CustomerEdgeEventFetcher(edge.getCustomerId())); + fetchers.add(new CustomerUsersEdgeEventFetcher(ctx.getUserService(), edge.getCustomerId())); } fetchers.add(new DashboardsEdgeEventFetcher(ctx.getDashboardService())); fetchers.add(new DefaultProfilesEdgeEventFetcher(ctx.getDeviceProfileService(), ctx.getAssetProfileService())); @@ -102,9 +104,4 @@ public class EdgeSyncCursor { currentIdx++; return edgeEventFetcher; } - - public int getCurrentIdx() { - return currentIdx; - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/telemetry/EntityDataMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/telemetry/EntityDataMsgConstructor.java index 6435bc563c..6493f97f57 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/telemetry/EntityDataMsgConstructor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/telemetry/EntityDataMsgConstructor.java @@ -23,12 +23,12 @@ import com.google.gson.JsonPrimitive; import com.google.gson.reflect.TypeToken; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.gen.edge.v1.AttributeDeleteMsg; import org.thingsboard.server.gen.edge.v1.EntityDataProto; import org.thingsboard.server.gen.transport.TransportProtos; @@ -106,4 +106,5 @@ public class EntityDataMsgConstructor { } return result; } + } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/tenant/TenantMsgConstructorV2.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/tenant/TenantMsgConstructorV2.java index 274e18e7f3..ee0c6a0d72 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/tenant/TenantMsgConstructorV2.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/tenant/TenantMsgConstructorV2.java @@ -19,7 +19,6 @@ import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; -import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.TenantProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.TenantUpdateMsg; @@ -37,22 +36,6 @@ public class TenantMsgConstructorV2 implements TenantMsgConstructor { @Override public TenantProfileUpdateMsg constructTenantProfileUpdateMsg(UpdateMsgType msgType, TenantProfile tenantProfile, EdgeVersion edgeVersion) { - tenantProfile = JacksonUtil.clone(tenantProfile); - // clear all config - var configuration = tenantProfile.getDefaultProfileConfiguration(); - configuration.setRpcTtlDays(0); - configuration.setMaxJSExecutions(0); - configuration.setMaxREExecutions(0); - configuration.setMaxDPStorageDays(0); - configuration.setMaxTbelExecutions(0); - configuration.setQueueStatsTtlDays(0); - configuration.setMaxTransportMessages(0); - configuration.setDefaultStorageTtlDays(0); - configuration.setMaxTransportDataPoints(0); - configuration.setRuleEngineExceptionsTtlDays(0); - configuration.setMaxRuleNodeExecutionsPerMessage(0); - tenantProfile.getProfileData().setConfiguration(configuration); - return TenantProfileUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(tenantProfile)).build(); } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/OAuth2EdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/OAuth2EdgeEventFetcher.java index 4412f91183..982c1a8eaf 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/OAuth2EdgeEventFetcher.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/OAuth2EdgeEventFetcher.java @@ -45,8 +45,11 @@ public class OAuth2EdgeEventFetcher implements EdgeEventFetcher { @Override public PageData fetchEdgeEvents(TenantId tenantId, Edge edge, PageLink pageLink) { - List result = new ArrayList<>(); OAuth2Info oAuth2Info = oAuth2Service.findOAuth2Info(); + if (!oAuth2Info.isEdgeEnabled()) { + return new PageData<>(); + } + List result = new ArrayList<>(); result.add(EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.OAUTH2, EdgeEventActionType.ADDED, null, JacksonUtil.valueToTree(oAuth2Info))); // returns PageData object to be in sync with other fetchers diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/SystemWidgetTypesEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/SystemWidgetTypesEdgeEventFetcher.java index 7e957fd0fe..2cf47c6abd 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/SystemWidgetTypesEdgeEventFetcher.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/SystemWidgetTypesEdgeEventFetcher.java @@ -20,6 +20,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.DeprecatedFilter; +import org.thingsboard.server.common.data.widget.WidgetTypeFilter; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.dao.widget.WidgetTypeService; @@ -32,6 +33,12 @@ public class SystemWidgetTypesEdgeEventFetcher extends BaseWidgetTypesEdgeEventF @Override protected PageData findWidgetTypes(TenantId tenantId, PageLink pageLink) { - return widgetTypeService.findSystemWidgetTypesByPageLink(tenantId, false, DeprecatedFilter.ALL, null, pageLink); + return widgetTypeService.findSystemWidgetTypesByPageLink( + WidgetTypeFilter.builder() + .tenantId(tenantId) + .fullSearch(false) + .deprecatedFilter(DeprecatedFilter.ALL) + .widgetTypes(null).build(), + pageLink); } } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/SystemWidgetsBundlesEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/SystemWidgetsBundlesEdgeEventFetcher.java index 5ec2af5fb2..1e6a345db7 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/SystemWidgetsBundlesEdgeEventFetcher.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/SystemWidgetsBundlesEdgeEventFetcher.java @@ -20,6 +20,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.common.data.widget.WidgetsBundleFilter; import org.thingsboard.server.dao.widget.WidgetsBundleService; @Slf4j @@ -31,6 +32,6 @@ public class SystemWidgetsBundlesEdgeEventFetcher extends BaseWidgetsBundlesEdge @Override protected PageData findWidgetsBundles(TenantId tenantId, PageLink pageLink) { - return widgetsBundleService.findSystemWidgetsBundlesByPageLink(tenantId, false, pageLink); + return widgetsBundleService.findSystemWidgetsBundlesByPageLink(WidgetsBundleFilter.fromTenantId(tenantId), pageLink); } } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/TenantWidgetTypesEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/TenantWidgetTypesEdgeEventFetcher.java index c2a971a0c0..62fac80fbf 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/TenantWidgetTypesEdgeEventFetcher.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/TenantWidgetTypesEdgeEventFetcher.java @@ -20,6 +20,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.DeprecatedFilter; +import org.thingsboard.server.common.data.widget.WidgetTypeFilter; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.dao.widget.WidgetTypeService; @@ -31,6 +32,12 @@ public class TenantWidgetTypesEdgeEventFetcher extends BaseWidgetTypesEdgeEventF } @Override protected PageData findWidgetTypes(TenantId tenantId, PageLink pageLink) { - return widgetTypeService.findTenantWidgetTypesByTenantIdAndPageLink(tenantId, false, DeprecatedFilter.ALL, null, pageLink); + return widgetTypeService.findTenantWidgetTypesByTenantIdAndPageLink( + WidgetTypeFilter.builder() + .tenantId(tenantId) + .fullSearch(false) + .deprecatedFilter(DeprecatedFilter.ALL) + .widgetTypes(null).build(), + pageLink); } } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java index f46c07ab5a..b1c1fc63c5 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java @@ -373,7 +373,7 @@ public abstract class BaseEdgeProcessor { private boolean doSaveIfEdgeIsOffline(EdgeEventType type, EdgeEventActionType action) { return switch (action) { - case TIMESERIES_UPDATED, ALARM_ACK, ALARM_CLEAR, ALARM_ASSIGNED, ALARM_UNASSIGNED, CREDENTIALS_REQUEST, ADDED_COMMENT, UPDATED_COMMENT -> + case TIMESERIES_UPDATED, ALARM_ACK, ALARM_CLEAR, ALARM_ASSIGNED, ALARM_UNASSIGNED, ADDED_COMMENT, UPDATED_COMMENT -> true; default -> switch (type) { case ALARM, ALARM_COMMENT, RULE_CHAIN, RULE_CHAIN_METADATA, USER, CUSTOMER, TENANT, TENANT_PROFILE, WIDGETS_BUNDLE, WIDGET_TYPE, diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceEdgeProcessor.java index 5e73034b1c..f448766994 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceEdgeProcessor.java @@ -43,7 +43,6 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponseActorMsg; import org.thingsboard.server.dao.exception.DataValidationException; -import org.thingsboard.server.gen.edge.v1.DeviceCredentialsRequestMsg; import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg; import org.thingsboard.server.gen.edge.v1.DeviceRpcCallMsg; import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg; @@ -70,7 +69,7 @@ public abstract class DeviceEdgeProcessor extends BaseDeviceProcessor implements case ENTITY_CREATED_RPC_MESSAGE: case ENTITY_UPDATED_RPC_MESSAGE: saveOrUpdateDevice(tenantId, deviceId, deviceUpdateMsg, edge); - return saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.DEVICE, EdgeEventActionType.CREDENTIALS_REQUEST, deviceId, null); + return Futures.immediateFuture(null); case ENTITY_DELETED_RPC_MESSAGE: Device deviceToDelete = deviceService.findDeviceById(tenantId, deviceId); if (deviceToDelete != null) { @@ -232,6 +231,12 @@ public abstract class DeviceEdgeProcessor extends BaseDeviceProcessor implements DownlinkMsg.Builder builder = DownlinkMsg.newBuilder() .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) .addDeviceUpdateMsg(deviceUpdateMsg); + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(edgeEvent.getTenantId(), deviceId); + if (deviceCredentials != null) { + DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg = ((DeviceMsgConstructor) + deviceMsgConstructorFactory.getMsgConstructorByEdgeVersion(edgeVersion)).constructDeviceCredentialsUpdatedMsg(deviceCredentials); + builder.addDeviceCredentialsUpdateMsg(deviceCredentialsUpdateMsg).build(); + } if (UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE.equals(msgType)) { DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileById(edgeEvent.getTenantId(), device.getDeviceProfileId()); deviceProfile = checkIfDeviceProfileDefaultFieldsAssignedToEdge(edgeEvent.getTenantId(), edgeId, deviceProfile, edgeVersion); @@ -269,22 +274,7 @@ public abstract class DeviceEdgeProcessor extends BaseDeviceProcessor implements deviceMsgConstructorFactory.getMsgConstructorByEdgeVersion(edgeVersion)) .constructDeviceRpcCallMsg(edgeEvent.getEntityId(), edgeEvent.getBody())) .build(); - case CREDENTIALS_REQUEST: - return convertCredentialsRequestEventToDownlink(edgeEvent); } return downlinkMsg; } - - private DownlinkMsg convertCredentialsRequestEventToDownlink(EdgeEvent edgeEvent) { - DeviceId deviceId = new DeviceId(edgeEvent.getEntityId()); - DeviceCredentialsRequestMsg deviceCredentialsRequestMsg = DeviceCredentialsRequestMsg.newBuilder() - .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) - .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) - .build(); - DownlinkMsg.Builder builder = DownlinkMsg.newBuilder() - .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) - .addDeviceCredentialsRequestMsg(deviceCredentialsRequestMsg); - return builder.build(); - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceProcessor.java index f1cd188af5..b55e47b294 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceProcessor.java @@ -21,7 +21,6 @@ import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg; -import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.DeviceRpcCallMsg; import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; @@ -37,4 +36,5 @@ public interface DeviceProcessor extends EdgeProcessor { DownlinkMsg convertDeviceEventToDownlink(EdgeEvent edgeEvent, EdgeId edgeId, EdgeVersion edgeVersion); ListenableFuture processDeviceRpcCallFromEdge(TenantId tenantId, Edge edge, DeviceRpcCallMsg deviceRpcCallMsg); + } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/oauth2/OAuth2EdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/oauth2/OAuth2EdgeProcessor.java index ce0600b491..adfb6f7d9f 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/oauth2/OAuth2EdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/oauth2/OAuth2EdgeProcessor.java @@ -40,7 +40,7 @@ public class OAuth2EdgeProcessor extends BaseEdgeProcessor { public DownlinkMsg convertOAuth2EventToDownlink(EdgeEvent edgeEvent) { DownlinkMsg downlinkMsg = null; OAuth2Info oAuth2Info = JacksonUtil.convertValue(edgeEvent.getBody(), OAuth2Info.class); - if (oAuth2Info != null) { + if (oAuth2Info != null && oAuth2Info.isEdgeEnabled()) { OAuth2UpdateMsg oAuth2UpdateMsg = oAuth2MsgConstructor.constructOAuth2UpdateMsg(oAuth2Info); downlinkMsg = DownlinkMsg.newBuilder() .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java index 30f74ae42d..f63aa8b829 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java @@ -61,10 +61,19 @@ public class RuleChainEdgeProcessor extends BaseEdgeProcessor { RuleChainUpdateMsg ruleChainUpdateMsg = ((RuleChainMsgConstructor) ruleChainMsgConstructorFactory.getMsgConstructorByEdgeVersion(edgeVersion)) .constructRuleChainUpdatedMsg(msgType, ruleChain, isRoot); - downlinkMsg = DownlinkMsg.newBuilder() + + DownlinkMsg.Builder builder = DownlinkMsg.newBuilder() .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) - .addRuleChainUpdateMsg(ruleChainUpdateMsg) - .build(); + .addRuleChainUpdateMsg(ruleChainUpdateMsg); + + RuleChainMetaData ruleChainMetaData = ruleChainService.loadRuleChainMetaData(edgeEvent.getTenantId(), ruleChainId); + RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = ((RuleChainMsgConstructor) + ruleChainMsgConstructorFactory.getMsgConstructorByEdgeVersion(edgeVersion)) + .constructRuleChainMetadataUpdatedMsg(edgeEvent.getTenantId(), msgType, ruleChainMetaData, edgeVersion); + if (ruleChainMetadataUpdateMsg != null) { + builder.addRuleChainMetadataUpdateMsg(ruleChainMetadataUpdateMsg); + } + downlinkMsg = builder.build(); } } case DELETED, UNASSIGNED_FROM_EDGE -> downlinkMsg = DownlinkMsg.newBuilder() diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java index 607c5e665c..cd072b4a1e 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java @@ -43,10 +43,16 @@ public class UserEdgeProcessor extends BaseEdgeProcessor { User user = userService.findUserById(edgeEvent.getTenantId(), userId); if (user != null) { UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); - downlinkMsg = DownlinkMsg.newBuilder() + DownlinkMsg.Builder builder = DownlinkMsg.newBuilder() .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) - .addUserUpdateMsg(((UserMsgConstructor) userMsgConstructorFactory.getMsgConstructorByEdgeVersion(edgeVersion)).constructUserUpdatedMsg(msgType, user)) - .build(); + .addUserUpdateMsg(((UserMsgConstructor) userMsgConstructorFactory.getMsgConstructorByEdgeVersion(edgeVersion)).constructUserUpdatedMsg(msgType, user)); + UserCredentials userCredentialsByUserId = userService.findUserCredentialsByUserId(edgeEvent.getTenantId(), userId); + if (userCredentialsByUserId != null && userCredentialsByUserId.isEnabled()) { + UserCredentialsUpdateMsg userCredentialsUpdateMsg = + ((UserMsgConstructor) userMsgConstructorFactory.getMsgConstructorByEdgeVersion(edgeVersion)).constructUserCredentialsUpdatedMsg(userCredentialsByUserId); + builder.addUserCredentialsUpdateMsg(userCredentialsUpdateMsg); + } + downlinkMsg = builder.build(); } } case DELETED -> downlinkMsg = DownlinkMsg.newBuilder() diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/DefaultEdgeRequestsService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/DefaultEdgeRequestsService.java index 3f61734801..6e4117869b 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/DefaultEdgeRequestsService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/DefaultEdgeRequestsService.java @@ -82,8 +82,6 @@ import java.util.UUID; @Slf4j public class DefaultEdgeRequestsService implements EdgeRequestsService { - private static final int DEFAULT_PAGE_SIZE = 1000; - @Autowired private EdgeEventService edgeEventService; @@ -142,8 +140,10 @@ public class DefaultEdgeRequestsService implements EdgeRequestsService { private ListenableFuture processEntityAttributesAndAddToEdgeQueue(TenantId tenantId, EntityId entityId, Edge edge, EdgeEventType entityType, String scope, List ssAttributes, AttributesRequestMsg attributesRequestMsg) { + Map entityData = null; + ObjectNode attributes = null; + ListenableFuture future; try { - ListenableFuture future; if (ssAttributes == null || ssAttributes.isEmpty()) { log.trace("[{}][{}] No attributes found for entity {} [{}]", tenantId, edge.getName(), @@ -151,8 +151,8 @@ public class DefaultEdgeRequestsService implements EdgeRequestsService { entityId.getId()); future = Futures.immediateFuture(null); } else { - Map entityData = new HashMap<>(); - ObjectNode attributes = JacksonUtil.newObjectNode(); + entityData = new HashMap<>(); + attributes = JacksonUtil.newObjectNode(); for (AttributeKvEntry attr : ssAttributes) { if (DefaultDeviceStateService.PERSISTENT_ATTRIBUTES.contains(attr.getKey()) && !DefaultDeviceStateService.INACTIVITY_TIMEOUT.equals(attr.getKey())) { @@ -170,7 +170,7 @@ public class DefaultEdgeRequestsService implements EdgeRequestsService { attributes.put(attr.getKey(), attr.getValueAsString()); } } - if (attributes.size() > 0) { + if (!attributes.isEmpty()) { entityData.put("kv", attributes); entityData.put("scope", scope); JsonNode body = JacksonUtil.valueToTree(entityData); @@ -182,12 +182,13 @@ public class DefaultEdgeRequestsService implements EdgeRequestsService { } return Futures.transformAsync(future, v -> processLatestTimeseriesAndAddToEdgeQueue(tenantId, entityId, edge, entityType), dbCallbackExecutorService); } catch (Exception e) { - String errMsg = String.format("[%s][%s] Failed to save attribute updates to the edge [%s]", tenantId, edge.getId(), attributesRequestMsg); + String errMsg = String.format("[%s][%s] Failed to save attribute updates to the edge [%s], scope = %s, entityData = %s, attributes = %s", + tenantId, edge.getId(), attributesRequestMsg, scope, entityData, attributes); log.error(errMsg, e); return Futures.immediateFailedFuture(new RuntimeException(errMsg, e)); } } - + private ListenableFuture processLatestTimeseriesAndAddToEdgeQueue(TenantId tenantId, EntityId entityId, Edge edge, EdgeEventType entityType) { ListenableFuture> getAllLatestFuture = timeseriesService.findAllLatest(tenantId, entityId); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java index f7c19773ae..b14e605b15 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java @@ -23,7 +23,6 @@ import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; @Service diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/DefaultTbDashboardService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/DefaultTbDashboardService.java index 685eda0858..ee8b2d7e47 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/DefaultTbDashboardService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/DefaultTbDashboardService.java @@ -30,7 +30,6 @@ import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.dashboard.DashboardService; -import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java index b05ece9c61..d527492d98 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java @@ -183,7 +183,7 @@ public class DefaultTbDeviceService extends AbstractTbEntityService implements T try { DeviceCredentials result = checkNotNull(deviceCredentialsService.updateDeviceCredentials(tenantId, deviceCredentials)); logEntityActionService.logEntityAction(tenantId, deviceId, device, device.getCustomerId(), - actionType, user, deviceCredentials); + actionType, user, result); return result; } catch (Exception e) { logEntityActionService.logEntityAction(tenantId, emptyId(EntityType.DEVICE), diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/DefaultTbEntityRelationService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/DefaultTbEntityRelationService.java index 724c077e62..e9cfc109f8 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/DefaultTbEntityRelationService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/DefaultTbEntityRelationService.java @@ -39,12 +39,13 @@ public class DefaultTbEntityRelationService extends AbstractTbEntityService impl private final RelationService relationService; @Override - public void save(TenantId tenantId, CustomerId customerId, EntityRelation relation, User user) throws ThingsboardException { + public EntityRelation save(TenantId tenantId, CustomerId customerId, EntityRelation relation, User user) throws ThingsboardException { ActionType actionType = ActionType.RELATION_ADD_OR_UPDATE; try { - relationService.saveRelation(tenantId, relation); + var savedRelation = relationService.saveRelation(tenantId, relation); logEntityActionService.logEntityRelationAction(tenantId, customerId, - relation, user, actionType, null, relation); + savedRelation, user, actionType, null, savedRelation); + return savedRelation; } catch (Exception e) { logEntityActionService.logEntityRelationAction(tenantId, customerId, relation, user, actionType, e, relation); @@ -53,14 +54,15 @@ public class DefaultTbEntityRelationService extends AbstractTbEntityService impl } @Override - public void delete(TenantId tenantId, CustomerId customerId, EntityRelation relation, User user) throws ThingsboardException { + public EntityRelation delete(TenantId tenantId, CustomerId customerId, EntityRelation relation, User user) throws ThingsboardException { ActionType actionType = ActionType.RELATION_DELETED; try { - boolean found = relationService.deleteRelation(tenantId, relation.getFrom(), relation.getTo(), relation.getType(), relation.getTypeGroup()); - if (!found) { + var found = relationService.deleteRelation(tenantId, relation.getFrom(), relation.getTo(), relation.getType(), relation.getTypeGroup()); + if (found == null) { throw new ThingsboardException("Requested item wasn't found!", ThingsboardErrorCode.ITEM_NOT_FOUND); } - logEntityActionService.logEntityRelationAction(tenantId, customerId, relation, user, actionType, null, relation); + logEntityActionService.logEntityRelationAction(tenantId, customerId, found, user, actionType, null, found); + return found; } catch (Exception e) { logEntityActionService.logEntityRelationAction(tenantId, customerId, relation, user, actionType, e, relation); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/TbEntityRelationService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/TbEntityRelationService.java index 7b732ff9ee..0ef75d0354 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/TbEntityRelationService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/TbEntityRelationService.java @@ -24,9 +24,9 @@ import org.thingsboard.server.common.data.relation.EntityRelation; public interface TbEntityRelationService { - void save(TenantId tenantId, CustomerId customerId, EntityRelation entity, User user) throws ThingsboardException; + EntityRelation save(TenantId tenantId, CustomerId customerId, EntityRelation entity, User user) throws ThingsboardException; - void delete(TenantId tenantId, CustomerId customerId, EntityRelation entity, User user) throws ThingsboardException; + EntityRelation delete(TenantId tenantId, CustomerId customerId, EntityRelation entity, User user) throws ThingsboardException; void deleteCommonRelations(TenantId tenantId, CustomerId customerId, EntityId entityId, User user) throws ThingsboardException; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java index 74061ee9e5..bb855d527a 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java @@ -17,7 +17,6 @@ package org.thingsboard.server.service.entitiy.tenant; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.beans.factory.annotation.Value; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.id.TenantId; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java index 68595bed2e..299d499daa 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.entitiy.user; +import jakarta.servlet.http.HttpServletRequest; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -32,8 +33,6 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; import org.thingsboard.server.service.security.system.SystemSecurityService; -import jakarta.servlet.http.HttpServletRequest; - import static org.thingsboard.server.controller.UserController.ACTIVATE_URL_PATTERN; @Service diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java index ae6b40e0fe..388db9df40 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java @@ -15,13 +15,12 @@ */ package org.thingsboard.server.service.entitiy.user; +import jakarta.servlet.http.HttpServletRequest; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; -import jakarta.servlet.http.HttpServletRequest; - public interface TbUserService { User save(TenantId tenantId, CustomerId customerId, User tbUser, boolean sendActivationMail, HttpServletRequest request, User user) throws ThingsboardException; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/DefaultWidgetTypeService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/DefaultWidgetTypeService.java index fab6029886..76027f3621 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/DefaultWidgetTypeService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/DefaultWidgetTypeService.java @@ -16,7 +16,6 @@ package org.thingsboard.server.service.entitiy.widgets.type; import lombok.AllArgsConstructor; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; @@ -25,7 +24,6 @@ import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; -import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; diff --git a/application/src/main/java/org/thingsboard/server/service/executors/SharedEventLoopGroupService.java b/application/src/main/java/org/thingsboard/server/service/executors/SharedEventLoopGroupService.java index d6d4d8f626..9bc62fbdab 100644 --- a/application/src/main/java/org/thingsboard/server/service/executors/SharedEventLoopGroupService.java +++ b/application/src/main/java/org/thingsboard/server/service/executors/SharedEventLoopGroupService.java @@ -17,11 +17,11 @@ package org.thingsboard.server.service.executors; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Getter; import org.springframework.stereotype.Component; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.concurrent.TimeUnit; @Component diff --git a/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/AlarmsUnassignTaskProcessor.java b/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/AlarmsUnassignTaskProcessor.java index 3ba6ecde52..ba596da500 100644 --- a/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/AlarmsUnassignTaskProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/AlarmsUnassignTaskProcessor.java @@ -18,10 +18,10 @@ package org.thingsboard.server.service.housekeeper.processor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.housekeeper.AlarmsUnassignHousekeeperTask; +import org.thingsboard.server.common.data.housekeeper.HousekeeperTaskType; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.UserId; -import org.thingsboard.server.common.data.housekeeper.HousekeeperTaskType; -import org.thingsboard.server.common.data.housekeeper.AlarmsUnassignHousekeeperTask; import org.thingsboard.server.service.entitiy.alarm.TbAlarmService; import java.util.List; diff --git a/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/AttributesDeletionTaskProcessor.java b/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/AttributesDeletionTaskProcessor.java index 5514921e2e..337f5ed73c 100644 --- a/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/AttributesDeletionTaskProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/AttributesDeletionTaskProcessor.java @@ -18,9 +18,9 @@ package org.thingsboard.server.service.housekeeper.processor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.common.data.housekeeper.HousekeeperTask; import org.thingsboard.server.common.data.housekeeper.HousekeeperTaskType; +import org.thingsboard.server.dao.attributes.AttributesService; @Component @RequiredArgsConstructor diff --git a/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/EventsDeletionTaskProcessor.java b/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/EventsDeletionTaskProcessor.java index bf9c528eb7..4b4865ea56 100644 --- a/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/EventsDeletionTaskProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/EventsDeletionTaskProcessor.java @@ -17,9 +17,9 @@ package org.thingsboard.server.service.housekeeper.processor; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import org.thingsboard.server.dao.event.EventService; import org.thingsboard.server.common.data.housekeeper.HousekeeperTask; import org.thingsboard.server.common.data.housekeeper.HousekeeperTaskType; +import org.thingsboard.server.dao.event.EventService; @Component @RequiredArgsConstructor diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java index 7588726a73..e49a42b57b 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java @@ -577,7 +577,7 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { Collections.singletonList(new BasicTsKvEntry(System.currentTimeMillis(), new BooleanDataEntry(key, value))), 0L); addTsCallback(saveFuture, new TelemetrySaveCallback<>(deviceId, key, value)); } else { - ListenableFuture> saveFuture = attributesService.save(TenantId.SYS_TENANT_ID, deviceId, AttributeScope.SERVER_SCOPE, + ListenableFuture> saveFuture = attributesService.save(TenantId.SYS_TENANT_ID, deviceId, AttributeScope.SERVER_SCOPE, Collections.singletonList(new BaseAttributeKvEntry(new BooleanDataEntry(key, value) , System.currentTimeMillis()))); addTsCallback(saveFuture, new TelemetrySaveCallback<>(deviceId, key, value)); diff --git a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java index 9db2a99e7f..7f76ce860f 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java +++ b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.install; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.Getter; import lombok.Setter; import lombok.SneakyThrows; @@ -26,8 +27,12 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.ImageDescriptor; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; @@ -43,8 +48,10 @@ import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.oauth2.OAuth2ConfigTemplateService; +import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.util.ImageUtils; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetsBundleService; import org.thingsboard.server.service.install.update.ImagesUpdater; @@ -84,12 +91,14 @@ public class InstallScripts { public static final String RULE_CHAINS_DIR = "rule_chains"; public static final String WIDGET_TYPES_DIR = "widget_types"; public static final String WIDGET_BUNDLES_DIR = "widget_bundles"; + public static final String SCADA_SYMBOLS_DIR = "scada_symbols"; public static final String OAUTH2_CONFIG_TEMPLATES_DIR = "oauth2_config_templates"; public static final String DASHBOARDS_DIR = "dashboards"; public static final String MODELS_LWM2M_DIR = "lwm2m-registry"; public static final String CREDENTIALS_DIR = "credentials"; public static final String JSON_EXT = ".json"; + public static final String SVG_EXT = ".svg"; public static final String XML_EXT = ".xml"; @Value("${install.data_dir:}") @@ -118,6 +127,9 @@ public class InstallScripts { @Getter @Setter private boolean updateImages = false; + @Autowired + private ImageService imageService; + Path getTenantRuleChainsDir() { return Paths.get(getDataDir(), JSON_DIR, TENANT_DIR, RULE_CHAINS_DIR); } @@ -253,6 +265,7 @@ public class InstallScripts { ); } } + this.loadSystemScadaSymbols(); for (var widgetsBundleDescriptorEntry : widgetsBundlesMap.entrySet()) { Path path = widgetsBundleDescriptorEntry.getKey(); try { @@ -290,6 +303,94 @@ public class InstallScripts { } } + private void loadSystemScadaSymbols() throws Exception { + log.info("Loading system SCADA symbols"); + Path scadaSymbolsDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, SCADA_SYMBOLS_DIR); + if (Files.exists(scadaSymbolsDir)) { + WidgetTypeDetails scadaSymbolWidgetTemplate = widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "scada_symbol"); + try (DirectoryStream dirStream = Files.newDirectoryStream(scadaSymbolsDir, path -> path.toString().endsWith(SVG_EXT))) { + dirStream.forEach( + path -> { + try { + var fileName = path.getFileName().toString(); + var scadaSymbolData = Files.readAllBytes(path); + var metadata = ImageUtils.processScadaSymbolMetadata(fileName, scadaSymbolData); + TbResourceInfo savedScadaSymbol = saveScadaSymbol(metadata, fileName, scadaSymbolData); + if (scadaSymbolWidgetTemplate != null) { + saveScadaSymbolWidget(scadaSymbolWidgetTemplate, savedScadaSymbol, metadata); + } + } catch (Exception e) { + log.error("Unable to load SCADA symbol from file: [{}]", path.toString()); + throw new RuntimeException("Unable to load SCADA symbol from file", e); + } + } + ); + } + } + } + + private TbResourceInfo saveScadaSymbol(ImageUtils.ScadaSymbolMetadataInfo metadata, String fileName, byte[] scadaSymbolData) { + String etag = imageService.calculateImageEtag(scadaSymbolData); + var existingImage = imageService.findSystemOrTenantImageByEtag(TenantId.SYS_TENANT_ID, etag); + if (existingImage != null && ResourceSubType.SCADA_SYMBOL.equals(existingImage.getResourceSubType())) { + return existingImage; + } else { + var existing = imageService.getImageInfoByTenantIdAndKey(TenantId.SYS_TENANT_ID, fileName); + if (existing != null && ResourceSubType.SCADA_SYMBOL.equals(existing.getResourceSubType())) { + imageService.deleteImage(existing, true); + } + TbResource image = new TbResource(); + image.setTenantId(TenantId.SYS_TENANT_ID); + image.setFileName(fileName); + image.setTitle(metadata.getTitle()); + image.setResourceSubType(ResourceSubType.SCADA_SYMBOL); + image.setResourceType(ResourceType.IMAGE); + image.setPublic(true); + ImageDescriptor descriptor = new ImageDescriptor(); + descriptor.setMediaType("image/svg+xml"); + image.setDescriptorValue(descriptor); + image.setData(scadaSymbolData); + return imageService.saveImage(image); + } + } + + private WidgetTypeDetails saveScadaSymbolWidget(WidgetTypeDetails template, TbResourceInfo scadaSymbol, + ImageUtils.ScadaSymbolMetadataInfo metadata) { + String symbolUrl = DataConstants.TB_IMAGE_PREFIX + scadaSymbol.getLink(); + WidgetTypeDetails scadaSymbolWidget = new WidgetTypeDetails(); + JsonNode descriptor = JacksonUtil.clone(template.getDescriptor()); + scadaSymbolWidget.setDescriptor(descriptor); + scadaSymbolWidget.setName(metadata.getTitle()); + scadaSymbolWidget.setImage(symbolUrl); + scadaSymbolWidget.setDescription(metadata.getDescription()); + scadaSymbolWidget.setTags(metadata.getSearchTags()); + scadaSymbolWidget.setScada(true); + ObjectNode defaultConfig = null; + if (descriptor.has("defaultConfig")) { + defaultConfig = JacksonUtil.fromString(descriptor.get("defaultConfig").asText(), ObjectNode.class); + } + if (defaultConfig == null) { + defaultConfig = JacksonUtil.newObjectNode(); + } + defaultConfig.put("title", metadata.getTitle()); + ObjectNode settings; + if (defaultConfig.has("settings")) { + settings = (ObjectNode)defaultConfig.get("settings"); + } else { + settings = JacksonUtil.newObjectNode(); + defaultConfig.set("settings", settings); + } + settings.put("scadaSymbolUrl", symbolUrl); + ((ObjectNode)descriptor).put("defaultConfig", JacksonUtil.toString(defaultConfig)); + ((ObjectNode)descriptor).put("sizeX", metadata.getWidgetSizeX()); + ((ObjectNode)descriptor).put("sizeY", metadata.getWidgetSizeY()); + String controllerScript = descriptor.get("controllerScript").asText(); + controllerScript = controllerScript.replaceAll("previewWidth: '\\d*px'", "previewWidth: '" + (metadata.getWidgetSizeX() * 100) + "px'"); + controllerScript = controllerScript.replaceAll("previewHeight: '\\d*px'", "previewHeight: '" + (metadata.getWidgetSizeY() * 100 + 20) + "px'"); + ((ObjectNode)descriptor).put("controllerScript", controllerScript); + return widgetTypeService.saveWidgetType(scadaSymbolWidget); + } + private void deleteSystemWidgetBundle(String bundleAlias) { WidgetsBundle widgetsBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, bundleAlias); if (widgetsBundle != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java index 86d922c305..2d5ac5a99b 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java @@ -121,6 +121,9 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService case "3.6.4": updateSchema("3.6.4", 3006004, "3.7.0", 3007000, null); break; + case "3.7.0": + updateSchema("3.7.0", 3007000, "3.7.1", 3007001, null); + break; default: throw new RuntimeException("Unable to upgrade SQL database, unsupported fromVersion: " + fromVersion); } @@ -189,4 +192,5 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService } return isOldSchema; } + } diff --git a/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraTsLatestToSqlMigrateService.java b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraTsLatestToSqlMigrateService.java index 9cad1d614f..b2ea8f9f59 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraTsLatestToSqlMigrateService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraTsLatestToSqlMigrateService.java @@ -24,8 +24,8 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.UUIDConverter; import org.thingsboard.server.dao.cassandra.CassandraCluster; -import org.thingsboard.server.dao.model.sqlts.dictionary.KeyDictionaryEntry; import org.thingsboard.server.dao.model.sqlts.dictionary.KeyDictionaryCompositeKey; +import org.thingsboard.server.dao.model.sqlts.dictionary.KeyDictionaryEntry; import org.thingsboard.server.dao.model.sqlts.latest.TsKvLatestEntity; import org.thingsboard.server.dao.sqlts.dictionary.KeyDictionaryRepository; import org.thingsboard.server.dao.sqlts.insert.latest.InsertLatestTsRepository; diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java index 7fd92bc5c6..0085df35d7 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java @@ -61,6 +61,10 @@ public class DefaultCacheCleanupService implements CacheCleanupService { log.info("Clearing cache to upgrade from version 3.6.4 to 3.7.0"); clearAll(); break; + case "3.7.0": + log.info("Clearing cache to upgrade from version 3.7.0 to 3.7.1"); + clearAll(); + break; default: //Do nothing, since cache cleanup is optional. } @@ -81,7 +85,7 @@ public class DefaultCacheCleanupService implements CacheCleanupService { if (redisTemplate.isPresent()) { log.info("Flushing all caches"); redisTemplate.get().execute((RedisCallback) connection -> { - connection.flushAll(); + connection.serverCommands().flushAll(); return null; }); return; diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/ImagesUpdater.java b/application/src/main/java/org/thingsboard/server/service/install/update/ImagesUpdater.java index 501cd788e3..3914f049ce 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/update/ImagesUpdater.java +++ b/application/src/main/java/org/thingsboard/server/service/install/update/ImagesUpdater.java @@ -147,7 +147,7 @@ public class ImagesUpdater { try { entity = dao.findById(TenantId.SYS_TENANT_ID, id.getId()); } catch (Exception e) { - log.error("Failed to update {} images: error fetching {} by id [{}]: {}", type, type, id.getId(), StringUtils.abbreviate(e.toString(), 1000)); + log.error("Failed to update {} images: error fetching entity by id [{}]: {}", type, id.getId(), StringUtils.abbreviate(e.toString(), 1000)); continue; } try { diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java index 257dfa14b1..3577683729 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java @@ -19,6 +19,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import freemarker.template.Configuration; import freemarker.template.Template; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.mail.internet.MimeMessage; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -53,9 +56,6 @@ import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import jakarta.mail.internet.MimeMessage; import java.io.ByteArrayInputStream; import java.util.HashMap; import java.util.Locale; diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java index a7726c3824..14e52c1326 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java @@ -16,12 +16,12 @@ package org.thingsboard.server.service.mail; import com.fasterxml.jackson.databind.JsonNode; +import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; -import jakarta.annotation.PostConstruct; import java.io.IOException; @Service diff --git a/application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java b/application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java index 63f2b9e9e4..10586bc41a 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java @@ -23,10 +23,11 @@ import com.google.api.client.auth.oauth2.TokenResponse; import com.google.api.client.http.GenericUrl; import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.client.json.gson.GsonFactory; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; import lombok.extern.slf4j.Slf4j; import org.springframework.lang.Nullable; import org.springframework.mail.MailException; -import org.springframework.mail.MailSendException; import org.springframework.mail.javamail.JavaMailSenderImpl; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.StringUtils; @@ -34,8 +35,6 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.mail.MailOauth2Provider; import org.thingsboard.server.dao.exception.IncorrectParameterException; -import jakarta.mail.MessagingException; -import jakarta.mail.internet.MimeMessage; import java.time.Duration; import java.time.Instant; import java.util.Properties; diff --git a/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java b/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java index e7964169fa..84d3b6c378 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java @@ -73,6 +73,7 @@ import org.thingsboard.server.service.telemetry.AbstractSubscriptionService; import org.thingsboard.server.service.ws.notification.sub.NotificationRequestUpdate; import org.thingsboard.server.service.ws.notification.sub.NotificationUpdate; +import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -115,12 +116,23 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple } else { notificationTemplate = request.getTemplate(); } - if (notificationTemplate == null) throw new IllegalArgumentException("Template is missing"); + if (notificationTemplate == null) { + throw new IllegalArgumentException("Template is missing"); + } Set deliveryMethods = new HashSet<>(); - List targets = request.getTargets().stream().map(NotificationTargetId::new) - .map(id -> notificationTargetService.findNotificationTargetById(tenantId, id)) - .collect(Collectors.toList()); + List targets = new ArrayList<>(); + for (UUID targetId : request.getTargets()) { + NotificationTarget target = notificationTargetService.findNotificationTargetById(tenantId, new NotificationTargetId(targetId)); + if (target != null) { + targets.add(target); + } else { + log.debug("Unknown notification target {} in request {}", targetId, request); + } + } + if (targets.isEmpty()) { + throw new IllegalArgumentException("No recipients chosen"); + } NotificationRuleId ruleId = request.getRuleId(); notificationTemplate.getConfiguration().getDeliveryMethodsTemplates().forEach((deliveryMethod, template) -> { @@ -350,6 +362,7 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple NotificationUpdate update = NotificationUpdate.builder() .updated(true) .notificationId(notificationId.getId()) + .notificationType(notification.getType()) .newStatus(NotificationStatus.READ) .build(); onNotificationUpdate(tenantId, recipientId, update); diff --git a/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationSchedulerService.java b/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationSchedulerService.java index 916fe6da6c..5ba86198db 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationSchedulerService.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationSchedulerService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.notification; import com.google.common.util.concurrent.ListenableFuture; +import jakarta.annotation.PostConstruct; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -40,7 +41,6 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.executors.NotificationExecutorService; import org.thingsboard.server.service.partition.AbstractPartitionBasedService; -import jakarta.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.util.Collections; import java.util.HashSet; diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/DefaultNotificationRuleProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/DefaultNotificationRuleProcessor.java index a86315ce86..dd31642aac 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/rule/DefaultNotificationRuleProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/DefaultNotificationRuleProcessor.java @@ -22,6 +22,7 @@ import org.springframework.context.annotation.Lazy; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.rule.engine.api.NotificationCenter; +import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.NotificationRequestId; @@ -41,7 +42,6 @@ import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.dao.notification.NotificationRequestService; -import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.notification.NotificationDeduplicationService; import org.thingsboard.server.service.executors.NotificationExecutorService; diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmAssignmentTriggerProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmAssignmentTriggerProcessor.java index 36c2d91b8e..9cef99fc38 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmAssignmentTriggerProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmAssignmentTriggerProcessor.java @@ -22,10 +22,10 @@ import org.thingsboard.server.common.data.alarm.AlarmStatusFilter; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.notification.info.AlarmAssignmentNotificationInfo; import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; +import org.thingsboard.server.common.data.notification.rule.trigger.AlarmAssignmentTrigger; import org.thingsboard.server.common.data.notification.rule.trigger.config.AlarmAssignmentNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.config.AlarmAssignmentNotificationRuleTriggerConfig.Action; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; -import org.thingsboard.server.common.data.notification.rule.trigger.AlarmAssignmentTrigger; import static org.apache.commons.collections4.CollectionUtils.isEmpty; import static org.thingsboard.server.common.data.util.CollectionsUtil.emptyOrContains; diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmCommentTriggerProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmCommentTriggerProcessor.java index bd2bccecb2..6adead3963 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmCommentTriggerProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmCommentTriggerProcessor.java @@ -24,9 +24,9 @@ import org.thingsboard.server.common.data.alarm.AlarmStatusFilter; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.notification.info.AlarmCommentNotificationInfo; import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; +import org.thingsboard.server.common.data.notification.rule.trigger.AlarmCommentTrigger; import org.thingsboard.server.common.data.notification.rule.trigger.config.AlarmCommentNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; -import org.thingsboard.server.common.data.notification.rule.trigger.AlarmCommentTrigger; import org.thingsboard.server.dao.entity.EntityService; import static org.apache.commons.collections4.CollectionUtils.isEmpty; diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/ApiUsageLimitTriggerProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/ApiUsageLimitTriggerProcessor.java index 7b21e5ca28..4b707d0c6d 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/ApiUsageLimitTriggerProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/ApiUsageLimitTriggerProcessor.java @@ -19,9 +19,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.notification.info.ApiUsageLimitNotificationInfo; import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; +import org.thingsboard.server.common.data.notification.rule.trigger.ApiUsageLimitTrigger; import org.thingsboard.server.common.data.notification.rule.trigger.config.ApiUsageLimitNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; -import org.thingsboard.server.common.data.notification.rule.trigger.ApiUsageLimitTrigger; import org.thingsboard.server.dao.tenant.TenantService; import static org.thingsboard.server.common.data.util.CollectionsUtil.emptyOrContains; diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/DeviceActivityTriggerProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/DeviceActivityTriggerProcessor.java index e2d5484f59..e5500c45d1 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/DeviceActivityTriggerProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/DeviceActivityTriggerProcessor.java @@ -23,10 +23,10 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.info.DeviceActivityNotificationInfo; import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; +import org.thingsboard.server.common.data.notification.rule.trigger.DeviceActivityTrigger; import org.thingsboard.server.common.data.notification.rule.trigger.config.DeviceActivityNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.config.DeviceActivityNotificationRuleTriggerConfig.DeviceEvent; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; -import org.thingsboard.server.common.data.notification.rule.trigger.DeviceActivityTrigger; import org.thingsboard.server.service.profile.TbDeviceProfileCache; @Service diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/EntityActionTriggerProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/EntityActionTriggerProcessor.java index 301955eb36..ccbbc8c5d8 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/EntityActionTriggerProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/EntityActionTriggerProcessor.java @@ -20,9 +20,9 @@ import org.thingsboard.server.common.data.HasCustomerId; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.notification.info.EntityActionNotificationInfo; import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; +import org.thingsboard.server.common.data.notification.rule.trigger.EntityActionTrigger; import org.thingsboard.server.common.data.notification.rule.trigger.config.EntityActionNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; -import org.thingsboard.server.common.data.notification.rule.trigger.EntityActionTrigger; import static org.thingsboard.server.common.data.util.CollectionsUtil.emptyOrContains; diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/NewPlatformVersionTriggerProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/NewPlatformVersionTriggerProcessor.java index 184acac061..6d9fb2d573 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/NewPlatformVersionTriggerProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/NewPlatformVersionTriggerProcessor.java @@ -20,9 +20,9 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.UpdateMessage; import org.thingsboard.server.common.data.notification.info.NewPlatformVersionNotificationInfo; import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; +import org.thingsboard.server.common.data.notification.rule.trigger.NewPlatformVersionTrigger; import org.thingsboard.server.common.data.notification.rule.trigger.config.NewPlatformVersionNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; -import org.thingsboard.server.common.data.notification.rule.trigger.NewPlatformVersionTrigger; @Service @RequiredArgsConstructor diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/NotificationRuleTriggerProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/NotificationRuleTriggerProcessor.java index 95f362f647..06d6b6d283 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/NotificationRuleTriggerProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/NotificationRuleTriggerProcessor.java @@ -16,9 +16,9 @@ package org.thingsboard.server.service.notification.rule.trigger; import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; +import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; -import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger; public interface NotificationRuleTriggerProcessor { diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/RateLimitsTriggerProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/RateLimitsTriggerProcessor.java index ca1478b9bb..0b3ce26991 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/RateLimitsTriggerProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/RateLimitsTriggerProcessor.java @@ -21,10 +21,10 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.info.RateLimitsNotificationInfo; import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; +import org.thingsboard.server.common.data.notification.rule.trigger.RateLimitsTrigger; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; import org.thingsboard.server.common.data.notification.rule.trigger.config.RateLimitsNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.util.CollectionsUtil; -import org.thingsboard.server.common.data.notification.rule.trigger.RateLimitsTrigger; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.tenant.TenantService; diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/RuleEngineComponentLifecycleEventTriggerProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/RuleEngineComponentLifecycleEventTriggerProcessor.java index a009841897..bc7b716b46 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/RuleEngineComponentLifecycleEventTriggerProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/RuleEngineComponentLifecycleEventTriggerProcessor.java @@ -23,10 +23,10 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.notification.info.RuleEngineComponentLifecycleEventNotificationInfo; import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; +import org.thingsboard.server.common.data.notification.rule.trigger.RuleEngineComponentLifecycleEventTrigger; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; import org.thingsboard.server.common.data.notification.rule.trigger.config.RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; -import org.thingsboard.server.common.data.notification.rule.trigger.RuleEngineComponentLifecycleEventTrigger; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.queue.discovery.PartitionService; diff --git a/application/src/main/java/org/thingsboard/server/service/ota/DefaultOtaPackageStateService.java b/application/src/main/java/org/thingsboard/server/service/ota/DefaultOtaPackageStateService.java index 7cb9a0fc0d..54ca38ad78 100644 --- a/application/src/main/java/org/thingsboard/server/service/ota/DefaultOtaPackageStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/ota/DefaultOtaPackageStateService.java @@ -16,13 +16,13 @@ package org.thingsboard.server.service.ota; import com.google.common.util.concurrent.FutureCallback; +import jakarta.annotation.Nullable; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; -import org.thingsboard.server.common.data.AttributeScope; -import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg; import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; @@ -43,6 +43,7 @@ import org.thingsboard.server.common.data.ota.OtaPackageUtil; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg; import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.ota.OtaPackageService; @@ -52,7 +53,6 @@ import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.provider.TbCoreQueueFactory; import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; -import jakarta.annotation.Nullable; import java.util.ArrayList; import java.util.Collections; import java.util.List; diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java index 2759ab7774..25c13f5a57 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java @@ -62,7 +62,6 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.executors.DbCallbackExecutorService; import org.thingsboard.server.service.security.AccessValidator; import org.thingsboard.server.service.security.model.SecurityUser; -import org.thingsboard.server.service.subscription.TbAttributeSubscriptionScope; import java.util.ArrayList; import java.util.Collection; diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java index bce5dece3b..70a39f0851 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java @@ -22,17 +22,21 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EdgeUtils; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.HasRuleEngineProfile; +import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.AssetId; @@ -43,6 +47,7 @@ import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; @@ -165,7 +170,7 @@ public class DefaultTbClusterService implements TbClusterService { @Override public void pushMsgToVersionControl(TenantId tenantId, TransportProtos.ToVersionControlServiceMsg msg, TbQueueCallback callback) { - TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_VC_EXECUTOR, tenantId, tenantId); + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_VC_EXECUTOR, TenantId.SYS_TENANT_ID, tenantId); log.trace("PUSHING msg: {} to:{}", msg, tpi); producerProvider.getTbVersionControlMsgProducer().send(tpi, new TbProtoQueueMsg<>(tenantId.getId(), msg), callback); //TODO: ashvayka @@ -186,6 +191,14 @@ public class DefaultTbClusterService implements TbClusterService { toCoreNfs.incrementAndGet(); } + @Override + public void pushNotificationToCore(String targetServiceId, TransportProtos.RestApiCallResponseMsgProto responseMsgProto, TbQueueCallback callback) { + TopicPartitionInfo tpi = topicService.getNotificationsTopic(ServiceType.TB_CORE, targetServiceId); + ToCoreNotificationMsg msg = ToCoreNotificationMsg.newBuilder().setRestApiCallResponseMsg(responseMsgProto).build(); + producerProvider.getTbCoreNotificationsMsgProducer().send(tpi, new TbProtoQueueMsg<>(UUID.randomUUID(), msg), callback); + toCoreNfs.incrementAndGet(); + } + @Override public void pushMsgToRuleEngine(TopicPartitionInfo tpi, UUID msgId, ToRuleEngineMsg msg, TbQueueCallback callback) { log.trace("PUSHING msg: {} to:{}", msg, tpi); @@ -195,6 +208,11 @@ public class DefaultTbClusterService implements TbClusterService { @Override public void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, TbMsg tbMsg, TbQueueCallback callback) { + pushMsgToRuleEngine(tenantId, entityId, tbMsg, false, callback); + } + + @Override + public void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, TbMsg tbMsg, boolean useQueueFromTbMsg, TbQueueCallback callback) { if (tenantId == null || tenantId.isNullUid()) { if (entityId.getEntityType().equals(EntityType.TENANT)) { tenantId = TenantId.fromUUID(entityId.getId()); @@ -203,31 +221,56 @@ public class DefaultTbClusterService implements TbClusterService { return; } } else { - HasRuleEngineProfile ruleEngineProfile = getRuleEngineProfileForEntityOrElseNull(tenantId, entityId); - tbMsg = transformMsg(tbMsg, ruleEngineProfile); + HasRuleEngineProfile ruleEngineProfile = getRuleEngineProfileForEntityOrElseNull(tenantId, entityId, tbMsg); + tbMsg = transformMsg(tbMsg, ruleEngineProfile, useQueueFromTbMsg); } - ruleEngineProducerService.sendToRuleEngine(producerProvider.getRuleEngineMsgProducer(), tenantId, tbMsg, callback); toRuleEngineMsgs.incrementAndGet(); } - private HasRuleEngineProfile getRuleEngineProfileForEntityOrElseNull(TenantId tenantId, EntityId entityId) { + HasRuleEngineProfile getRuleEngineProfileForEntityOrElseNull(TenantId tenantId, EntityId entityId, TbMsg tbMsg) { if (entityId.getEntityType().equals(EntityType.DEVICE)) { - return deviceProfileCache.get(tenantId, new DeviceId(entityId.getId())); + if (TbMsgType.ENTITY_DELETED.equals(tbMsg.getInternalType())) { + try { + Device deletedDevice = JacksonUtil.fromString(tbMsg.getData(), Device.class); + if (deletedDevice == null) { + return null; + } + return deviceProfileCache.get(tenantId, deletedDevice.getDeviceProfileId()); + } catch (Exception e) { + log.warn("[{}][{}] Failed to deserialize device: {}", tenantId, entityId, tbMsg, e); + return null; + } + } else { + return deviceProfileCache.get(tenantId, new DeviceId(entityId.getId())); + } } else if (entityId.getEntityType().equals(EntityType.DEVICE_PROFILE)) { return deviceProfileCache.get(tenantId, new DeviceProfileId(entityId.getId())); } else if (entityId.getEntityType().equals(EntityType.ASSET)) { - return assetProfileCache.get(tenantId, new AssetId(entityId.getId())); + if (TbMsgType.ENTITY_DELETED.equals(tbMsg.getInternalType())) { + try { + Asset deletedAsset = JacksonUtil.fromString(tbMsg.getData(), Asset.class); + if (deletedAsset == null) { + return null; + } + return assetProfileCache.get(tenantId, deletedAsset.getAssetProfileId()); + } catch (Exception e) { + log.warn("[{}][{}] Failed to deserialize asset: {}", tenantId, entityId, tbMsg, e); + return null; + } + } else { + return assetProfileCache.get(tenantId, new AssetId(entityId.getId())); + } } else if (entityId.getEntityType().equals(EntityType.ASSET_PROFILE)) { return assetProfileCache.get(tenantId, new AssetProfileId(entityId.getId())); } return null; } - private TbMsg transformMsg(TbMsg tbMsg, HasRuleEngineProfile ruleEngineProfile) { + private TbMsg transformMsg(TbMsg tbMsg, HasRuleEngineProfile ruleEngineProfile, boolean useQueueFromTbMsg) { if (ruleEngineProfile != null) { RuleChainId targetRuleChainId = ruleEngineProfile.getDefaultRuleChainId(); - String targetQueueName = ruleEngineProfile.getDefaultQueueName(); + String targetQueueName = useQueueFromTbMsg ? tbMsg.getQueueName() : ruleEngineProfile.getDefaultQueueName(); boolean isRuleChainTransform = targetRuleChainId != null && !targetRuleChainId.equals(tbMsg.getRuleChainId()); boolean isQueueTransform = targetQueueName != null && !targetQueueName.equals(tbMsg.getQueueName()); @@ -340,29 +383,33 @@ public class DefaultTbClusterService implements TbClusterService { @Override public void onResourceChange(TbResourceInfo resource, TbQueueCallback callback) { - TenantId tenantId = resource.getTenantId(); - log.trace("[{}][{}][{}] Processing change resource", tenantId, resource.getResourceType(), resource.getResourceKey()); - TransportProtos.ResourceUpdateMsg resourceUpdateMsg = TransportProtos.ResourceUpdateMsg.newBuilder() - .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) - .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) - .setResourceType(resource.getResourceType().name()) - .setResourceKey(resource.getResourceKey()) - .build(); - ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceUpdateMsg(resourceUpdateMsg).build(); - broadcast(transportMsg, callback); + if (resource.getResourceType() == ResourceType.LWM2M_MODEL) { + TenantId tenantId = resource.getTenantId(); + log.trace("[{}][{}][{}] Processing change resource", tenantId, resource.getResourceType(), resource.getResourceKey()); + TransportProtos.ResourceUpdateMsg resourceUpdateMsg = TransportProtos.ResourceUpdateMsg.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setResourceType(resource.getResourceType().name()) + .setResourceKey(resource.getResourceKey()) + .build(); + ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceUpdateMsg(resourceUpdateMsg).build(); + broadcast(transportMsg, DataConstants.LWM2M_TRANSPORT_NAME, callback); + } } @Override public void onResourceDeleted(TbResourceInfo resource, TbQueueCallback callback) { - log.trace("[{}] Processing delete resource", resource); - TransportProtos.ResourceDeleteMsg resourceUpdateMsg = TransportProtos.ResourceDeleteMsg.newBuilder() - .setTenantIdMSB(resource.getTenantId().getId().getMostSignificantBits()) - .setTenantIdLSB(resource.getTenantId().getId().getLeastSignificantBits()) - .setResourceType(resource.getResourceType().name()) - .setResourceKey(resource.getResourceKey()) - .build(); - ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceDeleteMsg(resourceUpdateMsg).build(); - broadcast(transportMsg, callback); + if (resource.getResourceType() == ResourceType.LWM2M_MODEL) { + log.trace("[{}][{}][{}] Processing delete resource", resource.getTenantId(), resource.getResourceType(), resource.getResourceKey()); + TransportProtos.ResourceDeleteMsg resourceDeleteMsg = TransportProtos.ResourceDeleteMsg.newBuilder() + .setTenantIdMSB(resource.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(resource.getTenantId().getId().getLeastSignificantBits()) + .setResourceType(resource.getResourceType().name()) + .setResourceKey(resource.getResourceKey()) + .build(); + ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceDeleteMsg(resourceDeleteMsg).build(); + broadcast(transportMsg, DataConstants.LWM2M_TRANSPORT_NAME, callback); + } } private void broadcastEntityChangeToTransport(TenantId tenantId, EntityId entityid, T entity, TbQueueCallback callback) { @@ -384,8 +431,19 @@ public class DefaultTbClusterService implements TbClusterService { } private void broadcast(ToTransportMsg transportMsg, TbQueueCallback callback) { - TbQueueProducer> toTransportNfProducer = producerProvider.getTransportNotificationsMsgProducer(); Set tbTransportServices = partitionService.getAllServiceIds(ServiceType.TB_TRANSPORT); + broadcast(transportMsg, tbTransportServices, callback); + } + + private void broadcast(ToTransportMsg transportMsg, String transportType, TbQueueCallback callback) { + Set tbTransportServices = partitionService.getAllServices(ServiceType.TB_TRANSPORT).stream() + .filter(info -> info.getTransportsList().contains(transportType)) + .map(TransportProtos.ServiceInfo::getServiceId).collect(Collectors.toSet()); + broadcast(transportMsg, tbTransportServices, callback); + } + + private void broadcast(ToTransportMsg transportMsg, Set tbTransportServices, TbQueueCallback callback) { + TbQueueProducer> toTransportNfProducer = producerProvider.getTransportNotificationsMsgProducer(); TbQueueCallback proxyCallback = callback != null ? new MultipleTbQueueCallbackWrapper(tbTransportServices.size(), callback) : null; for (String transportServiceId : tbTransportServices) { TopicPartitionInfo tpi = topicService.getNotificationsTopic(ServiceType.TB_TRANSPORT, transportServiceId); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index ae8ea8f7f4..1dc42218fb 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -96,6 +96,7 @@ import org.thingsboard.server.service.queue.processing.AbstractConsumerService; import org.thingsboard.server.service.queue.processing.IdMsgPair; import org.thingsboard.server.service.resource.TbImageService; import org.thingsboard.server.service.rpc.TbCoreDeviceRpcService; +import org.thingsboard.server.service.ruleengine.RuleEngineCallService; import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; import org.thingsboard.server.service.state.DeviceStateService; import org.thingsboard.server.service.subscription.SubscriptionManagerService; @@ -149,6 +150,7 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService, CoreQueueConfig> mainConsumer; @@ -177,7 +179,8 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService> requests = new ConcurrentHashMap<>(); + + public DefaultRuleEngineCallService(TbClusterService clusterService) { + this.clusterService = clusterService; + } + + @PostConstruct + public void initExecutor() { + executor = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("re-rest-callback")); + } + + @PreDestroy + public void shutdownExecutor() { + if (executor != null) { + executor.shutdownNow(); + } + } + + @Override + public void processRestApiCallToRuleEngine(TenantId tenantId, UUID requestId, TbMsg request, boolean useQueueFromTbMsg, Consumer responseConsumer) { + log.trace("[{}] Processing REST API call to rule engine: [{}] for entity: [{}]", tenantId, requestId, request.getOriginator()); + requests.put(requestId, responseConsumer); + sendRequestToRuleEngine(tenantId, request, useQueueFromTbMsg); + scheduleTimeout(request, requestId, requests); + } + + @Override + public void onQueueMsg(TransportProtos.RestApiCallResponseMsgProto restApiCallResponseMsg, TbCallback callback) { + UUID requestId = new UUID(restApiCallResponseMsg.getRequestIdMSB(), restApiCallResponseMsg.getRequestIdLSB()); + Consumer consumer = requests.remove(requestId); + if (consumer != null) { + consumer.accept(TbMsg.fromBytes(null, restApiCallResponseMsg.getResponse().toByteArray(), TbMsgCallback.EMPTY)); + } else { + log.trace("[{}] Unknown or stale rest api call response received", requestId); + } + callback.onSuccess(); + } + + private void sendRequestToRuleEngine(TenantId tenantId, TbMsg msg, boolean useQueueFromTbMsg) { + clusterService.pushMsgToRuleEngine(tenantId, msg.getOriginator(), msg, useQueueFromTbMsg, null); + } + + private void scheduleTimeout(TbMsg request, UUID requestId, ConcurrentMap> requestsMap) { + long expirationTime = Long.parseLong(request.getMetaData().getValue("expirationTime")); + long timeout = Math.max(0, expirationTime - System.currentTimeMillis()); + log.trace("[{}] processing the request: [{}]", this.hashCode(), requestId); + executor.schedule(() -> { + Consumer consumer = requestsMap.remove(requestId); + if (consumer != null) { + log.trace("[{}] request timeout detected: [{}]", this.hashCode(), requestId); + consumer.accept(null); + } + }, timeout, TimeUnit.MILLISECONDS); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/ruleengine/RuleEngineCallService.java b/application/src/main/java/org/thingsboard/server/service/ruleengine/RuleEngineCallService.java new file mode 100644 index 0000000000..cdaa90398a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ruleengine/RuleEngineCallService.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.ruleengine; + +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.gen.transport.TransportProtos; + +import java.util.UUID; +import java.util.function.Consumer; + +public interface RuleEngineCallService { + + void processRestApiCallToRuleEngine(TenantId tenantId, UUID requestId, TbMsg request, boolean useQueueFromTbMsg, Consumer responseConsumer); + + void onQueueMsg(TransportProtos.RestApiCallResponseMsgProto restApiCallResponseMsg, TbCallback callback); +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java b/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java index ebbd29e589..683dfc30be 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java +++ b/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java @@ -19,6 +19,9 @@ import com.google.common.base.Function; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -81,9 +84,6 @@ import org.thingsboard.server.service.security.permission.AccessControlService; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; -import jakarta.annotation.Nullable; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.BiConsumer; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java index bc80eb404a..acd43dd719 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java @@ -15,6 +15,10 @@ */ package org.thingsboard.server.service.security.auth.jwt; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -27,10 +31,6 @@ import org.thingsboard.server.service.security.auth.JwtAuthenticationToken; import org.thingsboard.server.service.security.auth.jwt.extractor.TokenExtractor; import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenProcessingFilter.java index 9b6f1805c0..b0dce5ec44 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenProcessingFilter.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenProcessingFilter.java @@ -15,6 +15,10 @@ */ package org.thingsboard.server.service.security.auth.jwt; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationServiceException; @@ -30,10 +34,6 @@ import org.thingsboard.server.service.security.auth.RefreshAuthenticationToken; import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException; import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @Slf4j diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/SkipPathRequestMatcher.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/SkipPathRequestMatcher.java index eeb94e3a6f..e99be65041 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/SkipPathRequestMatcher.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/SkipPathRequestMatcher.java @@ -15,12 +15,12 @@ */ package org.thingsboard.server.service.security.auth.jwt; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.OrRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.Assert; -import jakarta.servlet.http.HttpServletRequest; import java.util.List; import java.util.stream.Collectors; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtHeaderTokenExtractor.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtHeaderTokenExtractor.java index 51024dfd60..3b191e0285 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtHeaderTokenExtractor.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtHeaderTokenExtractor.java @@ -15,13 +15,12 @@ */ package org.thingsboard.server.service.security.auth.jwt.extractor; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.config.ThingsboardSecurityConfiguration; -import jakarta.servlet.http.HttpServletRequest; - @Component(value="jwtHeaderTokenExtractor") public class JwtHeaderTokenExtractor implements TokenExtractor { public static final String HEADER_PREFIX = "Bearer "; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtQueryTokenExtractor.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtQueryTokenExtractor.java index f611e94b12..d631bd4f25 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtQueryTokenExtractor.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtQueryTokenExtractor.java @@ -15,13 +15,12 @@ */ package org.thingsboard.server.service.security.auth.jwt.extractor; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.config.ThingsboardSecurityConfiguration; -import jakarta.servlet.http.HttpServletRequest; - @Component(value="jwtQueryTokenExtractor") public class JwtQueryTokenExtractor implements TokenExtractor { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java index d4425372e1..ab7fc19202 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java @@ -20,19 +20,19 @@ import org.apache.commons.lang3.tuple.Pair; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.LockedException; import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.limit.LimitedApi; import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; import org.thingsboard.server.dao.user.UserService; -import org.thingsboard.server.common.data.limit.LimitedApi; -import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFaProvider; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java index 22d3b9b5ea..917b0f4f82 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java @@ -42,7 +42,6 @@ import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; -import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.oauth2.OAuth2User; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TenantService; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java index 26935ad0ee..23a60f45b6 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.security.auth.oauth2; import com.fasterxml.jackson.databind.JsonNode; +import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.stereotype.Service; @@ -29,7 +30,6 @@ import org.thingsboard.server.dao.oauth2.OAuth2User; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.model.SecurityUser; -import jakarta.servlet.http.HttpServletRequest; import java.util.HashMap; import java.util.Map; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/BasicOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/BasicOAuth2ClientMapper.java index 693dfa5434..02fb9c6258 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/BasicOAuth2ClientMapper.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/BasicOAuth2ClientMapper.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.security.auth.oauth2; +import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.stereotype.Service; @@ -24,7 +25,6 @@ import org.thingsboard.server.dao.oauth2.OAuth2User; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.model.SecurityUser; -import jakarta.servlet.http.HttpServletRequest; import java.util.Map; @Service(value = "basicOAuth2ClientMapper") diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtils.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtils.java index a974c562be..280c9f1149 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtils.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtils.java @@ -17,12 +17,12 @@ package org.thingsboard.server.service.security.auth.oauth2; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.jackson2.SecurityJackson2Modules; - import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.jackson2.SecurityJackson2Modules; + import java.io.IOException; import java.util.Arrays; import java.util.Base64; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java index ed1d5cfd6d..e6cfb5a32a 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.security.auth.oauth2; import com.fasterxml.jackson.core.JsonProcessingException; +import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; @@ -30,8 +31,6 @@ import org.thingsboard.server.dao.oauth2.OAuth2User; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.model.SecurityUser; -import jakarta.servlet.http.HttpServletRequest; - @Service(value = "customOAuth2ClientMapper") @Slf4j @TbCoreComponent diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java index 1b42946ad6..5619788a21 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.security.auth.oauth2; +import jakarta.servlet.http.HttpServletRequest; import lombok.Data; import lombok.ToString; import lombok.extern.slf4j.Slf4j; @@ -30,7 +31,6 @@ import org.thingsboard.server.dao.oauth2.OAuth2User; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.model.SecurityUser; -import jakarta.servlet.http.HttpServletRequest; import java.util.ArrayList; import java.util.Map; import java.util.Optional; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java index 7d697d5687..c0ba5de006 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java @@ -15,14 +15,13 @@ */ package org.thingsboard.server.service.security.auth.oauth2; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.stereotype.Component; import org.thingsboard.server.queue.util.TbCoreComponent; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - @Component @TbCoreComponent public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapper.java index cc877f36d4..a27ac410c1 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapper.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapper.java @@ -15,12 +15,11 @@ */ package org.thingsboard.server.service.security.auth.oauth2; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.thingsboard.server.common.data.oauth2.OAuth2Registration; import org.thingsboard.server.service.security.model.SecurityUser; -import jakarta.servlet.http.HttpServletRequest; - public interface OAuth2ClientMapper { SecurityUser getOrCreateUserByClientPrincipal(HttpServletRequest request, OAuth2AuthenticationToken token, String providerAccessToken, OAuth2Registration registration); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java index be4555a36f..f2ce5e1e05 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.service.security.auth.oauth2; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.AuthenticationException; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; @@ -27,9 +30,6 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.system.SystemSecurityService; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java index 09ebb75e29..4605819894 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.service.security.auth.oauth2; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; @@ -38,9 +41,6 @@ import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.system.SystemSecurityService; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java index f570718a03..4aa948e2fd 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java @@ -15,11 +15,11 @@ */ package org.thingsboard.server.service.security.auth.rest; +import jakarta.servlet.http.HttpServletRequest; import lombok.Data; import ua_parser.Client; import ua_parser.Parser; -import jakarta.servlet.http.HttpServletRequest; import java.io.Serializable; @Data diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetailsSource.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetailsSource.java index 077dea18df..d282e05daa 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetailsSource.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetailsSource.java @@ -15,9 +15,8 @@ */ package org.thingsboard.server.service.security.auth.rest; -import org.springframework.security.authentication.AuthenticationDetailsSource; - import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.authentication.AuthenticationDetailsSource; public class RestAuthenticationDetailsSource implements AuthenticationDetailsSource { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationFailureHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationFailureHandler.java index 045ae44015..088a497c19 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationFailureHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationFailureHandler.java @@ -15,15 +15,15 @@ */ package org.thingsboard.server.service.security.auth.rest; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.stereotype.Component; import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @Component(value = "defaultAuthenticationFailureHandler") diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java index b05c3d7292..5dbcac0f84 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java @@ -15,6 +15,10 @@ */ package org.thingsboard.server.service.security.auth.rest; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -30,10 +34,6 @@ import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManage import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; import java.io.IOException; import java.util.Optional; import java.util.concurrent.TimeUnit; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java index e97fb02b1d..2b561cbf11 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java @@ -15,6 +15,10 @@ */ package org.thingsboard.server.service.security.auth.rest; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationDetailsSource; @@ -31,10 +35,6 @@ import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException; import org.thingsboard.server.service.security.model.UserPrincipal; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @Slf4j diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestPublicLoginProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestPublicLoginProcessingFilter.java index ffe17a1372..eda82e6571 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestPublicLoginProcessingFilter.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestPublicLoginProcessingFilter.java @@ -15,6 +15,10 @@ */ package org.thingsboard.server.service.security.auth.rest; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationServiceException; @@ -30,10 +34,6 @@ import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException; import org.thingsboard.server.service.security.model.UserPrincipal; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @Slf4j diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java index af1b34ae71..46cd1c2979 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java @@ -19,8 +19,6 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.DashboardInfo; import org.thingsboard.server.common.data.HasCustomerId; import org.thingsboard.server.common.data.HasTenantId; -import org.thingsboard.server.common.data.ResourceType; -import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.id.DashboardId; diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java index b12e6d4581..1b8cec2c91 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java @@ -18,6 +18,8 @@ package org.thingsboard.server.service.security.system; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.passay.CharacterRule; import org.passay.EnglishCharacterData; @@ -61,8 +63,6 @@ import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.utils.MiscUtils; import ua_parser.Client; -import jakarta.annotation.Resource; -import jakarta.servlet.http.HttpServletRequest; import java.util.ArrayList; import java.util.List; import java.util.Map; diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java index 06e5cece81..737bf95b13 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.security.system; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.core.AuthenticationException; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; @@ -27,8 +28,6 @@ import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettin import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.service.security.model.SecurityUser; -import jakarta.servlet.http.HttpServletRequest; - public interface SystemSecurityService { SecuritySettings getSecuritySettings(); diff --git a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java index aaac542a76..bf5a8c53b9 100644 --- a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java +++ b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java @@ -16,6 +16,8 @@ package org.thingsboard.server.service.sms; import com.fasterxml.jackson.databind.JsonNode; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.core.NestedRuntimeException; import org.springframework.stereotype.Service; @@ -35,9 +37,6 @@ import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; - @Service @Slf4j public class DefaultSmsService implements SmsService { diff --git a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java index be370ec562..1c53920a93 100644 --- a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java @@ -23,6 +23,10 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Setter; @@ -81,10 +85,6 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.partition.AbstractPartitionBasedService; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; -import jakarta.annotation.Nonnull; -import jakarta.annotation.Nullable; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; diff --git a/application/src/main/java/org/thingsboard/server/service/stats/DefaultJsInvokeStats.java b/application/src/main/java/org/thingsboard/server/service/stats/DefaultJsInvokeStats.java index 3799dfda3d..3fc389b3d7 100644 --- a/application/src/main/java/org/thingsboard/server/service/stats/DefaultJsInvokeStats.java +++ b/application/src/main/java/org/thingsboard/server/service/stats/DefaultJsInvokeStats.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.stats; +import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.thingsboard.server.actors.JsInvokeStats; @@ -22,8 +23,6 @@ import org.thingsboard.server.common.stats.StatsCounter; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.common.stats.StatsType; -import jakarta.annotation.PostConstruct; - @Service public class DefaultJsInvokeStats implements JsInvokeStats { private static final String REQUESTS = "requests"; diff --git a/application/src/main/java/org/thingsboard/server/service/stats/DefaultRuleEngineStatisticsService.java b/application/src/main/java/org/thingsboard/server/service/stats/DefaultRuleEngineStatisticsService.java index 03747d3062..e0c78e0afa 100644 --- a/application/src/main/java/org/thingsboard/server/service/stats/DefaultRuleEngineStatisticsService.java +++ b/application/src/main/java/org/thingsboard/server/service/stats/DefaultRuleEngineStatisticsService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.stats; import com.google.common.util.concurrent.FutureCallback; +import jakarta.annotation.Nullable; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,7 +37,6 @@ import org.thingsboard.server.queue.util.TbRuleEngineComponent; import org.thingsboard.server.service.queue.TbRuleEngineConsumerStats; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; -import jakarta.annotation.Nullable; import java.util.Collections; import java.util.List; import java.util.concurrent.ConcurrentHashMap; diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java index 66b73781a0..b18cf7992c 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java @@ -15,12 +15,12 @@ */ package org.thingsboard.server.service.subscription; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.server.cluster.TbClusterService; -import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.alarm.AlarmInfo; @@ -54,7 +54,6 @@ import org.thingsboard.server.service.state.DeviceStateService; import org.thingsboard.server.service.ws.notification.sub.NotificationUpdate; import org.thingsboard.server.service.ws.notification.sub.NotificationsSubscriptionUpdate; -import jakarta.annotation.PostConstruct; import java.util.ArrayList; import java.util.List; import java.util.Map; diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java index 984af019b4..632ea44940 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java @@ -19,6 +19,8 @@ import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.ArrayUtils; @@ -42,6 +44,7 @@ import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.TsValue; +import org.thingsboard.server.common.msg.tools.TbRateLimitsException; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.entity.EntityService; @@ -66,8 +69,6 @@ import org.thingsboard.server.service.ws.telemetry.cmd.v2.LatestValueCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.TimeSeriesCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.UnsubscribeCmd; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -355,6 +356,9 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc private void handleWsCmdRuntimeException(String sessionId, RuntimeException e, EntityDataCmd cmd) { log.debug("[{}] Failed to process ws cmd: {}", sessionId, cmd, e); + if (e instanceof TbRateLimitsException) { + return; + } wsService.close(sessionId, CloseStatus.SERVICE_RESTARTED); } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java index 9367439d4f..8fdb18cd1d 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java @@ -18,12 +18,15 @@ package org.thingsboard.server.service.subscription; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; +import org.thingsboard.common.util.DeduplicationUtil; import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.EntityType; @@ -36,10 +39,12 @@ import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.limit.LimitedApi; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.msg.tools.TbRateLimitsException; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.gen.transport.TransportProtos; @@ -48,6 +53,7 @@ import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import org.thingsboard.server.queue.discovery.event.ClusterTopologyChangeEvent; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.ws.WebSocketService; +import org.thingsboard.server.service.ws.WebSocketSessionRef; import org.thingsboard.server.service.ws.notification.sub.NotificationRequestUpdate; import org.thingsboard.server.service.ws.notification.sub.NotificationsSubscriptionUpdate; import org.thingsboard.server.service.ws.telemetry.sub.AlarmSubscriptionUpdate; @@ -88,13 +94,20 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer private final TbClusterService clusterService; private final SubscriptionManagerService subscriptionManagerService; private final WebSocketService webSocketService; + private final RateLimitService rateLimitService; private ExecutorService tsCallBackExecutor; private ScheduledExecutorService staleSessionCleanupExecutor; + @Value("${server.ws.rate_limits.subscriptions_per_tenant:2000:60}") + private String subscriptionsPerTenantRateLimit; + @Value("${server.ws.rate_limits.subscriptions_per_user:500:60}") + private String subscriptionsPerUserRateLimit; + public DefaultTbLocalSubscriptionService(AttributesService attrService, TimeseriesService tsService, TbServiceInfoProvider serviceInfoProvider, PartitionService partitionService, TbClusterService clusterService, - @Lazy SubscriptionManagerService subscriptionManagerService, @Lazy WebSocketService webSocketService) { + @Lazy SubscriptionManagerService subscriptionManagerService, @Lazy WebSocketService webSocketService, + RateLimitService rateLimitService) { this.attrService = attrService; this.tsService = tsService; this.serviceInfoProvider = serviceInfoProvider; @@ -102,6 +115,7 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer this.clusterService = clusterService; this.subscriptionManagerService = subscriptionManagerService; this.webSocketService = webSocketService; + this.rateLimitService = rateLimitService; } private String serviceId; @@ -185,9 +199,18 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer } @Override - public void addSubscription(TbSubscription subscription) { + public void addSubscription(TbSubscription subscription, WebSocketSessionRef sessionRef) { TenantId tenantId = subscription.getTenantId(); EntityId entityId = subscription.getEntityId(); + if (!rateLimitService.checkRateLimit(LimitedApi.WS_SUBSCRIPTIONS, (Object) tenantId, subscriptionsPerTenantRateLimit)) { + handleRateLimitError(subscription, sessionRef, "Exceeded rate limit for WS subscriptions per tenant"); + return; + } + if (sessionRef.getSecurityCtx() != null && !rateLimitService.checkRateLimit(LimitedApi.WS_SUBSCRIPTIONS, sessionRef.getSecurityCtx().getId(), subscriptionsPerUserRateLimit)) { + handleRateLimitError(subscription, sessionRef, "Exceeded rate limit for WS subscriptions per user"); + return; + } + log.debug("[{}][{}] Register subscription: {}", tenantId, entityId, subscription); SubscriptionModificationResult result; subsLock.lock(); @@ -584,4 +607,13 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer subscriptionsBySessionId.keySet().forEach(webSocketService::cleanupIfStale); } + private void handleRateLimitError(TbSubscription subscription, WebSocketSessionRef sessionRef, String message) { + String deduplicationKey = sessionRef.getSessionId() + message; + if (!DeduplicationUtil.alreadyProcessed(deduplicationKey, TimeUnit.SECONDS.toMillis(15))) { + log.info("{} {}", sessionRef, message); + webSocketService.sendError(sessionRef, subscription.getSubscriptionId(), SubscriptionErrorCode.BAD_REQUEST, message); + } + throw new TbRateLimitsException(message); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java index 9f7b108bd0..bfe22137a7 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java @@ -16,7 +16,6 @@ package org.thingsboard.server.service.subscription; import org.springframework.context.ApplicationListener; -import org.springframework.context.event.EventListener; import org.thingsboard.server.common.data.alarm.AlarmInfo; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; @@ -24,10 +23,8 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.msg.queue.TbCallback; -import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.discovery.event.OtherServiceShutdownEvent; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; -import org.thingsboard.server.service.ws.notification.sub.NotificationRequestUpdate; import org.thingsboard.server.service.ws.notification.sub.NotificationUpdate; import java.util.List; diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractDataSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractDataSubCtx.java index f65eec8453..77ef960d72 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractDataSubCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractDataSubCtx.java @@ -132,7 +132,7 @@ public abstract class TbAbstractDataSubCtx> keysByType = getEntityKeyByTypeMap(keys); for (EntityData entityData : data.getData()) { List entitySubscriptions = addSubscriptions(entityData, keysByType, latestValues, startTs, endTs); - entitySubscriptions.forEach(localSubscriptionService::addSubscription); + entitySubscriptions.forEach(subscription -> localSubscriptionService.addSubscription(subscription, sessionRef)); } } @@ -254,4 +254,5 @@ public abstract class TbAbstractDataSubCtx { .scope(TbAttributeSubscriptionScope.SERVER_SCOPE) .build(); subToDynamicValueKeySet.add(subIdx); - localSubscriptionService.addSubscription(sub); + localSubscriptionService.addSubscription(sub, sessionRef); } } catch (InterruptedException | ExecutionException e) { log.info("[{}][{}][{}] Failed to resolve dynamic values: {}", tenantId, customerId, userId, dynamicValues.keySet()); diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java index 0d1f2f94b2..828da388fb 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java @@ -177,7 +177,7 @@ public class TbAlarmDataSubCtx extends TbAbstractDataSubCtx { .updateProcessor((sub, update) -> sendWsMsg(sub.getSessionId(), update)) .ts(startTs) .build(); - localSubscriptionService.addSubscription(subscription); + localSubscriptionService.addSubscription(subscription, sessionRef); } @Override @@ -342,7 +342,7 @@ public class TbAlarmDataSubCtx extends TbAbstractDataSubCtx { newSubsList.forEach(entity -> createAlarmSubscriptionForEntity(query.getPageLink(), startTs, entity)); } subIdsToCancel.forEach(subId -> localSubscriptionService.cancelSubscription(getSessionId(), subId)); - subsToAdd.forEach(localSubscriptionService::addSubscription); + subsToAdd.forEach(subscription -> localSubscriptionService.addSubscription(subscription, sessionRef)); } private void resetInvocationCounter() { @@ -361,4 +361,5 @@ public class TbAlarmDataSubCtx extends TbAbstractDataSubCtx { EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtx.java index 98ec81b798..48458ca6fd 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtx.java @@ -226,7 +226,7 @@ public class TbEntityDataSubCtx extends TbAbstractDataSubCtx { } } subIdsToCancel.forEach(subId -> localSubscriptionService.cancelSubscription(getSessionId(), subId)); - subsToAdd.forEach(localSubscriptionService::addSubscription); + subsToAdd.forEach(subscription -> localSubscriptionService.addSubscription(subscription, sessionRef)); sendWsMsg(new EntityDataUpdate(cmdId, data, null, maxEntitiesPerDataSubscription)); } @@ -239,4 +239,5 @@ public class TbEntityDataSubCtx extends TbAbstractDataSubCtx { protected EntityDataQuery buildEntityDataQuery() { return query; } + } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityRemoteSubsInfo.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityRemoteSubsInfo.java index 255f0d9a92..b77f990edb 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityRemoteSubsInfo.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityRemoteSubsInfo.java @@ -18,7 +18,6 @@ package org.thingsboard.server.service.subscription; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.util.Pair; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbEntitySubEvent.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbEntitySubEvent.java index 02924710fc..708c5494f2 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbEntitySubEvent.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbEntitySubEvent.java @@ -21,8 +21,6 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; -import java.util.Set; - /** * Information about the local websocket subscriptions. */ diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbLocalSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbLocalSubscriptionService.java index ddb2a1b590..59e7ad532d 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbLocalSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbLocalSubscriptionService.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.discovery.event.ClusterTopologyChangeEvent; +import org.thingsboard.server.service.ws.WebSocketSessionRef; import org.thingsboard.server.service.ws.notification.sub.NotificationRequestUpdate; import org.thingsboard.server.service.ws.notification.sub.NotificationsSubscriptionUpdate; @@ -29,7 +30,7 @@ import java.util.List; public interface TbLocalSubscriptionService { - void addSubscription(TbSubscription subscription); + void addSubscription(TbSubscription subscription, WebSocketSessionRef sessionRef); void onSubEventCallback(TransportProtos.TbEntitySubEventCallbackProto subEventCallback, TbCallback callback); diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscription.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscription.java index d1822a6ffe..4324a3514a 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscription.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscription.java @@ -21,7 +21,6 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import java.util.Objects; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; @Data @@ -52,4 +51,5 @@ public abstract class TbSubscription { public int hashCode() { return Objects.hash(sessionId, subscriptionId, tenantId, entityId, type); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionsInfo.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionsInfo.java index aa8ea70eab..48464d5297 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionsInfo.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionsInfo.java @@ -20,9 +20,7 @@ import lombok.EqualsAndHashCode; import lombok.RequiredArgsConstructor; import lombok.ToString; -import java.util.HashSet; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; /** * Information about the local websocket subscriptions. diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java index 6df897c179..a9b278a19d 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java @@ -19,6 +19,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ExportableEntity; import org.thingsboard.server.common.data.audit.ActionType; @@ -32,7 +33,6 @@ import org.thingsboard.server.common.data.sync.ie.EntityImportResult; import org.thingsboard.server.common.data.util.ThrowingRunnable; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.TbLogEntityActionService; import org.thingsboard.server.service.sync.ie.exporting.EntityExportService; diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/BaseEntityExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/BaseEntityExportService.java index 881492f8e1..4c753ffffa 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/BaseEntityExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/BaseEntityExportService.java @@ -26,9 +26,9 @@ import org.thingsboard.server.service.sync.vc.data.EntitiesExportCtx; import java.util.Collection; import java.util.Set; -import java.util.regex.Pattern; import java.util.UUID; import java.util.function.Function; +import java.util.regex.Pattern; import java.util.stream.Stream; public abstract class BaseEntityExportService, D extends EntityExportData> extends DefaultEntityExportService { diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java index 77996a8b7f..26c0771c20 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java @@ -22,6 +22,7 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; @@ -74,6 +75,9 @@ public class DefaultEntityExportService deleteVersionControlSettings(TenantId tenantId) throws Exception { - if (repositorySettingsService.delete(tenantId)) { - return gitServiceQueue.clearRepository(tenantId); - } else { - return Futures.immediateFuture(null); - } + public ListenableFuture deleteVersionControlSettings(TenantId tenantId) { + return gitServiceQueue.clearRepository(tenantId); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitVersionControlQueueService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitVersionControlQueueService.java index f08d7ddd3b..7dac07edf0 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitVersionControlQueueService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitVersionControlQueueService.java @@ -21,6 +21,7 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; @@ -311,7 +312,13 @@ public class DefaultGitVersionControlQueueService implements GitVersionControlQu } return submitFuture; } else { - throw new RuntimeException("Future is already done!"); + try { + request.getFuture().get(); + throw new RuntimeException("Failed to process the request"); + } catch (Exception e) { + Throwable cause = ExceptionUtils.getRootCause(e); + throw new RuntimeException(cause.getMessage(), cause); + } } } @@ -562,5 +569,6 @@ public class DefaultGitVersionControlQueueService implements GitVersionControlQu private CommitRequestMsg.Builder buildCommitRequest(CommitGitRequest commit) { return CommitRequestMsg.newBuilder().setTxId(commit.getTxId().toString()); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java index 91d2a3be4a..ddda0c0bcf 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java @@ -65,7 +65,7 @@ public interface EntitiesVersionControlService { ListenableFuture saveVersionControlSettings(TenantId tenantId, RepositorySettings versionControlSettings); - ListenableFuture deleteVersionControlSettings(TenantId tenantId) throws Exception; + ListenableFuture deleteVersionControlSettings(TenantId tenantId); ListenableFuture checkVersionControlAccess(TenantId tenantId, RepositorySettings settings) throws Exception; diff --git a/application/src/main/java/org/thingsboard/server/service/system/DefaultSystemInfoService.java b/application/src/main/java/org/thingsboard/server/service/system/DefaultSystemInfoService.java index cd95e9504d..55319f1218 100644 --- a/application/src/main/java/org/thingsboard/server/service/system/DefaultSystemInfoService.java +++ b/application/src/main/java/org/thingsboard/server/service/system/DefaultSystemInfoService.java @@ -17,6 +17,8 @@ package org.thingsboard.server.service.system; import com.google.common.util.concurrent.FutureCallback; import com.google.protobuf.ProtocolStringList; +import jakarta.annotation.Nullable; +import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -49,8 +51,6 @@ import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; -import jakarta.annotation.Nullable; -import jakarta.annotation.PreDestroy; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java index 6624f43095..846c32b7fb 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java @@ -18,6 +18,9 @@ package org.thingsboard.server.service.telemetry; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.ThingsBoardThreadFactory; @@ -32,9 +35,6 @@ import org.thingsboard.server.queue.discovery.TbApplicationEventListener; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.service.subscription.SubscriptionManagerService; -import jakarta.annotation.Nullable; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index b19fde2983..5513c41929 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -20,6 +20,9 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.SettableFuture; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; @@ -50,9 +53,6 @@ import org.thingsboard.server.service.apiusage.TbApiUsageStateService; import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService; import org.thingsboard.server.service.subscription.TbSubscriptionUtils; -import jakarta.annotation.Nullable; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -260,14 +260,14 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer @Override public void saveAndNotifyInternal(TenantId tenantId, EntityId entityId, String scope, List attributes, boolean notifyDevice, FutureCallback callback) { - ListenableFuture> saveFuture = attrService.save(tenantId, entityId, scope, attributes); + ListenableFuture> saveFuture = attrService.save(tenantId, entityId, scope, attributes); addVoidCallback(saveFuture, callback); addWsCallback(saveFuture, success -> onAttributesUpdate(tenantId, entityId, scope, attributes, notifyDevice)); } @Override public void saveAndNotifyInternal(TenantId tenantId, EntityId entityId, AttributeScope scope, List attributes, boolean notifyDevice, FutureCallback callback) { - ListenableFuture> saveFuture = attrService.save(tenantId, entityId, scope, attributes); + ListenableFuture> saveFuture = attrService.save(tenantId, entityId, scope, attributes); addVoidCallback(saveFuture, callback); addWsCallback(saveFuture, success -> onAttributesUpdate(tenantId, entityId, scope.name(), attributes, notifyDevice)); } @@ -280,7 +280,7 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer @Override public void saveLatestAndNotifyInternal(TenantId tenantId, EntityId entityId, List ts, FutureCallback callback) { - ListenableFuture> saveFuture = tsService.saveLatest(tenantId, entityId, ts); + ListenableFuture> saveFuture = tsService.saveLatest(tenantId, entityId, ts); addVoidCallback(saveFuture, callback); addWsCallback(saveFuture, success -> onTimeSeriesUpdate(tenantId, entityId, ts)); } diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index f5bd42e29d..8b29af34ed 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -18,7 +18,6 @@ package org.thingsboard.server.service.transport; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; @@ -101,13 +100,11 @@ import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509Ce import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; -import org.thingsboard.server.service.executors.DbCallbackExecutorService; import org.thingsboard.server.service.profile.TbDeviceProfileCache; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.util.List; -import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; @@ -136,7 +133,6 @@ public class DefaultTransportApiService implements TransportApiService { private final DeviceProfileService deviceProfileService; private final RelationService relationService; private final DeviceCredentialsService deviceCredentialsService; - private final DbCallbackExecutorService dbCallbackExecutorService; private final TbClusterService tbClusterService; private final DeviceProvisionService deviceProvisionService; private final ResourceService resourceService; @@ -170,52 +166,54 @@ public class DefaultTransportApiService implements TransportApiService { @Override public ListenableFuture> handle(TbProtoQueueMsg tbProtoQueueMsg) { TransportApiRequestMsg transportApiRequestMsg = tbProtoQueueMsg.getValue(); - ListenableFuture result = null; + return handlerExecutor.submit(() -> { + TransportApiResponseMsg result = handle(transportApiRequestMsg); + return new TbProtoQueueMsg<>(tbProtoQueueMsg.getKey(), result, tbProtoQueueMsg.getHeaders()); + }); + } + private TransportApiResponseMsg handle(TransportApiRequestMsg transportApiRequestMsg) { if (transportApiRequestMsg.hasValidateTokenRequestMsg()) { ValidateDeviceTokenRequestMsg msg = transportApiRequestMsg.getValidateTokenRequestMsg(); final String token = msg.getToken(); - result = handlerExecutor.submit(() -> validateCredentials(token, DeviceCredentialsType.ACCESS_TOKEN)); + return validateCredentials(token, DeviceCredentialsType.ACCESS_TOKEN); } else if (transportApiRequestMsg.hasValidateBasicMqttCredRequestMsg()) { TransportProtos.ValidateBasicMqttCredRequestMsg msg = transportApiRequestMsg.getValidateBasicMqttCredRequestMsg(); - result = handlerExecutor.submit(() -> validateCredentials(msg)); + return validateCredentials(msg); } else if (transportApiRequestMsg.hasValidateX509CertRequestMsg()) { ValidateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateX509CertRequestMsg(); final String hash = msg.getHash(); - result = handlerExecutor.submit(() -> validateCredentials(hash, DeviceCredentialsType.X509_CERTIFICATE)); + return validateCredentials(hash, DeviceCredentialsType.X509_CERTIFICATE); } else if (transportApiRequestMsg.hasValidateOrCreateX509CertRequestMsg()) { TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateOrCreateX509CertRequestMsg(); final String certChain = msg.getCertificateChain(); - result = handlerExecutor.submit(() -> validateOrCreateDeviceX509Certificate(certChain)); + return validateOrCreateDeviceX509Certificate(certChain); } else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) { - result = handlerExecutor.submit(() -> handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg())); + return handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()); } else if (transportApiRequestMsg.hasEntityProfileRequestMsg()) { - result = handle(transportApiRequestMsg.getEntityProfileRequestMsg()); + return handle(transportApiRequestMsg.getEntityProfileRequestMsg()); } else if (transportApiRequestMsg.hasLwM2MRequestMsg()) { - result = handle(transportApiRequestMsg.getLwM2MRequestMsg()); + return handle(transportApiRequestMsg.getLwM2MRequestMsg()); } else if (transportApiRequestMsg.hasValidateDeviceLwM2MCredentialsRequestMsg()) { ValidateDeviceLwM2MCredentialsRequestMsg msg = transportApiRequestMsg.getValidateDeviceLwM2MCredentialsRequestMsg(); final String credentialsId = msg.getCredentialsId(); - result = handlerExecutor.submit(() -> validateCredentials(credentialsId, DeviceCredentialsType.LWM2M_CREDENTIALS)); + return validateCredentials(credentialsId, DeviceCredentialsType.LWM2M_CREDENTIALS); } else if (transportApiRequestMsg.hasProvisionDeviceRequestMsg()) { - result = handle(transportApiRequestMsg.getProvisionDeviceRequestMsg()); + return handle(transportApiRequestMsg.getProvisionDeviceRequestMsg()); } else if (transportApiRequestMsg.hasResourceRequestMsg()) { - result = handle(transportApiRequestMsg.getResourceRequestMsg()); + return handle(transportApiRequestMsg.getResourceRequestMsg()); } else if (transportApiRequestMsg.hasSnmpDevicesRequestMsg()) { - result = handle(transportApiRequestMsg.getSnmpDevicesRequestMsg()); + return handle(transportApiRequestMsg.getSnmpDevicesRequestMsg()); } else if (transportApiRequestMsg.hasDeviceRequestMsg()) { - result = handle(transportApiRequestMsg.getDeviceRequestMsg()); + return handle(transportApiRequestMsg.getDeviceRequestMsg()); } else if (transportApiRequestMsg.hasDeviceCredentialsRequestMsg()) { - result = handle(transportApiRequestMsg.getDeviceCredentialsRequestMsg()); + return handle(transportApiRequestMsg.getDeviceCredentialsRequestMsg()); } else if (transportApiRequestMsg.hasOtaPackageRequestMsg()) { - result = handle(transportApiRequestMsg.getOtaPackageRequestMsg()); + return handle(transportApiRequestMsg.getOtaPackageRequestMsg()); } else if (transportApiRequestMsg.hasGetAllQueueRoutingInfoRequestMsg()) { - return Futures.transform(handle(transportApiRequestMsg.getGetAllQueueRoutingInfoRequestMsg()), value -> new TbProtoQueueMsg<>(tbProtoQueueMsg.getKey(), value, tbProtoQueueMsg.getHeaders()), MoreExecutors.directExecutor()); + return handle(transportApiRequestMsg.getGetAllQueueRoutingInfoRequestMsg()); } - - return Futures.transform(Optional.ofNullable(result).orElseGet(this::getEmptyTransportApiResponseFuture), - value -> new TbProtoQueueMsg<>(tbProtoQueueMsg.getKey(), value, tbProtoQueueMsg.getHeaders()), - MoreExecutors.directExecutor()); + return getEmptyTransportApiResponse(); } private TransportApiResponseMsg validateCredentials(String credentialsId, DeviceCredentialsType credentialsType) { @@ -405,10 +403,10 @@ public class DefaultTransportApiService implements TransportApiService { } } - private ListenableFuture handle(ProvisionDeviceRequestMsg requestMsg) { - ListenableFuture provisionResponseFuture; + private TransportApiResponseMsg handle(ProvisionDeviceRequestMsg requestMsg) { + ProvisionResponse provisionResponse; try { - provisionResponseFuture = Futures.immediateFuture(deviceProvisionService.provisionDevice( + provisionResponse = deviceProvisionService.provisionDevice( new ProvisionRequest( requestMsg.getDeviceName(), requestMsg.getCredentialsType() != null ? DeviceCredentialsType.valueOf(requestMsg.getCredentialsType().name()) : null, @@ -419,18 +417,14 @@ public class DefaultTransportApiService implements TransportApiService { requestMsg.getCredentialsDataProto().getValidateDeviceX509CertRequestMsg().getHash()), new ProvisionDeviceProfileCredentials( requestMsg.getProvisionDeviceCredentialsMsg().getProvisionDeviceKey(), - requestMsg.getProvisionDeviceCredentialsMsg().getProvisionDeviceSecret())))); + requestMsg.getProvisionDeviceCredentialsMsg().getProvisionDeviceSecret()))); } catch (ProvisionFailedException e) { - return Futures.immediateFuture(getTransportApiResponseMsg( - new DeviceCredentials(), - TransportProtos.ResponseStatus.valueOf(e.getMessage()))); + return getTransportApiResponseMsg(new DeviceCredentials(), TransportProtos.ResponseStatus.valueOf(e.getMessage())); } - return Futures.transform(provisionResponseFuture, provisionResponse -> getTransportApiResponseMsg(provisionResponse.getDeviceCredentials(), TransportProtos.ResponseStatus.SUCCESS), - dbCallbackExecutorService); + return getTransportApiResponseMsg(provisionResponse.getDeviceCredentials(), TransportProtos.ResponseStatus.SUCCESS); } - private TransportApiResponseMsg getTransportApiResponseMsg(DeviceCredentials - deviceCredentials, TransportProtos.ResponseStatus status) { + private TransportApiResponseMsg getTransportApiResponseMsg(DeviceCredentials deviceCredentials, TransportProtos.ResponseStatus status) { if (!status.equals(TransportProtos.ResponseStatus.SUCCESS)) { return TransportApiResponseMsg.newBuilder().setProvisionDeviceResponseMsg(TransportProtos.ProvisionDeviceResponseMsg.newBuilder().setStatus(status).build()).build(); } @@ -453,7 +447,7 @@ public class DefaultTransportApiService implements TransportApiService { .build(); } - private ListenableFuture handle(GetEntityProfileRequestMsg requestMsg) { + private TransportApiResponseMsg handle(GetEntityProfileRequestMsg requestMsg) { EntityType entityType = EntityType.valueOf(requestMsg.getEntityType()); UUID entityUuid = new UUID(requestMsg.getEntityIdMSB(), requestMsg.getEntityIdLSB()); GetEntityProfileResponseMsg.Builder builder = GetEntityProfileResponseMsg.newBuilder(); @@ -470,10 +464,10 @@ public class DefaultTransportApiService implements TransportApiService { } else { throw new RuntimeException("Invalid entity profile request: " + entityType); } - return Futures.immediateFuture(TransportApiResponseMsg.newBuilder().setEntityProfileResponseMsg(builder).build()); + return TransportApiResponseMsg.newBuilder().setEntityProfileResponseMsg(builder).build(); } - private ListenableFuture handle(GetDeviceRequestMsg requestMsg) { + private TransportApiResponseMsg handle(GetDeviceRequestMsg requestMsg) { DeviceId deviceId = new DeviceId(new UUID(requestMsg.getDeviceIdMSB(), requestMsg.getDeviceIdLSB())); Device device = deviceService.findDeviceById(TenantId.SYS_TENANT_ID, deviceId); @@ -491,21 +485,20 @@ public class DefaultTransportApiService implements TransportApiService { } else { responseMsg = TransportApiResponseMsg.getDefaultInstance(); } - - return Futures.immediateFuture(responseMsg); + return responseMsg; } - private ListenableFuture handle(GetDeviceCredentialsRequestMsg requestMsg) { + private TransportApiResponseMsg handle(GetDeviceCredentialsRequestMsg requestMsg) { DeviceId deviceId = new DeviceId(new UUID(requestMsg.getDeviceIdMSB(), requestMsg.getDeviceIdLSB())); DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(TenantId.SYS_TENANT_ID, deviceId); - return Futures.immediateFuture(TransportApiResponseMsg.newBuilder() + return TransportApiResponseMsg.newBuilder() .setDeviceCredentialsResponseMsg(TransportProtos.GetDeviceCredentialsResponseMsg.newBuilder() .setDeviceCredentialsData(ProtoUtils.toProto(deviceCredentials))) - .build()); + .build(); } - private ListenableFuture handle(GetResourceRequestMsg requestMsg) { + private TransportApiResponseMsg handle(GetResourceRequestMsg requestMsg) { TenantId tenantId = TenantId.fromUUID(new UUID(requestMsg.getTenantIdMSB(), requestMsg.getTenantIdLSB())); ResourceType resourceType = ResourceType.valueOf(requestMsg.getResourceType()); String resourceKey = requestMsg.getResourceKey(); @@ -520,10 +513,10 @@ public class DefaultTransportApiService implements TransportApiService { builder.setResource(ProtoUtils.toProto(resource)); } - return Futures.immediateFuture(TransportApiResponseMsg.newBuilder().setResourceResponseMsg(builder).build()); + return TransportApiResponseMsg.newBuilder().setResourceResponseMsg(builder).build(); } - private ListenableFuture handle(GetSnmpDevicesRequestMsg requestMsg) { + private TransportApiResponseMsg handle(GetSnmpDevicesRequestMsg requestMsg) { PageLink pageLink = new PageLink(requestMsg.getPageSize(), requestMsg.getPage()); PageData result = deviceService.findDevicesIdsByDeviceProfileTransportType(DeviceTransportType.SNMP, pageLink); @@ -534,9 +527,9 @@ public class DefaultTransportApiService implements TransportApiService { .setHasNextPage(result.hasNext()) .build(); - return Futures.immediateFuture(TransportApiResponseMsg.newBuilder() + return TransportApiResponseMsg.newBuilder() .setSnmpDevicesResponseMsg(responseMsg) - .build()); + .build(); } TransportApiResponseMsg getDeviceInfo(DeviceCredentials credentials) { @@ -565,31 +558,26 @@ public class DefaultTransportApiService implements TransportApiService { } } - private ListenableFuture getEmptyTransportApiResponseFuture() { - return Futures.immediateFuture(getEmptyTransportApiResponse()); - } - private TransportApiResponseMsg getEmptyTransportApiResponse() { return TransportApiResponseMsg.newBuilder() .setValidateCredResponseMsg(ValidateDeviceCredentialsResponseMsg.getDefaultInstance()).build(); } - private ListenableFuture handle(TransportProtos.LwM2MRequestMsg requestMsg) { + private TransportApiResponseMsg handle(TransportProtos.LwM2MRequestMsg requestMsg) { if (requestMsg.hasRegistrationMsg()) { return handleRegistration(requestMsg.getRegistrationMsg()); } else { - return Futures.immediateFailedFuture(new RuntimeException("Not supported!")); + throw new RuntimeException("Not supported!"); } } - private ListenableFuture handle(TransportProtos.GetOtaPackageRequestMsg requestMsg) { + private TransportApiResponseMsg handle(TransportProtos.GetOtaPackageRequestMsg requestMsg) { TenantId tenantId = TenantId.fromUUID(new UUID(requestMsg.getTenantIdMSB(), requestMsg.getTenantIdLSB())); DeviceId deviceId = new DeviceId(new UUID(requestMsg.getDeviceIdMSB(), requestMsg.getDeviceIdLSB())); OtaPackageType otaPackageType = OtaPackageType.valueOf(requestMsg.getType()); Device device = deviceService.findDeviceById(tenantId, deviceId); - if (device == null) { - return getEmptyTransportApiResponseFuture(); + return getEmptyTransportApiResponse(); } OtaPackageId otaPackageId = OtaPackageUtil.getOtaPackageId(device, otaPackageType); @@ -626,14 +614,12 @@ public class DefaultTransportApiService implements TransportApiService { } } - return Futures.immediateFuture( - TransportApiResponseMsg.newBuilder() - .setOtaPackageResponseMsg(builder.build()) - .build()); + return TransportApiResponseMsg.newBuilder() + .setOtaPackageResponseMsg(builder.build()) + .build(); } - private ListenableFuture handleRegistration - (TransportProtos.LwM2MRegistrationRequestMsg msg) { + private TransportApiResponseMsg handleRegistration(TransportProtos.LwM2MRegistrationRequestMsg msg) { TenantId tenantId = TenantId.fromUUID(UUID.fromString(msg.getTenantId())); String deviceName = msg.getEndpoint(); Lock deviceCreationLock = deviceCreationLocks.computeIfAbsent(deviceName, id -> new ReentrantLock()); @@ -651,21 +637,18 @@ public class DefaultTransportApiService implements TransportApiService { TransportProtos.LwM2MRegistrationResponseMsg.newBuilder() .setDeviceInfo(ProtoUtils.toDeviceInfoProto(device)).build(); TransportProtos.LwM2MResponseMsg responseMsg = TransportProtos.LwM2MResponseMsg.newBuilder().setRegistrationMsg(registrationResponseMsg).build(); - return Futures.immediateFuture(TransportApiResponseMsg.newBuilder().setLwM2MResponseMsg(responseMsg).build()); + return TransportApiResponseMsg.newBuilder().setLwM2MResponseMsg(responseMsg).build(); } catch (JsonProcessingException e) { - log.warn("[{}][{}] Failed to lookup device by gateway id and name", tenantId, deviceName, e); + log.warn("[{}][{}] Failed to lookup device by name", tenantId, deviceName, e); throw new RuntimeException(e); } finally { deviceCreationLock.unlock(); } } - private ListenableFuture handle(TransportProtos.GetAllQueueRoutingInfoRequestMsg requestMsg) { - return queuesToTransportApiResponseMsg(queueService.findAllQueues()); - } - - private ListenableFuture queuesToTransportApiResponseMsg(List queues) { - return Futures.immediateFuture(TransportApiResponseMsg.newBuilder() + private TransportApiResponseMsg handle(TransportProtos.GetAllQueueRoutingInfoRequestMsg requestMsg) { + List queues = queueService.findAllQueues(); + return TransportApiResponseMsg.newBuilder() .addAllGetQueueRoutingInfoResponseMsgs(queues.stream() .map(queue -> TransportProtos.GetQueueRoutingInfoResponseMsg.newBuilder() .setTenantIdMSB(queue.getTenantId().getId().getMostSignificantBits()) @@ -676,7 +659,7 @@ public class DefaultTransportApiService implements TransportApiService { .setQueueTopic(queue.getTopic()) .setPartitions(queue.getPartitions()) .setDuplicateMsgToAllPartitions(queue.isDuplicateMsgToAllPartitions()) - .build()).collect(Collectors.toList())).build()); + .build()).collect(Collectors.toList())).build(); } private ProvisionRequest createProvisionRequest(String certificateValue) { diff --git a/application/src/main/java/org/thingsboard/server/service/transport/TbCoreTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/TbCoreTransportApiService.java index 15679986e6..b38fbf4fec 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/TbCoreTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/TbCoreTransportApiService.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.service.transport; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.event.ApplicationReadyEvent; @@ -34,8 +36,6 @@ import org.thingsboard.server.queue.provider.TbCoreQueueFactory; import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.queue.util.TbCoreComponent; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.concurrent.ExecutorService; /** diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/AlarmsCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/AlarmsCleanUpService.java index c0f3158c61..dd80eba6bc 100644 --- a/application/src/main/java/org/thingsboard/server/service/ttl/AlarmsCleanUpService.java +++ b/application/src/main/java/org/thingsboard/server/service/ttl/AlarmsCleanUpService.java @@ -31,7 +31,6 @@ import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileCon import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.dao.alarm.AlarmDao; import org.thingsboard.server.dao.alarm.AlarmService; -import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.queue.discovery.PartitionService; diff --git a/application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java b/application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java index a0695c226f..b2d919e136 100644 --- a/application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java +++ b/application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.update; import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -36,7 +37,6 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.instructions.EdgeInstallInstructionsService; import org.thingsboard.server.service.edge.instructions.EdgeUpgradeInstructionsService; -import jakarta.annotation.PreDestroy; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; diff --git a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java index d7091bbad2..33b011cc0a 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java @@ -21,6 +21,9 @@ import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -46,6 +49,7 @@ import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.msg.tools.TbRateLimitsException; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.timeseries.TimeseriesService; @@ -80,10 +84,6 @@ import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityDataUpdate; import org.thingsboard.server.service.ws.telemetry.cmd.v2.UnsubscribeCmd; import org.thingsboard.server.service.ws.telemetry.sub.TelemetrySubscriptionUpdate; -import jakarta.annotation.Nullable; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; - import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -224,9 +224,10 @@ public class DefaultWebSocketService implements WebSocketService { try { Optional.ofNullable(cmdsHandlers.get(cmd.getType())) .ifPresent(cmdHandler -> cmdHandler.handle(sessionRef, cmd)); + } catch (TbRateLimitsException e) { + log.debug("{} Failed to handle WS cmd: {}", sessionRef, cmd, e); } catch (Exception e) { - log.error("[sessionId: {}, tenantId: {}, userId: {}] Failed to handle WS cmd: {}", sessionId, - sessionRef.getSecurityCtx().getTenantId(), sessionRef.getSecurityCtx().getId(), cmd, e); + log.error("{} Failed to handle WS cmd: {}", sessionRef, cmd, e); } } } @@ -468,7 +469,7 @@ public class DefaultWebSocketService implements WebSocketService { subLock.lock(); try { - oldSubService.addSubscription(sub); + oldSubService.addSubscription(sub, sessionRef); sendUpdate(sessionRef, new TelemetrySubscriptionUpdate(cmd.getCmdId(), attributesData)); } finally { subLock.unlock(); @@ -581,7 +582,7 @@ public class DefaultWebSocketService implements WebSocketService { subLock.lock(); try { - oldSubService.addSubscription(sub); + oldSubService.addSubscription(sub, sessionRef); sendUpdate(sessionRef, new TelemetrySubscriptionUpdate(cmd.getCmdId(), attributesData)); } finally { subLock.unlock(); @@ -678,7 +679,7 @@ public class DefaultWebSocketService implements WebSocketService { subLock.lock(); try { - oldSubService.addSubscription(sub); + oldSubService.addSubscription(sub, sessionRef); sendUpdate(sessionRef, new TelemetrySubscriptionUpdate(cmd.getCmdId(), data)); } finally { subLock.unlock(); @@ -733,7 +734,7 @@ public class DefaultWebSocketService implements WebSocketService { subLock.lock(); try { - oldSubService.addSubscription(sub); + oldSubService.addSubscription(sub, sessionRef); sendUpdate(sessionRef, new TelemetrySubscriptionUpdate(cmd.getCmdId(), data)); } finally { subLock.unlock(); diff --git a/application/src/main/java/org/thingsboard/server/service/ws/notification/DefaultNotificationCommandsHandler.java b/application/src/main/java/org/thingsboard/server/service/ws/notification/DefaultNotificationCommandsHandler.java index 6fa9111c3d..285c58a0f5 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/notification/DefaultNotificationCommandsHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/notification/DefaultNotificationCommandsHandler.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.id.NotificationId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.NotificationStatus; +import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.dao.notification.NotificationService; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; @@ -78,8 +79,9 @@ public class DefaultNotificationCommandsHandler implements NotificationCommandsH .entityId(securityCtx.getId()) .updateProcessor(this::handleNotificationsSubscriptionUpdate) .limit(cmd.getLimit()) + .notificationTypes(cmd.getTypes()) .build(); - localSubscriptionService.addSubscription(subscription); + localSubscriptionService.addSubscription(subscription, sessionRef); fetchUnreadNotifications(subscription); sendUpdate(sessionRef.getSessionId(), subscription.createFullUpdate()); @@ -97,7 +99,7 @@ public class DefaultNotificationCommandsHandler implements NotificationCommandsH .entityId(securityCtx.getId()) .updateProcessor(this::handleNotificationsCountSubscriptionUpdate) .build(); - localSubscriptionService.addSubscription(subscription); + localSubscriptionService.addSubscription(subscription, sessionRef); fetchUnreadNotificationsCount(subscription); sendUpdate(sessionRef.getSessionId(), subscription.createUpdate()); @@ -105,8 +107,8 @@ public class DefaultNotificationCommandsHandler implements NotificationCommandsH private void fetchUnreadNotifications(NotificationsSubscription subscription) { log.trace("[{}, subId: {}] Fetching unread notifications from DB", subscription.getSessionId(), subscription.getSubscriptionId()); - PageData notifications = notificationService.findLatestUnreadNotificationsByRecipientId(subscription.getTenantId(), - WEB, (UserId) subscription.getEntityId(), subscription.getLimit()); + PageData notifications = notificationService.findLatestUnreadNotificationsByRecipientIdAndNotificationTypes(subscription.getTenantId(), + WEB, (UserId) subscription.getEntityId(), subscription.getNotificationTypes(), subscription.getLimit()); subscription.getLatestUnreadNotifications().clear(); notifications.getData().forEach(notification -> { subscription.getLatestUnreadNotifications().put(notification.getUuidId(), notification); @@ -139,6 +141,11 @@ public class DefaultNotificationCommandsHandler implements NotificationCommandsH log.trace("[{}, subId: {}] Handling notification update: {}", subscription.getSessionId(), subscription.getSubscriptionId(), update); Notification notification = update.getNotification(); UUID notificationId = notification != null ? notification.getUuidId() : update.getNotificationId(); + NotificationType notificationType = notification != null ? notification.getType() : update.getNotificationType(); + if (notificationType != null && !subscription.checkNotificationType(notificationType)) { + return; + } + if (update.isCreated()) { subscription.getLatestUnreadNotifications().put(notificationId, notification); subscription.getTotalUnreadCounter().incrementAndGet(); diff --git a/application/src/main/java/org/thingsboard/server/service/ws/notification/cmd/NotificationsSubCmd.java b/application/src/main/java/org/thingsboard/server/service/ws/notification/cmd/NotificationsSubCmd.java index 20f429b9f3..66a4a3dd30 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/notification/cmd/NotificationsSubCmd.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/notification/cmd/NotificationsSubCmd.java @@ -18,15 +18,19 @@ package org.thingsboard.server.service.ws.notification.cmd; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.service.ws.WsCmd; import org.thingsboard.server.service.ws.WsCmdType; +import java.util.Set; + @Data @NoArgsConstructor @AllArgsConstructor public class NotificationsSubCmd implements WsCmd { private int cmdId; private int limit; + private Set types; @Override public WsCmdType getType() { diff --git a/application/src/main/java/org/thingsboard/server/service/ws/notification/cmd/UnreadNotificationsUpdate.java b/application/src/main/java/org/thingsboard/server/service/ws/notification/cmd/UnreadNotificationsUpdate.java index 9349f2f8dd..eae88a0191 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/notification/cmd/UnreadNotificationsUpdate.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/notification/cmd/UnreadNotificationsUpdate.java @@ -24,13 +24,13 @@ import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.service.ws.telemetry.cmd.v2.CmdUpdate; import org.thingsboard.server.service.ws.telemetry.cmd.v2.CmdUpdateType; -import java.util.Collection; +import java.util.List; @Getter @ToString(exclude = "notifications") public class UnreadNotificationsUpdate extends CmdUpdate { - private final Collection notifications; + private final List notifications; private final Notification update; private final int totalUnreadCount; private final int sequenceNumber; @@ -39,7 +39,7 @@ public class UnreadNotificationsUpdate extends CmdUpdate { @JsonCreator public UnreadNotificationsUpdate(@JsonProperty("cmdId") int cmdId, @JsonProperty("errorCode") int errorCode, @JsonProperty("errorMsg") String errorMsg, - @JsonProperty("notifications") Collection notifications, + @JsonProperty("notifications") List notifications, @JsonProperty("update") Notification update, @JsonProperty("totalUnreadCount") int totalUnreadCount, @JsonProperty("sequenceNumber") int sequenceNumber) { diff --git a/application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationUpdate.java b/application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationUpdate.java index efbdd07c5b..b6835ca4ea 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationUpdate.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationUpdate.java @@ -21,6 +21,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.NotificationStatus; +import org.thingsboard.server.common.data.notification.NotificationType; import java.util.UUID; @@ -31,6 +32,7 @@ import java.util.UUID; public class NotificationUpdate { private UUID notificationId; + private NotificationType notificationType; private boolean created; private Notification notification; diff --git a/application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationsCountSubscription.java b/application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationsCountSubscription.java index 1e812c3b31..171416a63d 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationsCountSubscription.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationsCountSubscription.java @@ -23,7 +23,6 @@ import org.thingsboard.server.service.subscription.TbSubscription; import org.thingsboard.server.service.subscription.TbSubscriptionType; import org.thingsboard.server.service.ws.notification.cmd.UnreadNotificationsCountUpdate; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; @Getter diff --git a/application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationsSubscription.java b/application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationsSubscription.java index 97dc15f6b0..58ced7c4bf 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationsSubscription.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationsSubscription.java @@ -17,10 +17,12 @@ package org.thingsboard.server.service.ws.notification.sub; import lombok.Builder; import lombok.Getter; +import org.apache.commons.collections4.CollectionUtils; import org.thingsboard.server.common.data.BaseData; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.Notification; +import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.service.subscription.TbSubscription; import org.thingsboard.server.service.subscription.TbSubscriptionType; import org.thingsboard.server.service.ws.notification.cmd.UnreadNotificationsUpdate; @@ -29,8 +31,8 @@ import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.stream.Collectors; @@ -39,13 +41,19 @@ public class NotificationsSubscription extends AbstractNotificationSubscription< private final Map latestUnreadNotifications = new HashMap<>(); private final int limit; + private final Set notificationTypes; @Builder public NotificationsSubscription(String serviceId, String sessionId, int subscriptionId, TenantId tenantId, EntityId entityId, BiConsumer, NotificationsSubscriptionUpdate> updateProcessor, - int limit) { + int limit, Set notificationTypes) { super(serviceId, sessionId, subscriptionId, tenantId, entityId, TbSubscriptionType.NOTIFICATIONS, updateProcessor); this.limit = limit; + this.notificationTypes = notificationTypes; + } + + public boolean checkNotificationType(NotificationType type) { + return CollectionUtils.isEmpty(notificationTypes) || notificationTypes.contains(type); } public UnreadNotificationsUpdate createFullUpdate() { diff --git a/application/src/main/java/org/thingsboard/server/service/ws/telemetry/cmd/v1/SubscriptionCmd.java b/application/src/main/java/org/thingsboard/server/service/ws/telemetry/cmd/v1/SubscriptionCmd.java index 8b09b547b2..7807c3541f 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/telemetry/cmd/v1/SubscriptionCmd.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/telemetry/cmd/v1/SubscriptionCmd.java @@ -18,7 +18,6 @@ package org.thingsboard.server.service.ws.telemetry.cmd.v1; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import org.thingsboard.server.service.ws.telemetry.TelemetryFeature; @NoArgsConstructor @AllArgsConstructor diff --git a/application/src/main/java/org/thingsboard/server/utils/LwM2mObjectModelUtils.java b/application/src/main/java/org/thingsboard/server/utils/LwM2mObjectModelUtils.java index 95b9254b66..3faf09d993 100644 --- a/application/src/main/java/org/thingsboard/server/utils/LwM2mObjectModelUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/LwM2mObjectModelUtils.java @@ -31,7 +31,6 @@ import org.thingsboard.server.dao.exception.DataValidationException; import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.ArrayList; -import java.util.Base64; import java.util.List; import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPARATOR_KEY; diff --git a/application/src/main/java/org/thingsboard/server/utils/MiscUtils.java b/application/src/main/java/org/thingsboard/server/utils/MiscUtils.java index 9d324bc206..7e6a1322b3 100644 --- a/application/src/main/java/org/thingsboard/server/utils/MiscUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/MiscUtils.java @@ -17,8 +17,8 @@ package org.thingsboard.server.utils; import com.google.common.hash.HashFunction; import com.google.common.hash.Hashing; - import jakarta.servlet.http.HttpServletRequest; + import java.nio.charset.Charset; diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 4e5458bd18..a0a8ea0c2f 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -20,8 +20,8 @@ server: address: "${HTTP_BIND_ADDRESS:0.0.0.0}" # Server bind port port: "${HTTP_BIND_PORT:8080}" - # Server forward headers strategy - forward_headers_strategy: "${HTTP_FORWARD_HEADERS_STRATEGY:NONE}" + # Server forward headers strategy. Required for SWAGGER UI when reverse proxy is used + forward_headers_strategy: "${HTTP_FORWARD_HEADERS_STRATEGY:framework}" # Server SSL configuration ssl: # Enable/disable SSL support @@ -78,6 +78,11 @@ server: max_queue_messages_per_session: "${TB_SERVER_WS_DEFAULT_QUEUE_MESSAGES_PER_SESSION:1000}" # Maximum time between WS session opening and sending auth command auth_timeout_ms: "${TB_SERVER_WS_AUTH_TIMEOUT_MS:10000}" + rate_limits: + # Per-tenant rate limit for WS subscriptions + subscriptions_per_tenant: "${TB_SERVER_WS_SUBSCRIPTIONS_PER_TENANT_RATE_LIMIT:2000:60}" + # Per-user rate limit for WS subscriptions + subscriptions_per_user: "${TB_SERVER_WS_SUBSCRIPTIONS_PER_USER_RATE_LIMIT:500:60}" rest: server_side_rpc: # Minimum value of the server-side RPC timeout. May override value provided in the REST API call. @@ -485,12 +490,16 @@ actors: # Cache settings parameters cache: - # caffeine or redis + # caffeine or redis(7.2 - latest compatible version) type: "${CACHE_TYPE:caffeine}" maximumPoolSize: "${CACHE_MAXIMUM_POOL_SIZE:16}" # max pool size to process futures that call the external cache attributes: # make sure that if cache.type is 'redis' and cache.attributes.enabled is 'true' if you change 'maxmemory-policy' Redis config property to 'allkeys-lru', 'allkeys-lfu' or 'allkeys-random' enabled: "${CACHE_ATTRIBUTES_ENABLED:true}" + ts_latest: + # Will enable cache-aside strategy for SQL timeseries latest DAO. + # make sure that if cache.type is 'redis' and cache.ts_latest.enabled is 'true' if you change 'maxmemory-policy' Redis config property to 'allkeys-lru', 'allkeys-lfu' or 'allkeys-random' + enabled: "${CACHE_TS_LATEST_ENABLED:true}" specs: relations: timeToLiveInMinutes: "${CACHE_SPECS_RELATIONS_TTL:1440}" # Relations cache TTL @@ -547,6 +556,9 @@ cache: attributes: timeToLiveInMinutes: "${CACHE_SPECS_ATTRIBUTES_TTL:1440}" # Attributes cache TTL maxSize: "${CACHE_SPECS_ATTRIBUTES_MAX_SIZE:100000}" # 0 means the cache is disabled + tsLatest: + timeToLiveInMinutes: "${CACHE_SPECS_TS_LATEST_TTL:1440}" # Timeseries latest cache TTL + maxSize: "${CACHE_SPECS_TS_LATEST_MAX_SIZE:100000}" # 0 means the cache is disabled userSessionsInvalidation: # The value of this TTL is ignored and replaced by the JWT refresh token expiration time timeToLiveInMinutes: "0" @@ -761,7 +773,28 @@ spring: leakDetectionThreshold: "${SPRING_DATASOURCE_HIKARI_LEAK_DETECTION_THRESHOLD:0}" # This property increases the number of connections in the pool as demand increases. At the same time, the property ensures that the pool doesn't grow to the point of exhausting a system's resources, which ultimately affects an application's performance and availability maximumPoolSize: "${SPRING_DATASOURCE_MAXIMUM_POOL_SIZE:16}" - registerMbeans: "${SPRING_DATASOURCE_HIKARI_REGISTER_MBEANS:false}" # true - enable MBean to diagnose pools state via JMX + # Enable MBean to diagnose pools state via JMX + registerMbeans: "${SPRING_DATASOURCE_HIKARI_REGISTER_MBEANS:false}" + events: + # Enable dedicated datasource (a separate database) for events and audit logs. + # Before enabling this, make sure you have set up the following tables in the new DB: + # error_event, lc_event, rule_chain_debug_event, rule_node_debug_event, stats_event, audit_log + enabled: "${SPRING_DEDICATED_EVENTS_DATASOURCE_ENABLED:false}" + # Database driver for Spring JPA for events datasource + driverClassName: "${SPRING_EVENTS_DATASOURCE_DRIVER_CLASS_NAME:org.postgresql.Driver}" + # Database connection URL for events datasource + url: "${SPRING_EVENTS_DATASOURCE_URL:jdbc:postgresql://localhost:5432/thingsboard_events}" + # Database username for events datasource + username: "${SPRING_EVENTS_DATASOURCE_USERNAME:postgres}" + # Database user password for events datasource + password: "${SPRING_EVENTS_DATASOURCE_PASSWORD:postgres}" + hikari: + # This property controls the amount of time that a connection can be out of the pool before a message is logged indicating a possible connection leak for events datasource. A value of 0 means leak detection is disabled + leakDetectionThreshold: "${SPRING_EVENTS_DATASOURCE_HIKARI_LEAK_DETECTION_THRESHOLD:0}" + # This property increases the number of connections in the pool as demand increases for events datasource. At the same time, the property ensures that the pool doesn't grow to the point of exhausting a system's resources, which ultimately affects an application's performance and availability + maximumPoolSize: "${SPRING_EVENTS_DATASOURCE_MAXIMUM_POOL_SIZE:16}" + # Enable MBean to diagnose pools state via JMX for events datasource + registerMbeans: "${SPRING_EVENTS_DATASOURCE_HIKARI_REGISTER_MBEANS:false}" # Audit log parameters audit-log: @@ -959,6 +992,8 @@ transport: request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}" # HTTP maximum request processing timeout in milliseconds max_request_timeout: "${HTTP_MAX_REQUEST_TIMEOUT:300000}" + # Maximum request size + max_payload_size: "${HTTP_MAX_PAYLOAD_SIZE:65536}" # max payload size in bytes # Local MQTT transport parameters mqtt: # Enable/disable mqtt transport protocol. @@ -1385,6 +1420,8 @@ swagger: url: "${SWAGGER_LICENSE_URL:https://github.com/thingsboard/thingsboard/blob/master/LICENSE}" # The version of the API doc to display. Default to the package version. version: "${SWAGGER_VERSION:}" + # The group name (definition) on the API doc UI page. + group_name: "${SWAGGER_GROUP_NAME:thingsboard}" # Queue configuration parameters queue: diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java index 35c2ea65fa..d549407c44 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java @@ -386,6 +386,15 @@ public abstract class AbstractNotifyEntityTest extends AbstractWebTest { actionType, cntTime, extractMatcherAdditionalInfo(additionalInfo)); } + protected void testLogEntityActionError(EntityId originatorId, TenantId tenantId, + CustomerId customerId, UserId userId, String userName, + ActionType actionType, Exception exception, Object... additionalInfo) { + ArgumentMatcher matcherError = argument -> argument.getMessage().contains(exception.getMessage()) + & argument.getClass().equals(exception.getClass()); + testLogEntityActionErrorAdditionalInfo(Objects::isNull, originatorId, tenantId, customerId, userId, userName, + actionType, 1, matcherError, extractMatcherAdditionalInfo(additionalInfo)); + } + private void testLogEntityActionAdditionalInfo(ArgumentMatcher matcherEntity, ArgumentMatcher matcherOriginatorId, TenantId tenantId, ArgumentMatcher matcherCustomerId, ArgumentMatcher matcherUserId, String userName, ActionType actionType, @@ -529,8 +538,9 @@ public abstract class AbstractNotifyEntityTest extends AbstractWebTest { Mockito.argThat(matcherEntity), Mockito.eq(actionType), Mockito.argThat(matcherError), - Mockito.argThat(Mockito.eq(matcherAdditionalInfos.get(0))), - Mockito.argThat(Mockito.eq(matcherAdditionalInfos.get(1)))); + Mockito.argThat(matcherAdditionalInfos.get(0)), + Mockito.argThat(matcherAdditionalInfos.get(1))); + break; case 3: Mockito.verify(auditLogService, times(cntTime)) .logEntityAction(Mockito.eq(tenantId), @@ -541,9 +551,9 @@ public abstract class AbstractNotifyEntityTest extends AbstractWebTest { Mockito.argThat(matcherEntity), Mockito.eq(actionType), Mockito.argThat(matcherError), - Mockito.argThat(Mockito.eq(matcherAdditionalInfos.get(0))), - Mockito.argThat(Mockito.eq(matcherAdditionalInfos.get(1))), - Mockito.argThat(Mockito.eq(matcherAdditionalInfos.get(2)))); + Mockito.argThat(matcherAdditionalInfos.get(0)), + Mockito.argThat(matcherAdditionalInfos.get(1)), + Mockito.argThat(matcherAdditionalInfos.get(2))); break; default: Mockito.verify(auditLogService, times(cntTime)) diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index b83a1b567c..d0824bcd6d 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -212,6 +212,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected UserId differentCustomerUserId; protected UserId differentTenantCustomerUserId; + protected UserId currentUserId; @SuppressWarnings("rawtypes") private HttpMessageConverter mappingJackson2HttpMessageConverter; @@ -525,7 +526,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { JsonNode activateRequest = getActivateRequest(password); ResultActions resultActions = doPost("/api/noauth/activate", activateRequest); resultActions.andExpect(status().isOk()); - return savedUser; + return doGet("/api/user/" + savedUser.getId(), User.class); } private JsonNode getActivateRequest(String password) throws Exception { @@ -567,6 +568,8 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { Claims claims = jwsClaims.getPayload(); String subject = claims.getSubject(); Assert.assertEquals(username, subject); + String userId = claims.get("userId", String.class); + this.currentUserId = UserId.fromString(userId); } protected void resetTokens() throws Exception { @@ -633,6 +636,11 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return doPost("/api/device?accessToken=" + accessToken, device, Device.class); } + protected Device assignDeviceToCustomer(DeviceId deviceId, CustomerId customerId) { + String deviceIdStr = String.valueOf(deviceId.getId()); + return doPost("/api/customer/" + customerId.getId() + "/device/" + deviceIdStr, Device.class); + } + protected MqttDeviceProfileTransportConfiguration createMqttDeviceProfileTransportConfiguration(TransportPayloadTypeConfiguration transportPayloadTypeConfiguration, boolean sendAckOnValidationException) { MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = new MqttDeviceProfileTransportConfiguration(); mqttDeviceProfileTransportConfiguration.setDeviceTelemetryTopic(MqttTopics.DEVICE_TELEMETRY_TOPIC); @@ -803,7 +811,11 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return readResponse(doPost(urlTemplate, content, params).andExpect(resultMatcher), responseType); } - protected T doPostAsync(String urlTemplate, T content, Class responseClass, ResultMatcher resultMatcher, String... params) throws Exception { + protected R doPostAsyncWithTypedResponse(String urlTemplate, T content, TypeReference responseType, ResultMatcher resultMatcher, String... params) throws Exception { + return readResponse(doPostAsync(urlTemplate, content, DEFAULT_TIMEOUT, params).andExpect(resultMatcher), responseType); + } + + protected R doPostAsync(String urlTemplate, T content, Class responseClass, ResultMatcher resultMatcher, String... params) throws Exception { return readResponse(doPostAsync(urlTemplate, content, DEFAULT_TIMEOUT, params).andExpect(resultMatcher), responseClass); } diff --git a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java index 5005e6ad34..2386b91c6e 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java @@ -17,6 +17,7 @@ package org.thingsboard.server.controller; import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.core.type.TypeReference; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.junit.After; import org.junit.Assert; @@ -64,6 +65,7 @@ public class AuditLogControllerTest extends AbstractControllerTest { @Autowired private AuditLogDao auditLogDao; + @Getter @SpyBean private SqlPartitioningRepository partitioningRepository; @SpyBean @@ -161,7 +163,7 @@ public class AuditLogControllerTest extends AbstractControllerTest { Device savedDevice = doPost("/api/device", device, Device.class); for (int i = 0; i < 11; i++) { savedDevice.setName("Device name" + i); - doPost("/api/device", savedDevice, Device.class); + savedDevice = doPost("/api/device", savedDevice, Device.class); } List loadedAuditLogs = new ArrayList<>(); @@ -183,12 +185,12 @@ public class AuditLogControllerTest extends AbstractControllerTest { @Test public void whenSavingNewAuditLog_thenCheckAndCreatePartitionIfNotExists() throws ParseException { long entityTs = ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2024-01-01T01:43:11Z").getTime(); - reset(partitioningRepository); + reset(getPartitioningRepository()); AuditLog auditLog = createAuditLog(ActionType.LOGIN, tenantAdminUserId, entityTs); - verify(partitioningRepository).createPartitionIfNotExists(eq("audit_log"), eq(auditLog.getCreatedTime()), eq(partitionDurationInMs)); + verify(getPartitioningRepository()).createPartitionIfNotExists(eq("audit_log"), eq(auditLog.getCreatedTime()), eq(partitionDurationInMs)); - List partitions = partitioningRepository.fetchPartitions("audit_log"); - assertThat(partitions).contains(partitioningRepository.calculatePartitionStartTime(auditLog.getCreatedTime(), partitionDurationInMs)); + List partitions = getPartitioningRepository().fetchPartitions("audit_log"); + assertThat(partitions).contains(getPartitioningRepository().calculatePartitionStartTime(auditLog.getCreatedTime(), partitionDurationInMs)); } @Test @@ -197,15 +199,15 @@ public class AuditLogControllerTest extends AbstractControllerTest { final long oldAuditLogTs = ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2020-10-01T00:00:00Z").getTime(); final long currentTimeMillis = oldAuditLogTs + TimeUnit.SECONDS.toMillis(auditLogsTtlInSec) * 2; - final long partitionStartTs = partitioningRepository.calculatePartitionStartTime(oldAuditLogTs, partitionDurationInMs); - partitioningRepository.createPartitionIfNotExists("audit_log", oldAuditLogTs, partitionDurationInMs); - List partitions = partitioningRepository.fetchPartitions("audit_log"); + final long partitionStartTs = getPartitioningRepository().calculatePartitionStartTime(oldAuditLogTs, partitionDurationInMs); + getPartitioningRepository().createPartitionIfNotExists("audit_log", oldAuditLogTs, partitionDurationInMs); + List partitions = getPartitioningRepository().fetchPartitions("audit_log"); assertThat(partitions).contains(partitionStartTs); willReturn(currentTimeMillis).given(auditLogsCleanUpService).getCurrentTimeMillis(); auditLogsCleanUpService.cleanUp(); - partitions = partitioningRepository.fetchPartitions("audit_log"); + partitions = getPartitioningRepository().fetchPartitions("audit_log"); assertThat(partitions).as("partitions cleared").doesNotContain(partitionStartTs); assertThat(partitions).as("only newer partitions left").allSatisfy(partitionsStart -> { long partitionEndTs = partitionsStart + partitionDurationInMs; @@ -218,18 +220,18 @@ public class AuditLogControllerTest extends AbstractControllerTest { // creating partition bigger than sql.audit_logs.partition_size long entityTs = ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2022-04-29T07:43:11Z").getTime(); //the partition 7 days is overlapping default partition size 1 day, use in the far past to not affect other tests - partitioningRepository.createPartitionIfNotExists("audit_log", entityTs, TimeUnit.DAYS.toMillis(7)); - List partitions = partitioningRepository.fetchPartitions("audit_log"); + getPartitioningRepository().createPartitionIfNotExists("audit_log", entityTs, TimeUnit.DAYS.toMillis(7)); + List partitions = getPartitioningRepository().fetchPartitions("audit_log"); log.warn("entityTs [{}], fetched partitions {}", entityTs, partitions); assertThat(partitions).contains(ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2022-04-28T00:00:00Z").getTime()); - partitioningRepository.cleanupPartitionsCache("audit_log", entityTs, 0); + getPartitioningRepository().cleanupPartitionsCache("audit_log", entityTs, 0); assertDoesNotThrow(() -> { // expecting partition overlap error on partition save createAuditLog(ActionType.LOGIN, tenantAdminUserId, entityTs); }); - assertThat(partitioningRepository.fetchPartitions("audit_log")) - .contains(ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2022-04-28T00:00:00Z").getTime());; + assertThat(getPartitioningRepository().fetchPartitions("audit_log")) + .contains(ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2022-04-28T00:00:00Z").getTime()); } private AuditLog createAuditLog(ActionType actionType, EntityId entityId, long entityTs) { diff --git a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest_DedicatedEventsDataSource.java b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest_DedicatedEventsDataSource.java new file mode 100644 index 0000000000..7678b2a91f --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest_DedicatedEventsDataSource.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import lombok.Getter; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.sqlts.insert.sql.DedicatedEventsSqlPartitioningRepository; + +@DaoSqlTest +@TestPropertySource(properties = { + "spring.datasource.events.enabled=true", + "spring.datasource.events.url=${spring.datasource.url}", + "spring.datasource.events.driverClassName=${spring.datasource.driverClassName}", +}) +public class AuditLogControllerTest_DedicatedEventsDataSource extends AuditLogControllerTest { + + @Getter + @SpyBean + private DedicatedEventsSqlPartitioningRepository partitioningRepository; + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java index 7e1b9b30c9..d427c26d05 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java @@ -27,13 +27,16 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; import org.springframework.test.context.ContextConfiguration; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.DashboardInfo; +import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.ShortCustomerInfo; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.CustomerId; @@ -48,6 +51,7 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.ArrayList; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -547,6 +551,33 @@ public class DashboardControllerTest extends AbstractControllerTest { testEntityDaoWithRelationsTransactionalException(dashboardDao, savedTenant.getId(), dashboardId, "/api/dashboard/" + dashboardId); } + @Test + public void whenDeletingDashboard_ifReferencedByDeviceProfile_thenReturnError() throws Exception { + Dashboard dashboard = createDashboard("test"); + DeviceProfile deviceProfile = createDeviceProfile("test"); + deviceProfile.setDefaultDashboardId(dashboard.getId()); + doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + + String response = doDelete("/api/dashboard/" + dashboard.getUuidId()).andExpect(status().isBadRequest()) + .andReturn().getResponse().getContentAsString(); + String errorMessage = JacksonUtil.toJsonNode(response).get("message").asText(); + assertThat(errorMessage).containsIgnoringCase("referenced by a device profile"); + } + + @Test + public void whenDeletingDashboard_ifReferencedByAssetProfile_thenReturnError() throws Exception { + Dashboard dashboard = createDashboard("test"); + AssetProfile assetProfile = createAssetProfile("test"); + assetProfile.setDefaultDashboardId(dashboard.getId()); + doPost("/api/assetProfile", assetProfile, AssetProfile.class); + + String response = doDelete("/api/dashboard/" + dashboard.getUuidId()).andExpect(status().isBadRequest()) + .andReturn().getResponse().getContentAsString(); + String errorMessage = JacksonUtil.toJsonNode(response).get("message").asText(); + assertThat(errorMessage).containsIgnoringCase("referenced by an asset profile"); + + } + private Dashboard createDashboard(String title) { Dashboard dashboard = new Dashboard(); dashboard.setTitle(title); diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java index 512dd434dc..cf46d0f7fd 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java @@ -192,7 +192,7 @@ public class DeviceControllerTest extends AbstractControllerTest { Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); savedDevice.setName("My new device"); - doPost("/api/device", savedDevice, Device.class); + savedDevice = doPost("/api/device", savedDevice, Device.class); testNotifyEntityAllOneTime(savedDevice, savedDevice.getId(), savedDevice.getId(), savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.UPDATED); @@ -247,7 +247,7 @@ public class DeviceControllerTest extends AbstractControllerTest { Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); savedDevice.setName("My new device"); - doPost("/api/device", savedDevice, Device.class); + savedDevice = doPost("/api/device", savedDevice, Device.class); testNotifyEntityAllOneTime(savedDevice, savedDevice.getId(), savedDevice.getId(), savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.UPDATED); @@ -749,8 +749,7 @@ public class DeviceControllerTest extends AbstractControllerTest { Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); - doPost("/api/device/credentials", deviceCredentials) - .andExpect(status().isOk()); + deviceCredentials = doPost("/api/device/credentials", deviceCredentials, DeviceCredentials.class); testNotifyEntityMsgToEdgePushMsgToCoreOneTime(savedDevice, savedDevice.getId(), savedDevice.getId(), savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.CREDENTIALS_UPDATED, deviceCredentials); @@ -1586,10 +1585,33 @@ public class DeviceControllerTest extends AbstractControllerTest { Assert.assertEquals(newAttributeValue, actualAttribute.get("value")); } + @Test + public void testSaveDeviceWithOutdatedVersion() throws Exception { + Device device = createDevice("Device v1.0"); + assertThat(device.getVersion()).isOne(); + + device.setName("Device v2.0"); + device = doPost("/api/device", device, Device.class); + assertThat(device.getVersion()).isEqualTo(2); + + device.setName("Device v1.1"); + device.setVersion(1L); + String response = doPost("/api/device", device).andExpect(status().isConflict()) + .andReturn().getResponse().getContentAsString(); + assertThat(JacksonUtil.toJsonNode(response).get("message").asText()) + .containsIgnoringCase("already changed by someone else"); + + device.setVersion(null); // overriding entity + device = doPost("/api/device", device, Device.class); + assertThat(device.getName()).isEqualTo("Device v1.1"); + assertThat(device.getVersion()).isEqualTo(3); + } + private Device createDevice(String name) { Device device = new Device(); device.setName(name); device.setType("default"); return doPost("/api/device", device, Device.class); } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java index 49dfa71223..de530b5268 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java @@ -55,14 +55,18 @@ import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.queue.Queue; import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.model.JwtSettings; import org.thingsboard.server.dao.edge.EdgeDao; import org.thingsboard.server.dao.exception.DataValidationException; @@ -73,11 +77,13 @@ import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; import org.thingsboard.server.gen.edge.v1.CustomerUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg; import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg; import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.OAuth2UpdateMsg; import org.thingsboard.server.gen.edge.v1.QueueUpdateMsg; +import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; import org.thingsboard.server.gen.edge.v1.RuleChainUpdateMsg; import org.thingsboard.server.gen.edge.v1.SyncCompletedMsg; import org.thingsboard.server.gen.edge.v1.TenantProfileUpdateMsg; @@ -93,6 +99,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; import java.util.concurrent.TimeUnit; import static org.hamcrest.Matchers.containsString; @@ -887,23 +894,24 @@ public class EdgeControllerTest extends AbstractControllerTest { edgeImitator.ignoreType(UserCredentialsUpdateMsg.class); edgeImitator.ignoreType(OAuth2UpdateMsg.class); - edgeImitator.expectMessageAmount(24); + edgeImitator.expectMessageAmount(27); edgeImitator.connect(); waitForMessages(edgeImitator); - verifyFetchersMsgs(edgeImitator); + verifyFetchersMsgs(edgeImitator, savedDevice); // verify queue msgs Assert.assertTrue(popDeviceProfileMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); Assert.assertTrue(popDeviceMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Test Sync Edge Device 1")); + Assert.assertTrue(popDeviceCredentialsMsg(edgeImitator.getDownlinkMsgs(), savedDevice.getId())); Assert.assertTrue(popAssetProfileMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "test")); Assert.assertTrue(popAssetMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Test Sync Edge Asset 1")); Assert.assertTrue(edgeImitator.getDownlinkMsgs().isEmpty()); - edgeImitator.expectMessageAmount(20); + edgeImitator.expectMessageAmount(22); doPost("/api/edge/sync/" + edge.getId()); waitForMessages(edgeImitator); - verifyFetchersMsgs(edgeImitator); + verifyFetchersMsgs(edgeImitator, savedDevice); Assert.assertTrue(edgeImitator.getDownlinkMsgs().isEmpty()); edgeImitator.allowIgnoredTypes(); @@ -920,6 +928,23 @@ public class EdgeControllerTest extends AbstractControllerTest { .andExpect(status().isOk()); } + private RuleChainId getEdgeRootRuleChainId(EdgeImitator edgeImitator) { + try { + EdgeId edgeId = new EdgeId(new UUID(edgeImitator.getConfiguration().getEdgeIdMSB(), edgeImitator.getConfiguration().getEdgeIdLSB())); + List edgeRuleChains = doGetTypedWithPageLink("/api/edge/" + edgeId.getId() + "/ruleChains?", + new TypeReference>() { + }, new PageLink(100)).getData(); + for (RuleChain edgeRuleChain : edgeRuleChains) { + if (edgeRuleChain.isRoot()) { + return edgeRuleChain.getId(); + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + throw new RuntimeException("Root rule chain not found"); + } + private void simulateEdgeActivation(Edge edge) throws Exception { ObjectNode attributes = JacksonUtil.newObjectNode(); attributes.put("active", true); @@ -949,9 +974,10 @@ public class EdgeControllerTest extends AbstractControllerTest { } } - private void verifyFetchersMsgs(EdgeImitator edgeImitator) { + private void verifyFetchersMsgs(EdgeImitator edgeImitator, Device savedDevice) { Assert.assertTrue(popQueueMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Main")); Assert.assertTrue(popRuleChainMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Edge Root Rule Chain")); + Assert.assertTrue(popRuleChainMetadataMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, getEdgeRootRuleChainId(edgeImitator))); Assert.assertTrue(popAdminSettingsMsg(edgeImitator.getDownlinkMsgs(), "general")); Assert.assertTrue(popAdminSettingsMsg(edgeImitator.getDownlinkMsgs(), "mail")); Assert.assertTrue(popAdminSettingsMsg(edgeImitator.getDownlinkMsgs(), "connectivity")); @@ -965,6 +991,7 @@ public class EdgeControllerTest extends AbstractControllerTest { Assert.assertTrue(popCustomerMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Public")); Assert.assertTrue(popDeviceProfileMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); Assert.assertTrue(popDeviceMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Test Sync Edge Device 1")); + Assert.assertTrue(popDeviceCredentialsMsg(edgeImitator.getDownlinkMsgs(), savedDevice.getId())); Assert.assertTrue(popAssetProfileMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "test")); Assert.assertTrue(popAssetMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Test Sync Edge Asset 1")); Assert.assertTrue(popTenantMsg(edgeImitator.getDownlinkMsgs(), tenantId)); @@ -1002,6 +1029,21 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } + private boolean popRuleChainMetadataMsg(List messages, UpdateMsgType msgType, RuleChainId ruleChainId) { + for (AbstractMessage message : messages) { + if (message instanceof RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg) { + RuleChainMetaData ruleChainMetaData = JacksonUtil.fromString(ruleChainMetadataUpdateMsg.getEntity(), RuleChainMetaData.class, true); + Assert.assertNotNull(ruleChainMetaData); + if (msgType.equals(ruleChainMetadataUpdateMsg.getMsgType()) + && ruleChainId.equals(ruleChainMetaData.getRuleChainId())) { + messages.remove(message); + return true; + } + } + } + return false; + } + private boolean popAdminSettingsMsg(List messages, String key) { for (AbstractMessage message : messages) { if (message instanceof AdminSettingsUpdateMsg adminSettingsUpdateMsg) { @@ -1046,6 +1088,20 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } + private boolean popDeviceCredentialsMsg(List messages, DeviceId deviceId) { + for (AbstractMessage message : messages) { + if (message instanceof DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg) { + DeviceCredentials deviceCredentials = JacksonUtil.fromString(deviceCredentialsUpdateMsg.getEntity(), DeviceCredentials.class, true); + Assert.assertNotNull(deviceCredentials); + if (deviceId.equals(deviceCredentials.getDeviceId())) { + messages.remove(message); + return true; + } + } + } + return false; + } + private boolean popAssetProfileMsg(List messages, UpdateMsgType msgType, String name) { for (AbstractMessage message : messages) { if (message instanceof AssetProfileUpdateMsg assetProfileUpdateMsg) { diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index eb4f6fa083..17de555bdc 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -22,7 +22,6 @@ import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; -import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.web.servlet.ResultActions; import org.thingsboard.common.util.JacksonUtil; @@ -36,7 +35,6 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmSeverity; -import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.page.PageData; @@ -56,8 +54,8 @@ import org.thingsboard.server.common.data.query.EntityTypeFilter; import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.query.KeyFilter; import org.thingsboard.server.common.data.query.NumericFilterPredicate; -import org.thingsboard.server.common.data.query.StringFilterPredicate; import org.thingsboard.server.common.data.query.RelationsQueryFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; import org.thingsboard.server.common.data.query.TsValue; import org.thingsboard.server.common.data.queue.QueueStats; import org.thingsboard.server.common.data.relation.EntityRelation; diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java index 48e0fb6196..84cb6f41c0 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java @@ -103,7 +103,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest { Mockito.reset(tbClusterService, auditLogService); - doPost("/api/relation", relation).andExpect(status().isOk()); + relation = doPost("/api/v2/relation", relation, EntityRelation.class); String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", mainDevice.getUuidId(), EntityType.DEVICE, @@ -315,7 +315,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest { Device device = buildSimpleDevice("Test device 1"); EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); - doPost("/api/relation", relation).andExpect(status().isOk()); + relation = doPost("/api/v2/relation", relation, EntityRelation.class); String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", mainDevice.getUuidId(), EntityType.DEVICE, @@ -329,11 +329,15 @@ public class EntityRelationControllerTest extends AbstractControllerTest { Mockito.reset(tbClusterService, auditLogService); - doDelete(url).andExpect(status().isOk()); + String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + mainDevice.getUuidId(), EntityType.DEVICE, + "CONTAINS", device.getUuidId(), EntityType.DEVICE + ); + var deletedRelation = doDelete(deleteUrl, EntityRelation.class); - testNotifyEntityAllOneTimeRelation(foundRelation, + testNotifyEntityAllOneTimeRelation(deletedRelation, savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), - ActionType.RELATION_DELETED, foundRelation); + ActionType.RELATION_DELETED, deletedRelation); doGet(url).andExpect(status().is4xxClientError()); } @@ -523,7 +527,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest { @Test public void testCreateRelationFromTenantToDevice() throws Exception { EntityRelation relation = new EntityRelation(tenantAdmin.getTenantId(), mainDevice.getId(), "CONTAINS"); - doPost("/api/relation", relation).andExpect(status().isOk()); + relation = doPost("/api/v2/relation", relation, EntityRelation.class); String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", tenantAdmin.getTenantId(), EntityType.TENANT, @@ -539,7 +543,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest { @Test public void testCreateRelationFromDeviceToTenant() throws Exception { EntityRelation relation = new EntityRelation(mainDevice.getId(), tenantAdmin.getTenantId(), "CONTAINS"); - doPost("/api/relation", relation).andExpect(status().isOk()); + relation = doPost("/api/v2/relation", relation, EntityRelation.class); String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", mainDevice.getUuidId(), EntityType.DEVICE, diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java index 827ea250ed..9e70a00f2f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java @@ -176,7 +176,7 @@ public class EntityViewControllerTest extends AbstractControllerTest { savedView.setName("New test entity view"); - doPost("/api/entityView", savedView, EntityView.class); + savedView = doPost("/api/entityView", savedView, EntityView.class); foundEntityView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); assertEquals(savedView, foundEntityView); diff --git a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java index e272a40857..29bf048082 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java @@ -23,11 +23,14 @@ import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpMethod; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.mock.web.MockPart; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.ImageDescriptor; import org.thingsboard.server.common.data.ImageExportData; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.SystemParams; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.page.PageData; @@ -35,6 +38,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.sql.resource.TbResourceRepository; +import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.List; @@ -66,6 +70,8 @@ public class ImageControllerTest extends AbstractControllerTest { String filename = "my_png_image.png"; TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/png", PNG_IMAGE); + assertThat(imageInfo.getResourceSubType()).isEqualTo(ResourceSubType.IMAGE); + assertThat(imageInfo.getTitle()).isEqualTo(filename); assertThat(imageInfo.getResourceType()).isEqualTo(ResourceType.IMAGE); assertThat(imageInfo.getResourceKey()).isEqualTo(filename); @@ -85,6 +91,8 @@ public class ImageControllerTest extends AbstractControllerTest { String filename = "my_jpeg_image.jpg"; TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/jpeg", JPEG_IMAGE); + assertThat(imageInfo.getResourceSubType()).isEqualTo(ResourceSubType.IMAGE); + ImageDescriptor imageDescriptor = imageInfo.getDescriptor(ImageDescriptor.class); checkJpegImageDescriptor(imageDescriptor); @@ -97,6 +105,22 @@ public class ImageControllerTest extends AbstractControllerTest { String filename = "my_svg_image.svg"; TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/svg+xml", SVG_IMAGE); + assertThat(imageInfo.getResourceSubType()).isEqualTo(ResourceSubType.IMAGE); + + ImageDescriptor imageDescriptor = imageInfo.getDescriptor(ImageDescriptor.class); + checkSvgImageDescriptor(imageDescriptor); + + assertThat(downloadImage("tenant", filename)).containsExactly(SVG_IMAGE); + assertThat(downloadImagePreview("tenant", filename)).hasSize((int) imageDescriptor.getPreviewDescriptor().getSize()); + } + + @Test + public void testUploadScadaSymbolImage() throws Exception { + String filename = "my_scada_symbol_image.svg"; + TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", ResourceSubType.SCADA_SYMBOL.name(), filename, "image/svg+xml", SVG_IMAGE); + + assertThat(imageInfo.getResourceSubType()).isEqualTo(ResourceSubType.SCADA_SYMBOL); + ImageDescriptor imageDescriptor = imageInfo.getDescriptor(ImageDescriptor.class); checkSvgImageDescriptor(imageDescriptor); @@ -175,6 +199,7 @@ public class ImageControllerTest extends AbstractControllerTest { assertThat(exportData.getMediaType()).isEqualTo("image/png"); assertThat(exportData.getFileName()).isEqualTo(filename); assertThat(exportData.getTitle()).isEqualTo(filename); + assertThat(exportData.getSubType()).isEqualTo(ResourceSubType.IMAGE.name()); assertThat(exportData.getResourceKey()).isEqualTo(filename); assertThat(exportData.getData()).isEqualTo(Base64.getEncoder().encodeToString(PNG_IMAGE)); assertThat(exportData.isPublic()).isTrue(); @@ -184,6 +209,7 @@ public class ImageControllerTest extends AbstractControllerTest { TbResourceInfo importedImageInfo = doPut("/api/image/import", exportData, TbResourceInfo.class); assertThat(importedImageInfo.getTitle()).isEqualTo(filename); + assertThat(exportData.getSubType()).isEqualTo(ResourceSubType.IMAGE.name()); assertThat(importedImageInfo.getResourceKey()).isEqualTo(filename); assertThat(importedImageInfo.getFileName()).isEqualTo(filename); assertThat(importedImageInfo.isPublic()).isTrue(); @@ -198,20 +224,37 @@ public class ImageControllerTest extends AbstractControllerTest { String systemImageName = "my_system_png_image.png"; TbResourceInfo systemImage = uploadImage(HttpMethod.POST, "/api/image", systemImageName, "image/png", PNG_IMAGE); + String systemScadaSymbolName = "my_system_scada_symbol_image.svg"; + TbResourceInfo systemScadaSymbol = uploadImage(HttpMethod.POST, "/api/image", ResourceSubType.SCADA_SYMBOL.name(), systemScadaSymbolName, "image/svg+xml", SVG_IMAGE); + loginTenantAdmin(); String tenantImageName = "my_jpeg_image.jpg"; TbResourceInfo tenantImage = uploadImage(HttpMethod.POST, "/api/image", tenantImageName, "image/jpeg", JPEG_IMAGE); + String tenantScadaSymbolName = "my_scada_symbol_image.svg"; + TbResourceInfo tenantScadaSymbol = uploadImage(HttpMethod.POST, "/api/image", ResourceSubType.SCADA_SYMBOL.name(), tenantScadaSymbolName, "image/svg+xml", SVG_IMAGE); + List tenantImages = getImages(null, false, 10); assertThat(tenantImages).containsOnly(tenantImage); + List tenantScadaSymbols = getImages(null, ResourceSubType.SCADA_SYMBOL.name(), false, 10); + assertThat(tenantScadaSymbols).containsOnly(tenantScadaSymbol); + List allImages = getImages(null, true, 10); assertThat(allImages).containsOnly(tenantImage, systemImage); + List allScadaSymbols = getImages(null, ResourceSubType.SCADA_SYMBOL.name(), true, 10); + assertThat(allScadaSymbols).containsOnly(tenantScadaSymbol, systemScadaSymbol); + assertThat(getImages("png", true, 10)) .containsOnly(systemImage); assertThat(getImages("jpg", true, 10)) .containsOnly(tenantImage); + + assertThat(getImages("my_system_scada_symbol", ResourceSubType.SCADA_SYMBOL.name(), true, 10)) + .containsOnly(systemScadaSymbol); + assertThat(getImages("my_scada_symbol", ResourceSubType.SCADA_SYMBOL.name(),true, 10)) + .containsOnly(tenantScadaSymbol); } @Test @@ -312,7 +355,15 @@ public class ImageControllerTest extends AbstractControllerTest { } private List getImages(String searchText, boolean includeSystemImages, int limit) throws Exception { - PageData images = doGetTypedWithPageLink("/api/images?includeSystemImages=" + includeSystemImages + "&", new TypeReference<>() {}, new PageLink(limit, 0, searchText)); + return this.getImages(searchText, null, includeSystemImages, limit); + } + + private List getImages(String searchText, String imageSubType, boolean includeSystemImages, int limit) throws Exception { + var url = "/api/images?includeSystemImages=" + includeSystemImages + "&"; + if (StringUtils.isNotEmpty(imageSubType)) { + url += "imageSubType=" + imageSubType+ "&"; + } + PageData images = doGetTypedWithPageLink(url, new TypeReference<>() {}, new PageLink(limit, 0, searchText)); return images.getData(); } @@ -332,8 +383,16 @@ public class ImageControllerTest extends AbstractControllerTest { } private TbResourceInfo uploadImage(HttpMethod httpMethod, String url, String filename, String mediaType, byte[] content) throws Exception { + return this.uploadImage(httpMethod, url, null, filename, mediaType, content); + } + + private TbResourceInfo uploadImage(HttpMethod httpMethod, String url, String subType, String filename, String mediaType, byte[] content) throws Exception { MockMultipartFile file = new MockMultipartFile("file", filename, mediaType, content); var request = MockMvcRequestBuilders.multipart(httpMethod, url).file(file); + if (StringUtils.isNotEmpty(subType)) { + var imageSubTypePart = new MockPart("imageSubType", subType.getBytes(StandardCharsets.UTF_8)); + request.part(imageSubTypePart); + } setJwtToken(request); return readResponse(mockMvc.perform(request).andExpect(status().isOk()), TbResourceInfo.class); } diff --git a/application/src/test/java/org/thingsboard/server/controller/RpcControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/RpcControllerTest.java index d0f6426ab1..0cf84572c2 100644 --- a/application/src/test/java/org/thingsboard/server/controller/RpcControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/RpcControllerTest.java @@ -22,6 +22,7 @@ import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MvcResult; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; @@ -38,6 +39,9 @@ import java.util.List; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @DaoSqlTest +@TestPropertySource(properties = { + "transport.http.max_payload_size=10000" +}) public class RpcControllerTest extends AbstractControllerTest { private Tenant savedTenant; @@ -78,13 +82,18 @@ public class RpcControllerTest extends AbstractControllerTest { } private ObjectNode createDefaultRpc() { + return createDefaultRpc(100); + } + + private ObjectNode createDefaultRpc(int size) { ObjectNode rpc = JacksonUtil.newObjectNode(); rpc.put("method", "setGpio"); ObjectNode params = JacksonUtil.newObjectNode(); params.put("pin", 7); - params.put("value", 1); + String value = "a".repeat(size - 83); + params.put("value", value); rpc.set("params", params); rpc.put("persistent", true); @@ -122,6 +131,28 @@ public class RpcControllerTest extends AbstractControllerTest { Assert.assertEquals(savedDevice.getId(), savedRpc.getDeviceId()); } + @Test + public void testSaveLargeRpc() throws Exception { + Device device = createDefaultDevice(); + Device savedDevice = doPost("/api/device", device, Device.class); + + ObjectNode rpc = createDefaultRpc(10001); + doPost( + "/api/rpc/oneway/" + savedDevice.getId().getId().toString(), + JacksonUtil.toString(rpc), + String.class, + status().isPayloadTooLarge() + ); + + ObjectNode validRpc = createDefaultRpc(10000); + doPost( + "/api/rpc/oneway/" + savedDevice.getId().getId().toString(), + JacksonUtil.toString(validRpc), + String.class, + status().isOk() + ); + } + @Test public void testDeleteRpc() throws Exception { Device device = createDefaultDevice(); diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java index a990f55224..949dd4a781 100644 --- a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java @@ -120,7 +120,7 @@ public class RuleChainControllerTest extends AbstractControllerTest { ActionType.ADDED); savedRuleChain.setName("New RuleChain"); - doPost("/api/ruleChain", savedRuleChain, RuleChain.class); + savedRuleChain = doPost("/api/ruleChain", savedRuleChain, RuleChain.class); RuleChain foundRuleChain = doGet("/api/ruleChain/" + savedRuleChain.getId().getId().toString(), RuleChain.class); Assert.assertEquals(savedRuleChain.getName(), foundRuleChain.getName()); diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerTest.java new file mode 100644 index 0000000000..9c9322dc38 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerTest.java @@ -0,0 +1,249 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.service.ruleengine.RuleEngineCallService; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class RuleEngineControllerTest extends AbstractControllerTest { + + private final String REQUEST_BODY = "{\"request\":\"download\"}"; + private final String RESPONSE_BODY = "{\"response\":\"downloadOk\"}"; + + @SpyBean + private RuleEngineCallService ruleEngineCallService; + + @Test + public void testHandleRuleEngineRequestWithMsgOriginatorUser() throws Exception { + loginSysAdmin(); + TbMsg responseMsg = TbMsg.newMsg(TbMsgType.REST_API_REQUEST, currentUserId, TbMsgMetaData.EMPTY, RESPONSE_BODY); + mockRestApiCallToRuleEngine(responseMsg); + + JsonNode apiResponse = doPostAsyncWithTypedResponse("/api/rule-engine/", REQUEST_BODY, new TypeReference<>() { + }, status().isOk()); + + assertThat(JacksonUtil.toString(apiResponse)).isEqualTo(RESPONSE_BODY); + ArgumentCaptor requestMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + verify(ruleEngineCallService).processRestApiCallToRuleEngine(eq(TenantId.SYS_TENANT_ID), any(UUID.class), requestMsgCaptor.capture(), eq(false), any(Consumer.class)); + TbMsg requestMsgCaptorValue = requestMsgCaptor.getValue(); + assertThat(requestMsgCaptorValue.getData()).isEqualTo(REQUEST_BODY); + assertThat(requestMsgCaptorValue.getType()).isEqualTo(TbMsgType.REST_API_REQUEST.name()); + assertThat(requestMsgCaptorValue.getOriginator()).isEqualTo(currentUserId); + assertThat(requestMsgCaptorValue.getCustomerId()).isNull(); + checkMetadataProperties(requestMsgCaptorValue.getMetaData()); + testLogEntityAction(null, currentUserId, TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), currentUserId, + SYS_ADMIN_EMAIL, ActionType.REST_API_RULE_ENGINE_CALL, 1, REQUEST_BODY, RESPONSE_BODY); + } + + @Test + public void testHandleRuleEngineRequestWithMsgOriginatorDevice() throws Exception { + loginTenantAdmin(); + Device device = createDevice("Test", "123"); + DeviceId deviceId = device.getId(); + TbMsg responseMsg = TbMsg.newMsg(TbMsgType.REST_API_REQUEST, deviceId, TbMsgMetaData.EMPTY, RESPONSE_BODY); + mockRestApiCallToRuleEngine(responseMsg); + + JsonNode apiResponse = doPostAsyncWithTypedResponse("/api/rule-engine/DEVICE/" + deviceId.getId(), REQUEST_BODY, new TypeReference<>() { + }, status().isOk()); + + assertThat(JacksonUtil.toString(apiResponse)).isEqualTo(RESPONSE_BODY); + ArgumentCaptor requestMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + verify(ruleEngineCallService).processRestApiCallToRuleEngine(eq(tenantId), any(UUID.class), requestMsgCaptor.capture(), eq(false), any(Consumer.class)); + TbMsg requestMsgCaptorValue = requestMsgCaptor.getValue(); + assertThat(requestMsgCaptorValue.getData()).isEqualTo(REQUEST_BODY); + assertThat(requestMsgCaptorValue.getType()).isEqualTo(TbMsgType.REST_API_REQUEST.name()); + assertThat(requestMsgCaptorValue.getOriginator()).isEqualTo(deviceId); + assertThat(requestMsgCaptorValue.getCustomerId()).isNull(); + checkMetadataProperties(requestMsgCaptorValue.getMetaData()); + testLogEntityAction(null, deviceId, tenantId, new CustomerId(EntityId.NULL_UUID), tenantAdminUserId, + TENANT_ADMIN_EMAIL, ActionType.REST_API_RULE_ENGINE_CALL, 1, REQUEST_BODY, RESPONSE_BODY); + } + + @Test + public void testHandleRuleEngineRequestWithMsgOriginatorDeviceAndSpecifiedTimeout() throws Exception { + loginTenantAdmin(); + Device device = createDevice("Test", "123"); + DeviceId deviceId = device.getId(); + TbMsg responseMsg = TbMsg.newMsg(TbMsgType.REST_API_REQUEST, deviceId, TbMsgMetaData.EMPTY, RESPONSE_BODY); + mockRestApiCallToRuleEngine(responseMsg); + + JsonNode apiResponse = doPostAsyncWithTypedResponse("/api/rule-engine/DEVICE/" + deviceId.getId() + "/15000", REQUEST_BODY, new TypeReference<>() { + }, status().isOk()); + + assertThat(JacksonUtil.toString(apiResponse)).isEqualTo(RESPONSE_BODY); + ArgumentCaptor requestMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + verify(ruleEngineCallService).processRestApiCallToRuleEngine(eq(tenantId), any(UUID.class), requestMsgCaptor.capture(), eq(false), any(Consumer.class)); + TbMsg requestMsgCaptorValue = requestMsgCaptor.getValue(); + assertThat(requestMsgCaptorValue.getData()).isEqualTo(REQUEST_BODY); + assertThat(requestMsgCaptorValue.getType()).isEqualTo(TbMsgType.REST_API_REQUEST.name()); + assertThat(requestMsgCaptorValue.getOriginator()).isEqualTo(deviceId); + assertThat(requestMsgCaptorValue.getCustomerId()).isNull(); + checkMetadataProperties(requestMsgCaptorValue.getMetaData()); + testLogEntityAction(null, deviceId, tenantId, new CustomerId(EntityId.NULL_UUID), tenantAdminUserId, + TENANT_ADMIN_EMAIL, ActionType.REST_API_RULE_ENGINE_CALL, 1, REQUEST_BODY, RESPONSE_BODY); + } + + @Test + public void testHandleRuleEngineRequestWithMsgOriginatorDeviceAndResponseIsNull() throws Exception { + loginTenantAdmin(); + Device device = createDevice("Test", "123"); + DeviceId deviceId = device.getId(); + mockRestApiCallToRuleEngine(null); + + doPostAsync("/api/rule-engine/DEVICE/" + deviceId.getId() + "/15000", REQUEST_BODY, String.class, status().isRequestTimeout()); + + ArgumentCaptor requestMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + verify(ruleEngineCallService).processRestApiCallToRuleEngine(eq(tenantId), any(UUID.class), requestMsgCaptor.capture(), eq(false), any(Consumer.class)); + TbMsg requestMsgCaptorValue = requestMsgCaptor.getValue(); + assertThat(requestMsgCaptorValue.getData()).isEqualTo(REQUEST_BODY); + assertThat(requestMsgCaptorValue.getType()).isEqualTo(TbMsgType.REST_API_REQUEST.name()); + assertThat(requestMsgCaptorValue.getOriginator()).isEqualTo(deviceId); + assertThat(requestMsgCaptorValue.getCustomerId()).isNull(); + checkMetadataProperties(requestMsgCaptorValue.getMetaData()); + Exception exception = new TimeoutException("Processing timeout detected!"); + testLogEntityActionError(deviceId, tenantId, new CustomerId(EntityId.NULL_UUID), tenantAdminUserId, + TENANT_ADMIN_EMAIL, ActionType.REST_API_RULE_ENGINE_CALL, exception, REQUEST_BODY, ""); + } + + @Test + public void testHandleRuleEngineRequestWithMsgOriginatorDeviceAndSpecifiedQueue() throws Exception { + loginTenantAdmin(); + Device device = createDevice("Test", "123"); + DeviceId deviceId = device.getId(); + TbMsg responseMsg = TbMsg.newMsg(DataConstants.HP_QUEUE_NAME, TbMsgType.REST_API_REQUEST, deviceId, TbMsgMetaData.EMPTY, RESPONSE_BODY); + mockRestApiCallToRuleEngine(responseMsg); + + JsonNode apiResponse = doPostAsyncWithTypedResponse("/api/rule-engine/DEVICE/" + deviceId.getId() + "/HighPriority/1000", REQUEST_BODY, new TypeReference<>() { + }, status().isOk()); + + assertThat(JacksonUtil.toString(apiResponse)).isEqualTo(RESPONSE_BODY); + ArgumentCaptor requestMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + verify(ruleEngineCallService).processRestApiCallToRuleEngine(eq(tenantId), any(UUID.class), requestMsgCaptor.capture(), eq(true), any(Consumer.class)); + TbMsg requestMsgCaptorValue = requestMsgCaptor.getValue(); + assertThat(requestMsgCaptorValue.getData()).isEqualTo(REQUEST_BODY); + assertThat(requestMsgCaptorValue.getType()).isEqualTo(TbMsgType.REST_API_REQUEST.name()); + assertThat(requestMsgCaptorValue.getQueueName()).isEqualTo(DataConstants.HP_QUEUE_NAME); + assertThat(requestMsgCaptorValue.getOriginator()).isEqualTo(deviceId); + assertThat(requestMsgCaptorValue.getCustomerId()).isNull(); + checkMetadataProperties(requestMsgCaptorValue.getMetaData()); + testLogEntityAction(null, deviceId, tenantId, new CustomerId(EntityId.NULL_UUID), tenantAdminUserId, + TENANT_ADMIN_EMAIL, ActionType.REST_API_RULE_ENGINE_CALL, 1, REQUEST_BODY, RESPONSE_BODY); + } + + @Test + public void testHandleRuleEngineRequestWithInvalidRequestBody() throws Exception { + loginSysAdmin(); + + doPost("/api/rule-engine/", (Object) "@") + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Invalid request body"))); + + verifyNoInteractions(ruleEngineCallService); + } + + @Test + public void testHandleRuleEngineRequestWithAuthorityCustomerUser() throws Exception { + loginTenantAdmin(); + Device device = createDevice("Test", "123"); + DeviceId deviceId = device.getId(); + assignDeviceToCustomer(deviceId, customerId); + loginCustomerUser(); + + TbMsg responseMsg = TbMsg.newMsg(TbMsgType.REST_API_REQUEST, deviceId, customerId, TbMsgMetaData.EMPTY, RESPONSE_BODY); + mockRestApiCallToRuleEngine(responseMsg); + + JsonNode apiResponse = doPostAsyncWithTypedResponse("/api/rule-engine/DEVICE/" + deviceId.getId(), REQUEST_BODY, new TypeReference<>() { + }, status().isOk()); + + assertThat(JacksonUtil.toString(apiResponse)).isEqualTo(RESPONSE_BODY); + ArgumentCaptor requestMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + verify(ruleEngineCallService).processRestApiCallToRuleEngine(eq(tenantId), any(UUID.class), requestMsgCaptor.capture(), eq(false), any(Consumer.class)); + TbMsg requestMsgCaptorValue = requestMsgCaptor.getValue(); + assertThat(requestMsgCaptorValue.getData()).isEqualTo(REQUEST_BODY); + assertThat(requestMsgCaptorValue.getType()).isEqualTo(TbMsgType.REST_API_REQUEST.name()); + assertThat(requestMsgCaptorValue.getOriginator()).isEqualTo(deviceId); + assertThat(requestMsgCaptorValue.getCustomerId()).isEqualTo(customerId); + checkMetadataProperties(requestMsgCaptorValue.getMetaData()); + testLogEntityAction(null, deviceId, tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, + ActionType.REST_API_RULE_ENGINE_CALL, 1, REQUEST_BODY, RESPONSE_BODY); + } + + @Test + public void testHandleRuleEngineRequestWithoutPermission() throws Exception { + loginTenantAdmin(); + Device device = createDevice("test", "123"); + loginCustomerUser(); + + doPostAsync("/api/rule-engine/DEVICE/" + device.getId().getId(), (Object) REQUEST_BODY, -1L) + .andExpect(status().isForbidden()) + .andExpect(content().string("You don't have permission to perform this operation!")); + + verifyNoInteractions(ruleEngineCallService); + } + + @Test + public void testHandleRuleEngineRequestUnauthorized() throws Exception { + doPost("/api/rule-engine/", (Object) REQUEST_BODY) + .andExpect(status().isUnauthorized()) + .andExpect(statusReason(containsString("Authentication failed"))); + } + + private void mockRestApiCallToRuleEngine(TbMsg responseMsg) { + doAnswer(invocation -> { + Consumer consumer = invocation.getArgument(4); + consumer.accept(responseMsg); + return null; + }).when(ruleEngineCallService).processRestApiCallToRuleEngine(any(TenantId.class), any(UUID.class), any(TbMsg.class), anyBoolean(), any(Consumer.class)); + } + + public void checkMetadataProperties(TbMsgMetaData metaData) { + Map data = metaData.getData(); + assertThat(data).containsKeys("serviceId", "requestUUID", "expirationTime"); + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java index 0a110ef2d0..3aab891322 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java @@ -15,17 +15,14 @@ */ package org.thingsboard.server.controller; -import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Assert; import org.junit.Test; import org.springframework.test.context.TestPropertySource; -import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; -import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.SingleEntityFilter; import org.thingsboard.server.common.data.security.DeviceCredentials; diff --git a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java index 3160d542f1..e1beda28d8 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java @@ -149,7 +149,7 @@ public class TenantControllerTest extends AbstractControllerTest { testBroadcastEntityStateChangeEventTimeManyTimeTenant(savedTenant, ComponentLifecycleEvent.CREATED, 1); savedTenant.setTitle("My new tenant"); - saveTenant(savedTenant); + savedTenant = saveTenant(savedTenant); Tenant foundTenant = doGet("/api/tenant/" + savedTenant.getId().getId().toString(), Tenant.class); Assert.assertEquals(foundTenant.getTitle(), savedTenant.getTitle()); @@ -470,7 +470,7 @@ public class TenantControllerTest extends AbstractControllerTest { tenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); tenant.setTenantProfileId(tenantProfile.getId()); - saveTenant(tenant); + tenant = saveTenant(tenant); login(username, password); @@ -500,7 +500,7 @@ public class TenantControllerTest extends AbstractControllerTest { tenantProfile2 = doPost("/api/tenantProfile", tenantProfile2, TenantProfile.class); tenant.setTenantProfileId(tenantProfile2.getId()); - saveTenant(tenant); + tenant = saveTenant(tenant); login(username, password); @@ -542,7 +542,7 @@ public class TenantControllerTest extends AbstractControllerTest { loginSysAdmin(); tenant.setTenantProfileId(null); - saveTenant(tenant); + tenant = saveTenant(tenant); login(username, password); for (Queue queue : foundTenantQueues) { @@ -616,6 +616,14 @@ public class TenantControllerTest extends AbstractControllerTest { assertThat(usedTpi.getTopic()).isEqualTo(DataConstants.HP_QUEUE_TOPIC); assertThat(usedTpi.getTenantId()).get().isEqualTo(TenantId.SYS_TENANT_ID); }); + assertThat(partitionService.resolve(ServiceType.TB_RULE_ENGINE, null, tenantId, tenantId)).satisfies(tpi -> { + assertThat(tpi.getTopic()).isEqualTo(MAIN_QUEUE_TOPIC); + assertThat(tpi.getTenantId()).get().isEqualTo(tenantId); + }); + assertThat(partitionService.resolve(ServiceType.TB_RULE_ENGINE, "", tenantId, tenantId)).satisfies(tpi -> { + assertThat(tpi.getTopic()).isEqualTo(MAIN_QUEUE_TOPIC); + assertThat(tpi.getTenantId()).get().isEqualTo(tenantId); + }); loginSysAdmin(); tenantProfile.setIsolatedTbRuleEngine(true); @@ -850,4 +858,5 @@ public class TenantControllerTest extends AbstractControllerTest { testBroadcastEntityStateChangeEventNever(createEntityId_NULL_UUID(new Tenant())); Mockito.reset(tbClusterService); } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java b/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java index 6e1198758d..b87f82de77 100644 --- a/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java @@ -15,6 +15,10 @@ */ package org.thingsboard.server.controller.plugin; +import jakarta.websocket.RemoteEndpoint; +import jakarta.websocket.SendHandler; +import jakarta.websocket.SendResult; +import jakarta.websocket.Session; import lombok.extern.slf4j.Slf4j; import org.awaitility.Awaitility; import org.junit.jupiter.api.AfterEach; @@ -26,10 +30,6 @@ import org.springframework.web.socket.adapter.NativeWebSocketSession; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.service.ws.WebSocketSessionRef; -import jakarta.websocket.RemoteEndpoint; -import jakarta.websocket.SendHandler; -import jakarta.websocket.SendResult; -import jakarta.websocket.Session; import java.io.IOException; import java.util.Collection; import java.util.Deque; diff --git a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java index e7f664477c..f7a066f7e0 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java @@ -83,6 +83,7 @@ import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; import org.thingsboard.server.gen.edge.v1.CustomerUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg; import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg; import org.thingsboard.server.gen.edge.v1.EdgeConfiguration; @@ -96,6 +97,7 @@ import org.thingsboard.server.gen.edge.v1.TenantProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.TenantUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; import java.util.ArrayList; @@ -143,7 +145,7 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { edgeImitator = new EdgeImitator("localhost", 7070, edge.getRoutingKey(), edge.getSecret()); edgeImitator.ignoreType(OAuth2UpdateMsg.class); - edgeImitator.expectMessageAmount(21); + edgeImitator.expectMessageAmount(24); edgeImitator.connect(); requestEdgeRuleChainMetadata(); @@ -250,7 +252,7 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { UUID ruleChainUUID = validateRuleChains(); // 1 from request message - validateMsgsCnt(RuleChainMetadataUpdateMsg.class, 1); + validateMsgsCnt(RuleChainMetadataUpdateMsg.class, 2); validateRuleChainMetadataUpdates(ruleChainUUID); // 4 messages ('general', 'mail', 'connectivity', 'jwt') @@ -275,6 +277,8 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { validateMsgsCnt(DeviceUpdateMsg.class, 1); validateDevices(); + validateMsgsCnt(DeviceCredentialsUpdateMsg.class, 1); + // 1 from asset fetcher validateMsgsCnt(AssetUpdateMsg.class, 1); validateAssets(); @@ -287,6 +291,8 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { validateMsgsCnt(UserUpdateMsg.class, 1); validateUsers(); + validateMsgsCnt(UserCredentialsUpdateMsg.class, 1); + // 1 from tenant fetcher validateMsgsCnt(TenantUpdateMsg.class, 1); validateTenant(); diff --git a/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java index 9e09a16fd9..071d6b1391 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java @@ -150,25 +150,25 @@ public class DeviceEdgeTest extends AbstractEdgeTest { + "/edge/" + edge.getUuidId(), Edge.class); Assert.assertTrue(edgeImitator.waitForMessages()); - edgeImitator.expectMessageAmount(1); + edgeImitator.expectMessageAmount(2); doPost("/api/customer/" + savedCustomer.getUuidId() + "/device/" + savedDevice.getUuidId(), Device.class); Assert.assertTrue(edgeImitator.waitForMessages()); - latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof DeviceUpdateMsg); - deviceUpdateMsg = (DeviceUpdateMsg) latestMessage; + deviceUpdateMsgOpt = edgeImitator.findMessageByType(DeviceUpdateMsg.class); + Assert.assertTrue(deviceUpdateMsgOpt.isPresent()); + deviceUpdateMsg = deviceUpdateMsgOpt.get(); deviceFromMsg = JacksonUtil.fromString(deviceUpdateMsg.getEntity(), Device.class, true); Assert.assertNotNull(deviceFromMsg); Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, deviceUpdateMsg.getMsgType()); Assert.assertEquals(savedCustomer.getId(), deviceFromMsg.getCustomerId()); // unassign device #2 from customer - edgeImitator.expectMessageAmount(1); + edgeImitator.expectMessageAmount(2); doDelete("/api/customer/device/" + savedDevice.getUuidId(), Device.class); Assert.assertTrue(edgeImitator.waitForMessages()); - latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof DeviceUpdateMsg); - deviceUpdateMsg = (DeviceUpdateMsg) latestMessage; + deviceUpdateMsgOpt = edgeImitator.findMessageByType(DeviceUpdateMsg.class); + Assert.assertTrue(deviceUpdateMsgOpt.isPresent()); + deviceUpdateMsg = deviceUpdateMsgOpt.get(); deviceFromMsg = JacksonUtil.fromString(deviceUpdateMsg.getEntity(), Device.class, true); Assert.assertNotNull(deviceFromMsg); Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, deviceUpdateMsg.getMsgType()); @@ -204,8 +204,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest { Assert.assertEquals(savedDevice.getId(), deviceCredentials.getDeviceId()); deviceCredentials.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); deviceCredentials.setCredentialsId("access_token"); - doPost("/api/device/credentials", deviceCredentials) - .andExpect(status().isOk()); + deviceCredentials = doPost("/api/device/credentials", deviceCredentials, DeviceCredentials.class); Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof DeviceCredentialsUpdateMsg); @@ -218,8 +217,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest { deviceCredentials.setCredentialsType(DeviceCredentialsType.X509_CERTIFICATE); deviceCredentials.setCredentialsId(null); deviceCredentials.setCredentialsValue("-----BEGIN RSA PRIVATE KEY-----"); - doPost("/api/device/credentials", deviceCredentials) - .andExpect(status().isOk()); + deviceCredentials = doPost("/api/device/credentials", deviceCredentials, DeviceCredentials.class); Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof DeviceCredentialsUpdateMsg); @@ -243,7 +241,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest { Assert.assertTrue(edgeImitator.waitForMessages()); // update device - edgeImitator.expectMessageAmount(1); + edgeImitator.expectMessageAmount(2); savedDevice.setFirmwareId(firmwareOtaPackageInfo.getId()); savedDevice.setSoftwareId(softwareOtaPackageInfo.getId()); @@ -256,9 +254,9 @@ public class DeviceEdgeTest extends AbstractEdgeTest { savedDevice = doPost("/api/device", savedDevice, Device.class); Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof DeviceUpdateMsg); - DeviceUpdateMsg deviceUpdateMsg = (DeviceUpdateMsg) latestMessage; + Optional deviceUpdateMsgOpt = edgeImitator.findMessageByType(DeviceUpdateMsg.class); + Assert.assertTrue(deviceUpdateMsgOpt.isPresent()); + DeviceUpdateMsg deviceUpdateMsg = deviceUpdateMsgOpt.get(); Device deviceMsg = JacksonUtil.fromString(deviceUpdateMsg.getEntity(), Device.class, true); Assert.assertNotNull(deviceMsg); Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, deviceUpdateMsg.getMsgType()); @@ -504,7 +502,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest { uplinkMsgBuilder.addDeviceUpdateMsg(deviceUpdateMsgBuilder.build()); edgeImitator.expectResponsesAmount(1); - edgeImitator.expectMessageAmount(2); + edgeImitator.expectMessageAmount(1); testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); @@ -526,18 +524,6 @@ public class DeviceEdgeTest extends AbstractEdgeTest { Device device = doGet("/api/device/" + newDeviceId, Device.class); Assert.assertNotNull(device); Assert.assertNotEquals(deviceOnCloudName, device.getName()); - - Optional deviceCredentialsUpdateMsgOpt = edgeImitator.findMessageByType(DeviceCredentialsRequestMsg.class); - Assert.assertTrue(deviceCredentialsUpdateMsgOpt.isPresent()); - DeviceCredentialsRequestMsg latestDeviceCredentialsRequestMsg = deviceCredentialsUpdateMsgOpt.get(); - Assert.assertEquals(deviceMsg.getUuidId().getMostSignificantBits(), latestDeviceCredentialsRequestMsg.getDeviceIdMSB()); - Assert.assertEquals(device.getUuidId().getLeastSignificantBits(), latestDeviceCredentialsRequestMsg.getDeviceIdLSB()); - - newDeviceId = new UUID(latestDeviceCredentialsRequestMsg.getDeviceIdMSB(), latestDeviceCredentialsRequestMsg.getDeviceIdLSB()); - - device = doGet("/api/device/" + newDeviceId, Device.class); - Assert.assertNotNull(device); - Assert.assertNotEquals(deviceOnCloudName, device.getName()); } @Test @@ -553,22 +539,10 @@ public class DeviceEdgeTest extends AbstractEdgeTest { uplinkMsgBuilder.addDeviceUpdateMsg(deviceUpdateMsgBuilder.build()); edgeImitator.expectResponsesAmount(1); - edgeImitator.expectMessageAmount(1); - edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); - Assert.assertTrue(edgeImitator.waitForResponses()); - Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof DeviceCredentialsRequestMsg); - DeviceCredentialsRequestMsg latestDeviceCredentialsRequestMsg = (DeviceCredentialsRequestMsg) latestMessage; - Assert.assertEquals(deviceMsg.getUuidId().getMostSignificantBits(), latestDeviceCredentialsRequestMsg.getDeviceIdMSB()); - Assert.assertEquals(deviceMsg.getUuidId().getLeastSignificantBits(), latestDeviceCredentialsRequestMsg.getDeviceIdLSB()); - - UUID newDeviceId = new UUID(latestDeviceCredentialsRequestMsg.getDeviceIdMSB(), latestDeviceCredentialsRequestMsg.getDeviceIdLSB()); - - Device device = doGet("/api/device/" + newDeviceId, Device.class); + Device device = doGet("/api/device/" + deviceMsg.getId().getId(), Device.class); Assert.assertNotNull(device); Assert.assertEquals("Edge Device 2", device.getName()); } diff --git a/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java index 890ec00913..6d4ebb3676 100644 --- a/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java @@ -18,7 +18,6 @@ package org.thingsboard.server.edge; import com.google.protobuf.AbstractMessage; import org.junit.Assert; import org.junit.Test; -import org.springframework.test.context.TestPropertySource; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; diff --git a/application/src/test/java/org/thingsboard/server/edge/OAuth2EdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/OAuth2EdgeTest.java index c25410b562..beae36718d 100644 --- a/application/src/test/java/org/thingsboard/server/edge/OAuth2EdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/OAuth2EdgeTest.java @@ -59,12 +59,7 @@ public class OAuth2EdgeTest extends AbstractEdgeTest { oAuth2Info.setEnabled(false); oAuth2Info.setEdgeEnabled(false); doPost("/api/oauth2/config", oAuth2Info, OAuth2Info.class); - Assert.assertTrue(edgeImitator.waitForMessages()); - latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof OAuth2UpdateMsg); - oAuth2UpdateMsg = (OAuth2UpdateMsg) latestMessage; - result = JacksonUtil.fromString(oAuth2UpdateMsg.getEntity(), OAuth2Info.class, true); - Assert.assertEquals(oAuth2Info, result); + Assert.assertFalse(edgeImitator.waitForMessages(5)); edgeImitator.ignoreType(OAuth2UpdateMsg.class); loginTenantAdmin(); diff --git a/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java index d89b601866..82b7a09623 100644 --- a/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java @@ -48,7 +48,7 @@ public class RelationEdgeTest extends AbstractEdgeTest { relation.setTo(asset.getId()); relation.setTypeGroup(RelationTypeGroup.COMMON); edgeImitator.expectMessageAmount(1); - doPost("/api/relation", relation); + relation = doPost("/api/v2/relation", relation, EntityRelation.class); Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof RelationUpdateMsg); @@ -60,21 +60,20 @@ public class RelationEdgeTest extends AbstractEdgeTest { // delete relation edgeImitator.expectMessageAmount(1); - doDelete("/api/relation?" + + var deletedRelation = doDelete("/api/v2/relation?" + "fromId=" + relation.getFrom().getId().toString() + "&fromType=" + relation.getFrom().getEntityType().name() + "&relationType=" + relation.getType() + "&relationTypeGroup=" + relation.getTypeGroup().name() + "&toId=" + relation.getTo().getId().toString() + - "&toType=" + relation.getTo().getEntityType().name()) - .andExpect(status().isOk()); + "&toType=" + relation.getTo().getEntityType().name(), EntityRelation.class); Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof RelationUpdateMsg); relationUpdateMsg = (RelationUpdateMsg) latestMessage; entityRelation = JacksonUtil.fromString(relationUpdateMsg.getEntity(), EntityRelation.class, true); Assert.assertNotNull(entityRelation); - Assert.assertEquals(relation, entityRelation); + Assert.assertEquals(deletedRelation, entityRelation); Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, relationUpdateMsg.getMsgType()); } @@ -119,7 +118,7 @@ public class RelationEdgeTest extends AbstractEdgeTest { deviceToAssetRelation.setTypeGroup(RelationTypeGroup.COMMON); edgeImitator.expectMessageAmount(1); - doPost("/api/relation", deviceToAssetRelation); + deviceToAssetRelation = doPost("/api/v2/relation", deviceToAssetRelation, EntityRelation.class); Assert.assertTrue(edgeImitator.waitForMessages()); EntityRelation assetToTenantRelation = new EntityRelation(); diff --git a/application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java index 62d2c5eade..c694df84f8 100644 --- a/application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java @@ -193,7 +193,7 @@ public class RuleChainEdgeTest extends AbstractEdgeTest { ruleChain.setType(RuleChainType.EDGE); RuleChain savedRuleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); - edgeImitator.expectMessageAmount(2); + edgeImitator.expectMessageAmount(4); doPost("/api/edge/" + edge.getUuidId() + "/ruleChain/" + savedRuleChain.getUuidId(), RuleChain.class); RuleChainMetaData metaData = createRuleChainMetadata(savedRuleChain); @@ -201,7 +201,7 @@ public class RuleChainEdgeTest extends AbstractEdgeTest { // set new rule chain as root RuleChainId currentRootRuleChainId = edge.getRootRuleChainId(); - edgeImitator.expectMessageAmount(1); + edgeImitator.expectMessageAmount(2); doPost("/api/edge/" + edge.getUuidId() + "/" + savedRuleChain.getUuidId() + "/root", Edge.class); Assert.assertTrue(edgeImitator.waitForMessages()); diff --git a/application/src/test/java/org/thingsboard/server/edge/TelemetryEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/TelemetryEdgeTest.java index 00b8cc4b74..b77e0b5ccc 100644 --- a/application/src/test/java/org/thingsboard/server/edge/TelemetryEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/TelemetryEdgeTest.java @@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.gen.edge.v1.AttributeDeleteMsg; +import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg; import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg; import org.thingsboard.server.gen.edge.v1.EntityDataProto; import org.thingsboard.server.gen.edge.v1.UplinkMsg; @@ -183,7 +184,7 @@ public class TelemetryEdgeTest extends AbstractEdgeTest { edgeImitator.setRandomFailuresOnTimeseriesDownlink(true); // imitator will generate failure in 100% of timeseries cases edgeImitator.setFailureProbability(100); - edgeImitator.expectMessageAmount(numberOfMsgsToSend); + edgeImitator.expectMessageAmount(numberOfMsgsToSend * 2); for (int idx = 1; idx <= numberOfMsgsToSend; idx++) { String timeseriesData = "{\"data\":{\"idx\":" + idx + "},\"ts\":" + System.currentTimeMillis() + "}"; JsonNode timeseriesEntityData = JacksonUtil.toJsonNode(timeseriesData); @@ -204,6 +205,9 @@ public class TelemetryEdgeTest extends AbstractEdgeTest { List deviceUpdateMsgs = edgeImitator.findAllMessagesByType(DeviceUpdateMsg.class); Assert.assertEquals(numberOfMsgsToSend, deviceUpdateMsgs.size()); + List deviceCredentialsUpdateMsgs = edgeImitator.findAllMessagesByType(DeviceCredentialsUpdateMsg.class); + Assert.assertEquals(numberOfMsgsToSend, deviceCredentialsUpdateMsgs.size()); + edgeImitator.setRandomFailuresOnTimeseriesDownlink(false); } diff --git a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java index 6314cf53b9..93fea90c5a 100644 --- a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java @@ -47,7 +47,7 @@ public class UserEdgeTest extends AbstractEdgeTest { @Test public void testCreateUpdateDeleteTenantUser() throws Exception { // create user - edgeImitator.expectMessageAmount(3); + edgeImitator.expectMessageAmount(4); User newTenantAdmin = new User(); newTenantAdmin.setAuthority(Authority.TENANT_ADMIN); newTenantAdmin.setTenantId(tenantId); @@ -55,7 +55,7 @@ public class UserEdgeTest extends AbstractEdgeTest { newTenantAdmin.setFirstName("Boris"); newTenantAdmin.setLastName("Johnson"); User savedTenantAdmin = createUser(newTenantAdmin, "tenant"); - Assert.assertTrue(edgeImitator.waitForMessages()); // wait 3 messages - user update msg and x2 user credentials update msgs + Assert.assertTrue(edgeImitator.waitForMessages()); // wait 4 messages - x2 user update msg and x2 user credentials update msgs Optional userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class); Assert.assertTrue(userUpdateMsgOpt.isPresent()); UserUpdateMsg userUpdateMsg = userUpdateMsgOpt.get(); @@ -71,13 +71,13 @@ public class UserEdgeTest extends AbstractEdgeTest { Assert.assertTrue(userCredentialsUpdateMsgOpt.isPresent()); // update user - edgeImitator.expectMessageAmount(1); + edgeImitator.expectMessageAmount(2); savedTenantAdmin.setLastName("Borisov"); savedTenantAdmin = doPost("/api/user", savedTenantAdmin, User.class); Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserUpdateMsg); - userUpdateMsg = (UserUpdateMsg) latestMessage; + userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class); + Assert.assertTrue(userUpdateMsgOpt.isPresent()); + userUpdateMsg = userUpdateMsgOpt.get(); userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); Assert.assertNotNull(userMsg); Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, userUpdateMsg.getMsgType()); @@ -92,7 +92,7 @@ public class UserEdgeTest extends AbstractEdgeTest { changePasswordRequest.setNewPassword("newTenant"); doPost("/api/auth/changePassword", changePasswordRequest); Assert.assertTrue(edgeImitator.waitForMessages()); - latestMessage = edgeImitator.getLatestMessage(); + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg); UserCredentialsUpdateMsg userCredentialsUpdateMsg = (UserCredentialsUpdateMsg) latestMessage; UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true); @@ -131,7 +131,7 @@ public class UserEdgeTest extends AbstractEdgeTest { Assert.assertTrue(edgeImitator.waitForMessages()); // create user - edgeImitator.expectMessageAmount(3); + edgeImitator.expectMessageAmount(4); User customerUser = new User(); customerUser.setAuthority(Authority.CUSTOMER_USER); customerUser.setTenantId(tenantId); @@ -140,7 +140,7 @@ public class UserEdgeTest extends AbstractEdgeTest { customerUser.setFirstName("John"); customerUser.setLastName("Edwards"); User savedCustomerUser = createUser(customerUser, "customer"); - Assert.assertTrue(edgeImitator.waitForMessages()); // wait 3 messages - user update msg and x2 user credentials update msgs + Assert.assertTrue(edgeImitator.waitForMessages()); // wait 4 messages - x2 user update msg and x2 user credentials update msgs Optional userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class); Assert.assertTrue(userUpdateMsgOpt.isPresent()); UserUpdateMsg userUpdateMsg = userUpdateMsgOpt.get(); @@ -155,13 +155,13 @@ public class UserEdgeTest extends AbstractEdgeTest { Assert.assertEquals(savedCustomerUser.getLastName(), userMsg.getLastName()); // update user - edgeImitator.expectMessageAmount(1); + edgeImitator.expectMessageAmount(2); savedCustomerUser.setLastName("Addams"); savedCustomerUser = doPost("/api/user", savedCustomerUser, User.class); Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserUpdateMsg); - userUpdateMsg = (UserUpdateMsg) latestMessage; + userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class); + Assert.assertTrue(userUpdateMsgOpt.isPresent()); + userUpdateMsg = userUpdateMsgOpt.get(); userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); Assert.assertNotNull(userMsg); Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, userUpdateMsg.getMsgType()); @@ -176,7 +176,7 @@ public class UserEdgeTest extends AbstractEdgeTest { changePasswordRequest.setNewPassword("newCustomer"); doPost("/api/auth/changePassword", changePasswordRequest); Assert.assertTrue(edgeImitator.waitForMessages()); - latestMessage = edgeImitator.getLatestMessage(); + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg); UserCredentialsUpdateMsg userCredentialsUpdateMsg = (UserCredentialsUpdateMsg) latestMessage; UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true); diff --git a/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java b/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java index dbef55539f..c468387c55 100644 --- a/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java @@ -21,8 +21,8 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.ListUtils; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; diff --git a/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java b/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java index 2f3ec5315f..4c4e887644 100644 --- a/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java @@ -24,9 +24,9 @@ import org.junit.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.rule.engine.util.TbMsgSource; import org.thingsboard.rule.engine.metadata.TbGetAttributesNode; import org.thingsboard.rule.engine.metadata.TbGetAttributesNodeConfiguration; +import org.thingsboard.rule.engine.util.TbMsgSource; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/processor/device/AbstractDeviceProcessorTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/processor/device/AbstractDeviceProcessorTest.java index 0ec40fb908..31524e467a 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/processor/device/AbstractDeviceProcessorTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/processor/device/AbstractDeviceProcessorTest.java @@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessorTest; @@ -61,6 +62,9 @@ public abstract class AbstractDeviceProcessorTest extends BaseEdgeProcessorTest deviceProfile.setProfileData(deviceProfileData); deviceProfile.setTransportType(DeviceTransportType.DEFAULT); + DeviceCredentials deviceCredentials = new DeviceCredentials(); + deviceCredentials.setDeviceId(deviceId); + Device device = new Device(); device.setDeviceProfileId(deviceProfileId); device.setId(deviceId); @@ -71,9 +75,9 @@ public abstract class AbstractDeviceProcessorTest extends BaseEdgeProcessorTest edgeEvent.setTenantId(tenantId); edgeEvent.setAction(EdgeEventActionType.ADDED); - willReturn(device).given(deviceService).findDeviceById(tenantId, deviceId); willReturn(deviceProfile).given(deviceProfileService).findDeviceProfileById(tenantId, deviceProfileId); + willReturn(deviceCredentials).given(deviceCredentialsService).findDeviceCredentialsByDeviceId(tenantId, deviceId); } protected void updateDeviceProfileDefaultFields(long expectedDashboardIdMSB, long expectedDashboardIdLSB, diff --git a/application/src/test/java/org/thingsboard/server/service/install/InstallScriptsTest.java b/application/src/test/java/org/thingsboard/server/service/install/InstallScriptsTest.java index 2d6009b15c..37b50fda4f 100644 --- a/application/src/test/java/org/thingsboard/server/service/install/InstallScriptsTest.java +++ b/application/src/test/java/org/thingsboard/server/service/install/InstallScriptsTest.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.oauth2.OAuth2ConfigTemplateService; +import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.service.validator.RuleChainDataValidator; @@ -65,6 +66,8 @@ class InstallScriptsTest { @MockBean ResourceService resourceService; @MockBean + ImageService imageService; + @MockBean ImagesUpdater imagesUpdater; @SpyBean InstallScripts installScripts; diff --git a/application/src/test/java/org/thingsboard/server/service/mail/TbMailSenderTest.java b/application/src/test/java/org/thingsboard/server/service/mail/TbMailSenderTest.java index a299b56dbe..e5e729b634 100644 --- a/application/src/test/java/org/thingsboard/server/service/mail/TbMailSenderTest.java +++ b/application/src/test/java/org/thingsboard/server/service/mail/TbMailSenderTest.java @@ -15,15 +15,16 @@ */ package org.thingsboard.server.service.mail; +import jakarta.mail.MessagingException; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mockito; -import jakarta.mail.MessagingException; -import jakarta.mail.Session; -import jakarta.mail.internet.MimeMessage; + import java.util.ArrayList; import java.util.List; import java.util.Properties; diff --git a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java index 15650ff2e6..bbe31f3e94 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java @@ -143,6 +143,14 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest return submitNotificationRequest(targetId, text, 0, deliveryMethods); } + protected NotificationRequest submitNotificationRequest(NotificationType type, NotificationTargetId targetId, String text, NotificationDeliveryMethod... deliveryMethods) { + if (deliveryMethods.length == 0) { + deliveryMethods = new NotificationDeliveryMethod[]{NotificationDeliveryMethod.WEB}; + } + NotificationTemplate notificationTemplate = createNotificationTemplate(type, DEFAULT_NOTIFICATION_SUBJECT, text, deliveryMethods); + return submitNotificationRequest(List.of(targetId), notificationTemplate.getId(), 0); + } + protected NotificationRequest submitNotificationRequest(NotificationTargetId targetId, String text, int delayInSec, NotificationDeliveryMethod... deliveryMethods) { return submitNotificationRequest(List.of(targetId), text, delayInSec, deliveryMethods); } diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java index 883a173edb..9981b7935d 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java @@ -210,6 +210,88 @@ public class NotificationApiTest extends AbstractNotificationApiTest { checkPartialNotificationsUpdate(otherWsClient.getLastDataUpdate(), notificationText, 1); } + @Test + public void testNotificationUpdates_typesFilter_multipleSubs() { + int generalSub = wsClient.subscribeForUnreadNotificationsAndWait(10, NotificationType.GENERAL); + int alarmSub = wsClient.subscribeForUnreadNotificationsAndWait(10, NotificationType.ALARM, NotificationType.GENERAL); + int entityActionSub = wsClient.subscribeForUnreadNotificationsAndWait(10, NotificationType.ENTITY_ACTION, NotificationType.GENERAL); + NotificationTarget notificationTarget = createNotificationTarget(customerUserId); + + String generalNotificationText1 = "General notification 1"; + submitNotificationRequest(NotificationType.GENERAL, notificationTarget.getId(), generalNotificationText1); + // expecting all 3 subs to received update + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(wsClient.getLastUpdates()).extractingByKeys(generalSub, alarmSub, entityActionSub) + .allMatch(update -> update.getUpdate().getText().equals(generalNotificationText1) + && update.getTotalUnreadCount() == 1); + }); + Notification generalNotification1 = wsClient.getLastDataUpdate().getUpdate(); + + String generalNotificationText2 = "General notification 2"; + submitNotificationRequest(NotificationType.GENERAL, notificationTarget.getId(), generalNotificationText2); + // expecting all 3 subs to received update + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(wsClient.getLastUpdates()).extractingByKeys(generalSub, alarmSub, entityActionSub) + .allMatch(update -> update.getUpdate().getText().equals(generalNotificationText2) + && update.getTotalUnreadCount() == 2); + }); + Notification generalNotification2 = wsClient.getLastDataUpdate().getUpdate(); + + // marking as read, expecting all 3 subs to received update + wsClient.markNotificationAsRead(generalNotification1.getUuidId()); + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(wsClient.getLastUpdates()).extractingByKeys(generalSub, alarmSub, entityActionSub) + .allMatch(update -> update.getTotalUnreadCount() == 1 && update.getNotifications().size() == 1 + && update.getNotifications().get(0).getText().equals(generalNotificationText2)); + }); + wsClient.getLastUpdates().clear(); + + String alarmNotificationText1 = "Alarm notification 1"; + submitNotificationRequest(NotificationType.ALARM, notificationTarget.getId(), alarmNotificationText1); + // expecting only 1 sub to received update + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(wsClient.getLastUpdates()).extractingByKey(alarmSub) + .matches(update -> update.getUpdate().getText().equals(alarmNotificationText1) + && update.getTotalUnreadCount() == 2); + }); + Notification alarmNotification1 = wsClient.getLastDataUpdate().getUpdate(); + + String alarmNotificationText2 = "Alarm notification 2"; + submitNotificationRequest(NotificationType.ALARM, notificationTarget.getId(), alarmNotificationText2); + // expecting only 1 sub to received update + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(wsClient.getLastUpdates()).extractingByKey(alarmSub) + .matches(update -> update.getUpdate().getText().equals(alarmNotificationText2) + && update.getTotalUnreadCount() == 3); + }); + await().during(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + assertThat(wsClient.getLastUpdates()).extractingByKeys(generalSub, entityActionSub) + .containsOnlyNulls(); + }); + + // marking as read, expecting only 1 sub to receive update + wsClient.markNotificationAsRead(alarmNotification1.getUuidId()); + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(wsClient.getLastUpdates()).extractingByKey(alarmSub) + .matches(update -> update.getTotalUnreadCount() == 2 && update.getNotifications().size() == 2); + }); + await().during(3, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(wsClient.getLastUpdates()).extractingByKeys(generalSub, entityActionSub) + .containsOnlyNulls(); + }); + + // marking as read, expecting general and entity action subs with 0 unread, and alarm with 1 unread + wsClient.markNotificationAsRead(generalNotification2.getUuidId()); + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(wsClient.getLastUpdates()).extractingByKeys(generalSub, entityActionSub) + .allMatch(update -> update.getTotalUnreadCount() == 0 && update.getNotifications().isEmpty()); + assertThat(wsClient.getLastUpdates()).extractingByKey(alarmSub) + .matches(update -> update.getTotalUnreadCount() == 1 && update.getNotifications().size() == 1 + && update.getNotifications().get(0).getText().equals(alarmNotificationText2)); + }); + } + @Test public void testMarkingAsRead_multipleSessions() throws Exception { connectOtherWsClient(); diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiWsClient.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiWsClient.java index 1ecae638a6..358e98bed1 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiWsClient.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiWsClient.java @@ -21,6 +21,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.RandomUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.notification.Notification; +import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.controller.TbTestWebSocketClient; import org.thingsboard.server.service.ws.notification.cmd.MarkAllNotificationsAsReadCmd; import org.thingsboard.server.service.ws.notification.cmd.MarkNotificationsAsReadCmd; @@ -35,7 +36,10 @@ import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; @Slf4j @Getter @@ -48,18 +52,28 @@ public class NotificationApiWsClient extends TbTestWebSocketClient { private int unreadCount; private List notifications; + private final Map lastUpdates = new ConcurrentHashMap<>(); + public NotificationApiWsClient(String wsUrl) throws URISyntaxException { super(new URI(wsUrl + "/api/ws")); } - public NotificationApiWsClient subscribeForUnreadNotifications(int limit) { - send(new NotificationsSubCmd(1, limit)); + public NotificationApiWsClient subscribeForUnreadNotifications(int limit, NotificationType... types) { + send(new NotificationsSubCmd(newCmdId(), limit, Arrays.stream(types).collect(Collectors.toSet()))); this.limit = limit; return this; } + public int subscribeForUnreadNotificationsAndWait(int limit, NotificationType... types) { + int subId = newCmdId(); + send(new NotificationsSubCmd(subId, limit, Arrays.stream(types).collect(Collectors.toSet()))); + waitForReply(); + this.limit = limit; + return subId; + } + public NotificationApiWsClient subscribeForUnreadNotificationsCount() { - send(new NotificationsCountSubCmd(2)); + send(new NotificationsCountSubCmd(newCmdId())); return this; } @@ -84,6 +98,7 @@ public class NotificationApiWsClient extends TbTestWebSocketClient { CmdUpdateType updateType = CmdUpdateType.valueOf(update.get("cmdUpdateType").asText()); if (updateType == CmdUpdateType.NOTIFICATIONS) { lastDataUpdate = JacksonUtil.treeToValue(update, UnreadNotificationsUpdate.class); + lastUpdates.put(lastDataUpdate.getCmdId(), lastDataUpdate); unreadCount = lastDataUpdate.getTotalUnreadCount(); if (lastDataUpdate.getNotifications() != null) { notifications = new ArrayList<>(lastDataUpdate.getNotifications()); @@ -115,7 +130,7 @@ public class NotificationApiWsClient extends TbTestWebSocketClient { super.onMessage(s); } - private static int newCmdId() { + private int newCmdId() { return RandomUtils.nextInt(1, 1000); } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java index 22f3620d8c..59cca779f5 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java @@ -19,19 +19,34 @@ import com.google.common.collect.Sets; import lombok.extern.slf4j.Slf4j; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.QueueId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.TbQueueCallback; import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.DefaultTbQueueMsgHeaders; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.common.TbRuleEngineProducerService; import org.thingsboard.server.queue.discovery.PartitionService; @@ -44,13 +59,16 @@ import org.thingsboard.server.service.profile.TbDeviceProfileCache; import java.util.List; import java.util.UUID; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @Slf4j @@ -240,6 +258,99 @@ public class DefaultTbClusterServiceTest { .send(eq(topicService.getNotificationsTopic(ServiceType.TB_TRANSPORT, monolith2)), any(TbProtoQueueMsg.class), isNull()); } + @Test + public void testPushNotificationToCoreWithRestApiCallResponseMsgProto() { + TopicPartitionInfo tpi = mock(TopicPartitionInfo.class); + TbQueueCallback callbackMock = mock(TbQueueCallback.class); + TbQueueProducer> tbCoreQueueProducer = mock(TbQueueProducer.class); + + doReturn(tpi).when(topicService).getNotificationsTopic(any(ServiceType.class), any(String.class)); + when(producerProvider.getTbCoreNotificationsMsgProducer()).thenReturn(tbCoreQueueProducer); + TransportProtos.RestApiCallResponseMsgProto responseMsgProto = TransportProtos.RestApiCallResponseMsgProto.getDefaultInstance(); + TransportProtos.ToCoreNotificationMsg toCoreNotificationMsg = TransportProtos.ToCoreNotificationMsg.newBuilder().setRestApiCallResponseMsg(responseMsgProto).build(); + + clusterService.pushNotificationToCore(CORE, responseMsgProto, callbackMock); + + verify(topicService).getNotificationsTopic(ServiceType.TB_CORE, CORE); + verify(producerProvider).getTbCoreNotificationsMsgProducer(); + ArgumentCaptor> protoQueueMsgArgumentCaptor = ArgumentCaptor.forClass(TbProtoQueueMsg.class); + verify(tbCoreQueueProducer).send(eq(tpi), protoQueueMsgArgumentCaptor.capture(), eq(callbackMock)); + TbProtoQueueMsg protoQueueMsgArgumentCaptorValue = protoQueueMsgArgumentCaptor.getValue(); + assertThat(protoQueueMsgArgumentCaptorValue.getKey()).isNotNull(); + assertThat(protoQueueMsgArgumentCaptorValue.getValue()).isEqualTo(toCoreNotificationMsg); + assertThat(protoQueueMsgArgumentCaptorValue.getHeaders().getData()).isEqualTo(new DefaultTbQueueMsgHeaders().getData()); + } + + @Test + public void testPushMsgToRuleEngineWithTenantIdIsNullUuidAndEntityIsTenantUseQueueFromMsgIsTrue() { + TbQueueProducer> tbREQueueProducer = mock(TbQueueProducer.class); + TbQueueCallback callback = mock(TbQueueCallback.class); + + TenantId tenantId = TenantId.fromUUID(UUID.fromString("3c8bd350-1239-4a3b-b9c3-4dd76f8e20f1")); + TbMsg requestMsg = TbMsg.newMsg(DataConstants.HP_QUEUE_NAME, TbMsgType.REST_API_REQUEST, tenantId, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); + + when(producerProvider.getRuleEngineMsgProducer()).thenReturn(tbREQueueProducer); + + clusterService.pushMsgToRuleEngine(TenantId.SYS_TENANT_ID, tenantId, requestMsg, true, callback); + + verify(producerProvider).getRuleEngineMsgProducer(); + verify(ruleEngineProducerService).sendToRuleEngine(tbREQueueProducer, tenantId, requestMsg, callback); + } + + @Test + public void testPushMsgToRuleEngineWithTenantIdIsNullUuidAndEntityIsDevice() { + TenantId tenantId = TenantId.SYS_TENANT_ID; + DeviceId deviceId = new DeviceId(UUID.fromString("aa6d112d-2914-4a22-a9e3-bee33edbdb14")); + TbMsg requestMsg = TbMsg.newMsg(TbMsgType.REST_API_REQUEST, deviceId, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); + TbQueueCallback callback = mock(TbQueueCallback.class); + + clusterService.pushMsgToRuleEngine(tenantId, deviceId, requestMsg, false, callback); + + verifyNoMoreInteractions(partitionService, producerProvider); + } + + @Test + public void testPushMsgToRuleEngineWithTenantIdIsNotNullUuidUseQueueFromMsgIsTrue() { + TbQueueProducer> tbREQueueProducer = mock(TbQueueProducer.class); + TbQueueCallback callback = mock(TbQueueCallback.class); + + TenantId tenantId = TenantId.fromUUID(UUID.fromString("3c8bd350-1239-4a3b-b9c3-4dd76f8e20f1")); + DeviceId deviceId = new DeviceId(UUID.fromString("adbb9d41-3367-40fd-9e74-7dd7cc5d30cf")); + DeviceProfile deviceProfile = new DeviceProfile(new DeviceProfileId(UUID.fromString("552f5d6d-0b2b-43e1-a7d2-a51cb2a96927"))); + TbMsg requestMsg = TbMsg.newMsg(DataConstants.HP_QUEUE_NAME, TbMsgType.REST_API_REQUEST, deviceId, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); + + when(deviceProfileCache.get(any(TenantId.class), any(DeviceId.class))).thenReturn(deviceProfile); + when(producerProvider.getRuleEngineMsgProducer()).thenReturn(tbREQueueProducer); + + clusterService.pushMsgToRuleEngine(tenantId, deviceId, requestMsg, true, callback); + + verify(producerProvider).getRuleEngineMsgProducer(); + verify(ruleEngineProducerService).sendToRuleEngine(tbREQueueProducer, tenantId, requestMsg, callback); + } + + @Test + public void testPushMsgToRuleEngineUseQueueFromMsgIsFalse() { + TbQueueProducer> tbREQueueProducer = mock(TbQueueProducer.class); + TbQueueCallback callback = mock(TbQueueCallback.class); + + TenantId tenantId = TenantId.fromUUID(UUID.fromString("5377c8d0-26e5-4d81-84c6-4344043973c8")); + DeviceId deviceId = new DeviceId(UUID.fromString("016c2abb-f46f-49f9-a83d-4d28b803cfe6")); + DeviceProfile deviceProfile = new DeviceProfile(new DeviceProfileId(UUID.fromString("dc5766e2-1a32-4022-859b-743050097ab7"))); + deviceProfile.setDefaultQueueName(DataConstants.MAIN_QUEUE_NAME); + TbMsg requestMsg = TbMsg.newMsg(TbMsgType.REST_API_REQUEST, deviceId, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); + + when(deviceProfileCache.get(any(TenantId.class), any(DeviceId.class))).thenReturn(deviceProfile); + when(producerProvider.getRuleEngineMsgProducer()).thenReturn(tbREQueueProducer); + + clusterService.pushMsgToRuleEngine(tenantId, deviceId, requestMsg, false, callback); + + verify(producerProvider).getRuleEngineMsgProducer(); + TbMsg expectedMsg = TbMsg.transformMsgQueueName(requestMsg, DataConstants.MAIN_QUEUE_NAME); + ArgumentCaptor actualMsg = ArgumentCaptor.forClass(TbMsg.class); + verify(ruleEngineProducerService).sendToRuleEngine(eq(tbREQueueProducer), eq(tenantId), actualMsg.capture(), eq(callback)); + assertThat(actualMsg.getValue()).usingRecursiveComparison().ignoringFields("ctx").isEqualTo(expectedMsg); + } + protected Queue createTestQueue() { TenantId tenantId = TenantId.SYS_TENANT_ID; Queue queue = new Queue(new QueueId(UUID.randomUUID())); @@ -249,4 +360,44 @@ public class DefaultTbClusterServiceTest { queue.setPartitions(10); return queue; } + + @Test + public void testGetRuleEngineProfileForUpdatedAndDeletedDevice() { + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + TenantId tenantId = new TenantId(UUID.randomUUID()); + DeviceProfileId deviceProfileId = new DeviceProfileId(UUID.randomUUID()); + + Device device = new Device(deviceId); + device.setDeviceProfileId(deviceProfileId); + + // device updated + TbMsg tbMsg = TbMsg.builder().internalType(TbMsgType.ENTITY_UPDATED).build(); + ((DefaultTbClusterService) clusterService).getRuleEngineProfileForEntityOrElseNull(tenantId, deviceId, tbMsg); + verify(deviceProfileCache, times(1)).get(tenantId, deviceId); + + // device deleted + tbMsg = TbMsg.builder().internalType(TbMsgType.ENTITY_DELETED).data(JacksonUtil.toString(device)).build(); + ((DefaultTbClusterService) clusterService).getRuleEngineProfileForEntityOrElseNull(tenantId, deviceId, tbMsg); + verify(deviceProfileCache, times(1)).get(tenantId, deviceProfileId); + } + + @Test + public void testGetRuleEngineProfileForUpdatedAndDeletedAsset() { + AssetId assetId = new AssetId(UUID.randomUUID()); + TenantId tenantId = new TenantId(UUID.randomUUID()); + AssetProfileId assetProfileId = new AssetProfileId(UUID.randomUUID()); + + Asset asset = new Asset(assetId); + asset.setAssetProfileId(assetProfileId); + + // asset updated + TbMsg tbMsg = TbMsg.builder().internalType(TbMsgType.ENTITY_UPDATED).build(); + ((DefaultTbClusterService) clusterService).getRuleEngineProfileForEntityOrElseNull(tenantId, assetId, tbMsg); + verify(assetProfileCache, times(1)).get(tenantId, assetId); + + // asset deleted + tbMsg = TbMsg.builder().internalType(TbMsgType.ENTITY_DELETED).data(JacksonUtil.toString(asset)).build(); + ((DefaultTbClusterService) clusterService).getRuleEngineProfileForEntityOrElseNull(tenantId, assetId, tbMsg); + verify(assetProfileCache, times(1)).get(tenantId, assetProfileId); + } } \ No newline at end of file diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java index 621d4c5a91..ca6e6728ff 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.service.ruleengine.RuleEngineCallService; import org.thingsboard.server.service.state.DeviceStateService; import java.util.UUID; @@ -48,6 +49,8 @@ public class DefaultTbCoreConsumerServiceTest { private DeviceStateService stateServiceMock; @Mock private TbCoreConsumerStats statsMock; + @Mock + private RuleEngineCallService ruleEngineCallServiceMock; @Mock private TbCallback tbCallbackMock; @@ -529,4 +532,17 @@ public class DefaultTbCoreConsumerServiceTest { then(statsMock).should(never()).log(inactivityMsg); } + @Test + public void givenRestApiCallResponseMsgProto_whenForwardToRuleEngineCallService_thenCallOnQueueMsg() { + // GIVEN + ReflectionTestUtils.setField(defaultTbCoreConsumerServiceMock, "ruleEngineCallService", ruleEngineCallServiceMock); + var restApiCallResponseMsgProto = TransportProtos.RestApiCallResponseMsgProto.getDefaultInstance(); + doCallRealMethod().when(defaultTbCoreConsumerServiceMock).forwardToRuleEngineCallService(restApiCallResponseMsgProto, tbCallbackMock); + + // WHEN + defaultTbCoreConsumerServiceMock.forwardToRuleEngineCallService(restApiCallResponseMsgProto, tbCallbackMock); + + // THEN + then(ruleEngineCallServiceMock).should().onQueueMsg(restApiCallResponseMsgProto, tbCallbackMock); + } } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java new file mode 100644 index 0000000000..098c622e31 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java @@ -0,0 +1,286 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.ruleengine; + +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.queue.ProcessingStrategy; +import org.thingsboard.server.common.data.queue.ProcessingStrategyType; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.common.data.queue.SubmitStrategy; +import org.thingsboard.server.common.data.queue.SubmitStrategyType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgDataType; +import org.thingsboard.server.common.msg.TbMsgProcessingCtx; +import org.thingsboard.server.common.msg.queue.QueueToRuleEngineMsg; +import org.thingsboard.server.common.msg.queue.RuleEngineException; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; +import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.thingsboard.server.common.data.queue.ProcessingStrategyType.RETRY_ALL; +import static org.thingsboard.server.common.data.queue.ProcessingStrategyType.RETRY_FAILED; +import static org.thingsboard.server.common.data.queue.ProcessingStrategyType.RETRY_FAILED_AND_TIMED_OUT; +import static org.thingsboard.server.common.data.queue.ProcessingStrategyType.RETRY_TIMED_OUT; +import static org.thingsboard.server.common.data.queue.ProcessingStrategyType.SKIP_ALL_FAILURES; +import static org.thingsboard.server.common.data.queue.ProcessingStrategyType.SKIP_ALL_FAILURES_AND_TIMED_OUT; +import static org.thingsboard.server.common.data.queue.SubmitStrategyType.BATCH; +import static org.thingsboard.server.common.data.queue.SubmitStrategyType.BURST; +import static org.thingsboard.server.common.data.queue.SubmitStrategyType.SEQUENTIAL_BY_ORIGINATOR; + +@Slf4j +@MockitoSettings(strictness = Strictness.LENIENT) +public class TbRuleEngineStrategyTest { + + @Mock + private ActorSystemContext actorContext; + @Mock + private TbQueueConsumer> consumer; + private TbRuleEngineConsumerContext ruleEngineConsumerContext; + + private static UUID tenantId = UUID.randomUUID(); + private static DeviceId deviceId = new DeviceId(UUID.randomUUID()); + + @BeforeEach + public void beforeEach() { + ruleEngineConsumerContext = new TbRuleEngineConsumerContext( + actorContext, mock(), new TbRuleEngineSubmitStrategyFactory(), + new TbRuleEngineProcessingStrategyFactory(), mock(), mock(), + mock(), mock(), mock(), mock() + ); + when(consumer.isStopped()).thenReturn(false); + } + + private static Stream testData() { + return Stream.of( + //BURST + Arguments.of(BURST, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BURST, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BURST, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(1), new ProcessingData(1))), + + Arguments.of(BURST, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BURST, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BURST, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(1), new ProcessingData(1))), + + Arguments.of(BURST, RETRY_ALL, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BURST, RETRY_ALL, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(2), new ProcessingData(2))), + Arguments.of(BURST, RETRY_ALL, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(2), new ProcessingData(2))), + + Arguments.of(BURST, RETRY_FAILED, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BURST, RETRY_FAILED, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BURST, RETRY_FAILED, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(1), new ProcessingData(1))), + + Arguments.of(BURST, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BURST, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BURST, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(1), new ProcessingData(1))), + + Arguments.of(BURST, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BURST, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BURST, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(1), new ProcessingData(1))), + + //BATCH + Arguments.of(BATCH, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BATCH, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(1), new ProcessingData(0))), + Arguments.of(BATCH, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(1), new ProcessingData(0))), + + Arguments.of(BATCH, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BATCH, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(1), new ProcessingData(0))), + Arguments.of(BATCH, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(1), new ProcessingData(0))), + + Arguments.of(BATCH, RETRY_ALL, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BATCH, RETRY_ALL, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(2), new ProcessingData(1))), + Arguments.of(BATCH, RETRY_ALL, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(2), new ProcessingData(1))), + + Arguments.of(BATCH, RETRY_FAILED, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BATCH, RETRY_FAILED, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(1), new ProcessingData(0))), + Arguments.of(BATCH, RETRY_FAILED, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(1), new ProcessingData(0))), + + Arguments.of(BATCH, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BATCH, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BATCH, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(1), new ProcessingData(1))), + + Arguments.of(BATCH, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BATCH, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(BATCH, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(1), new ProcessingData(1))), + + //SEQUENTIAL_BY_ORIGINATOR + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(0), new ProcessingData(0))), + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(0), new ProcessingData(0))), + + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(0), new ProcessingData(0))), + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(0), new ProcessingData(0))), + + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_ALL, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_ALL, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_ALL, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(1), new ProcessingData(1))), + + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_FAILED, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_FAILED, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(0), new ProcessingData(0))), + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_FAILED, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(0), new ProcessingData(0))), + + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(1), new ProcessingData(1))), + + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(1), new ProcessingData(1))), + Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(1), new ProcessingData(1))) + ); + } + + @ParameterizedTest + @MethodSource("testData") + public void processMsgsTest(SubmitStrategyType submitStrategyType, ProcessingStrategyType processingStrategyType, int retries, List processingData) throws Exception { + var queue = new Queue(); + queue.setName("Test"); + queue.setPackProcessingTimeout(100); + SubmitStrategy submitStrategy = new SubmitStrategy(); + submitStrategy.setType(submitStrategyType); + submitStrategy.setBatchSize(2); + queue.setSubmitStrategy(submitStrategy); + ProcessingStrategy processingStrategy = new ProcessingStrategy(); + processingStrategy.setType(processingStrategyType); + processingStrategy.setRetries(retries); + queue.setProcessingStrategy(processingStrategy); + + QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, queue); + var consumerManager = TbRuleEngineQueueConsumerManager.create() + .ctx(ruleEngineConsumerContext) + .queueKey(queueKey) + .build(); + + consumerManager.init(queue); + + Map msgsMap = processingData.stream().collect(Collectors.toMap(data -> data.tbMsg.getId(), data -> data)); + Map attemptsMap = new HashMap<>(); + Map failedMap = new HashMap<>(); + Map timeoutMap = new HashMap<>(); + + doAnswer(inv -> { + QueueToRuleEngineMsg msg = inv.getArgument(0); + + UUID msgId = msg.getMsg().getId(); + var data = msgsMap.get(msgId); + + var attempts = attemptsMap.computeIfAbsent(msgId, key -> new AtomicInteger(0)); + Assertions.assertTrue(attempts.getAndIncrement() <= data.attempts); + + var callback = msg.getMsg().getCallback(); + + if (data.shouldFailed) { + var alreadyFailed = failedMap.computeIfAbsent(msgId, key -> new AtomicBoolean(false)); + if (!alreadyFailed.getAndSet(true)) { + callback.onFailure(new RuleEngineException("Failed to process test msg!")); + return null; + } + } + + if (data.shouldTimeout) { + var alreadyTimedOuted = timeoutMap.computeIfAbsent(msgId, key -> new AtomicBoolean(false)); + if (!alreadyTimedOuted.getAndSet(true)) { + return null; + } + } + + callback.onSuccess(); + return null; + }).when(actorContext).tell(any()); + + List> protoMsgs = processingData.stream() + .map(data -> data.tbMsg) + .map(this::toProto) + .toList(); + + consumerManager.processMsgs(protoMsgs, consumer, queue); + + processingData.forEach(data -> { + verify(actorContext, times(data.attempts)).tell(argThat(msg -> + ((QueueToRuleEngineMsg) msg).getMsg().getId().equals(data.tbMsg.getId()) + )); + }); + } + + private static TbMsg createRandomMsg() { + return TbMsg.builder() + .id(UUID.randomUUID()) + .type("test type") + .originator(deviceId) + .dataType(TbMsgDataType.TEXT) + .data("test data") + .ctx(new TbMsgProcessingCtx()) + .build(); + } + + private TbProtoQueueMsg toProto(TbMsg tbMsg) { + return new TbProtoQueueMsg<>(UUID.randomUUID(), ToRuleEngineMsg.newBuilder() + .setTenantIdMSB(tenantId.getMostSignificantBits()) + .setTenantIdLSB(tenantId.getLeastSignificantBits()) + .addRelationTypes("Success") + .setTbMsg(TbMsg.toByteString(tbMsg)) + .build()); + } + + @RequiredArgsConstructor + @EqualsAndHashCode + @ToString(exclude = "tbMsg") + private static class ProcessingData { + private final TbMsg tbMsg = createRandomMsg(); + private final boolean shouldFailed; + private final boolean shouldTimeout; + private final int attempts; + + public ProcessingData(int attempts) { + shouldFailed = false; + shouldTimeout = false; + this.attempts = attempts; + } + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/rpc/DefaultTbRuleEngineRpcServiceTest.java b/application/src/test/java/org/thingsboard/server/service/rpc/DefaultTbRuleEngineRpcServiceTest.java new file mode 100644 index 0000000000..0ed53b9561 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/rpc/DefaultTbRuleEngineRpcServiceTest.java @@ -0,0 +1,61 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.rpc; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.gen.transport.TransportProtos; +import java.util.UUID; + +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class DefaultTbRuleEngineRpcServiceTest { + + @Mock + private TbClusterService tbClusterServiceMock; + + @InjectMocks + private DefaultTbRuleEngineRpcService tbRuleEngineRpcService; + + @Test + public void givenTbMsg_whenSendRestApiCallReply_thenPushNotificationToCore() { + // GIVEN + String serviceId = "tb-core-0"; + UUID requestId = UUID.fromString("f64a20df-eb1e-46a3-ba6f-0b3ae053ee0a"); + DeviceId deviceId = new DeviceId(UUID.fromString("1d9f771a-7cdc-4ac7-838c-ba193d05a012")); + TbMsg msg = TbMsg.newMsg(TbMsgType.REST_API_REQUEST, deviceId, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); + var restApiCallResponseMsgProto = TransportProtos.RestApiCallResponseMsgProto.newBuilder() + .setRequestIdMSB(requestId.getMostSignificantBits()) + .setRequestIdLSB(requestId.getLeastSignificantBits()) + .setResponse(TbMsg.toByteString(msg)) + .build(); + + // WHEN + tbRuleEngineRpcService.sendRestApiCallReply(serviceId, requestId, msg); + + // THEN + then(tbClusterServiceMock).should().pushNotificationToCore(serviceId, restApiCallResponseMsgProto, null); + } +} diff --git a/application/src/test/java/org/thingsboard/server/service/ruleengine/DefaultRuleEngineCallServiceTest.java b/application/src/test/java/org/thingsboard/server/service/ruleengine/DefaultRuleEngineCallServiceTest.java new file mode 100644 index 0000000000..282563574f --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/ruleengine/DefaultRuleEngineCallServiceTest.java @@ -0,0 +1,141 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.ruleengine; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.testcontainers.shaded.org.awaitility.Awaitility; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.gen.transport.TransportProtos; + +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +public class DefaultRuleEngineCallServiceTest { + + private static final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("d7210c7f-a152-4e91-8186-19ae85499a6b")); + + private final ConcurrentMap> requests = new ConcurrentHashMap<>(); + + @Mock + private TbClusterService tbClusterServiceMock; + + private DefaultRuleEngineCallService ruleEngineCallService; + private ScheduledExecutorService executor; + + @BeforeEach + void setUp() { + executor = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("re-rest-callback")); + ruleEngineCallService = new DefaultRuleEngineCallService(tbClusterServiceMock); + ReflectionTestUtils.setField(ruleEngineCallService, "executor", executor); + ReflectionTestUtils.setField(ruleEngineCallService, "requests", requests); + } + + @AfterEach + void tearDown() { + requests.clear(); + if (executor != null) { + executor.shutdownNow(); + } + } + + @Test + void givenRequest_whenProcessRestApiCallToRuleEngine_thenPushMsgToRuleEngineAndCheckRemovedDueTimeout() { + long timeout = 1L; + long expTime = System.currentTimeMillis() + timeout; + HashMap metaData = new HashMap<>(); + UUID requestId = UUID.randomUUID(); + metaData.put("serviceId", "core"); + metaData.put("requestUUID", requestId.toString()); + metaData.put("expirationTime", Long.toString(expTime)); + TbMsg msg = TbMsg.newMsg(DataConstants.MAIN_QUEUE_NAME, TbMsgType.REST_API_REQUEST, TENANT_ID, new TbMsgMetaData(metaData), "{\"key\":\"value\"}"); + + Consumer anyConsumer = TbMsg::getData; + doAnswer(invocation -> { + //check the presence of request in the map after pushMsgToRuleEngine() + assertThat(requests.size()).isEqualTo(1); + assertThat(requests.get(requestId)).isEqualTo(anyConsumer); + return null; + }).when(tbClusterServiceMock).pushMsgToRuleEngine(any(), any(), any(), anyBoolean(), any()); + ruleEngineCallService.processRestApiCallToRuleEngine(TENANT_ID, requestId, msg, true, anyConsumer); + + verify(tbClusterServiceMock).pushMsgToRuleEngine(TENANT_ID, TENANT_ID, msg, true, null); + //check map is empty after scheduleTimeout() + Awaitility.await("Await until request was deleted from map due to timeout") + .pollDelay(25, TimeUnit.MILLISECONDS) + .atMost(10, TimeUnit.SECONDS) + .until(requests::isEmpty); + } + + @Test + void givenResponse_whenOnQueue_thenAcceptTbMsgResponse() { + long timeout = 10000L; + long expTime = System.currentTimeMillis() + timeout; + HashMap metaData = new HashMap<>(); + UUID requestId = UUID.randomUUID(); + metaData.put("serviceId", "core"); + metaData.put("requestUUID", requestId.toString()); + metaData.put("expirationTime", Long.toString(expTime)); + TbMsg msg = TbMsg.newMsg(DataConstants.MAIN_QUEUE_NAME, TbMsgType.REST_API_REQUEST, TENANT_ID, new TbMsgMetaData(metaData), "{\"key\":\"value\"}"); + + Consumer anyConsumer = TbMsg::getData; + doAnswer(invocation -> { + //check the presence of request in the map after pushMsgToRuleEngine() + assertThat(requests.size()).isEqualTo(1); + assertThat(requests.get(requestId)).isEqualTo(anyConsumer); + ruleEngineCallService.onQueueMsg(getResponse(requestId, msg), TbCallback.EMPTY); + //check map is empty after onQueueMsg() + assertThat(requests.size()).isEqualTo(0); + return null; + }).when(tbClusterServiceMock).pushMsgToRuleEngine(any(), any(), any(), anyBoolean(), any()); + ruleEngineCallService.processRestApiCallToRuleEngine(TENANT_ID, requestId, msg, true, anyConsumer); + + verify(tbClusterServiceMock).pushMsgToRuleEngine(TENANT_ID, TENANT_ID, msg, true, null); + } + + private TransportProtos.RestApiCallResponseMsgProto getResponse(UUID requestId, TbMsg msg) { + return TransportProtos.RestApiCallResponseMsgProto.newBuilder() + .setResponse(TbMsg.toByteString(msg)) + .setRequestIdMSB(requestId.getMostSignificantBits()) + .setRequestIdLSB(requestId.getLeastSignificantBits()) + .build(); + } +} diff --git a/application/src/test/java/org/thingsboard/server/service/script/NashornJsInvokeServiceTest.java b/application/src/test/java/org/thingsboard/server/service/script/NashornJsInvokeServiceTest.java index 28834a0ab7..33e7ccea23 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/NashornJsInvokeServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/NashornJsInvokeServiceTest.java @@ -39,12 +39,13 @@ import java.util.concurrent.TimeoutException; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_REQUEST; @DaoSqlTest @TestPropertySource(properties = { "js.evaluator=local", - "js.max_script_body_size=50", + "js.max_script_body_size=10000", "js.max_total_args_size=50", "js.max_result_size=50", "js.local.max_errors=2", @@ -87,7 +88,7 @@ class NashornJsInvokeServiceTest extends AbstractControllerTest { @Test void givenSimpleScriptMultiThreadTestPerformance() throws ExecutionException, InterruptedException, TimeoutException { - int iterations = 1000*4; + int iterations = 1000 * 4; List> futures = new ArrayList<>(iterations); UUID scriptId = evalScript("return msg.temperature > 20 ;"); // warmup @@ -125,7 +126,7 @@ class NashornJsInvokeServiceTest extends AbstractControllerTest { @Test void givenTooBigScriptForEval_thenReturnError() { - String hugeScript = "var a = 'qwertyqwertywertyqwabababer'; return {a: a};"; + String hugeScript = "var a = '" + "a".repeat(10000) + "'; return {a: a};"; assertThatThrownBy(() -> { evalScript(hugeScript); @@ -159,6 +160,46 @@ class NashornJsInvokeServiceTest extends AbstractControllerTest { assertThatScriptIsBlocked(scriptId); } + @Test + void givenComplexScript_testCompile() { + String script = """ + function(data) { + if (data.get("propertyA") == "a special value 1" || data.get("propertyA") == "a special value 2") { + return "a special value 1"; + } else if (data.get("propertyB") == "a special value 3" && (data.get("propertyC") == "a special value 1" || data.get("propertyJ") == "a special value 1" || data.get("propertyV") == "a special value 1")) { + return "a special value 1"; + } else if (data.get("propertyB") == "4" && (data.get("propertyD") == "a special value 1" || data.get("propertyV") == "a special value 1" || data.get("propertyW") == "a special value 1")) { + return "a special value 1"; + } else if (data.get("propertyB") == "a special value 2" && (data.get("propertyE") == "a special value 1" || data.get("propertyF") == "a special value 1" || data.get("propertyL") == "a special value 1")) { + return "a special value 1"; + } else if (data.get("propertyB") == "a special value 3" && (data.get("propertyE") == "a special value 1" || data.get("propertyF") == "a special value 1" || data.get("propertyL") == "a special value 1")) { + return "a special value 1"; + } else if (data.get("propertyB") == "a special value 3" && (data.get("propertyM") == "a special value 1" || data.get("propertyY") == "a special value 1" || data.get("propertyH") == "a special value 1")) { + return "a special value 1"; + } else if (data.get("propertyB") == "a special value 3" && (data.get("propertyM") == "a special value 1" || data.get("propertyY") == "a special value 1" || data.get("propertyH") == "a special value 1")) { + return "a special value 1"; + } else if (data.get("propertyB") == "a special value 3" && (data.get("propertyM") == "a special value 1" || data.get("propertyY") == "a special value 1" || data.get("propertyH") == "a special value 1")) { + return "a special value 1"; + } else if (data.get("propertyB") == "a special value 3" && (data.get("propertyM") == "a special value 1" || data.get("propertyY") == "a special value 1" || data.get("propertyH") == "a special value 1")) { + return "a special value 1"; + } else if (data.get("propertyB") == "a special value 3" && (data.get("propertyM") == "a special value 1" || data.get("propertyY") == "a special value 1" || data.get("propertyH") == "a special value 1")) { + return "a special value 1"; + } else if (data.get("propertyB") == "a special value 3" && (data.get("propertyM") == "a special value 1" || data.get("propertyY") == "a special value 1" || data.get("propertyH") == "a special value 1")) { + return "a special value 1"; + } else if (data.get("propertyB") == "a special value 3" && (data.get("propertyM") == "a special value 1" || data.get("propertyY") == "a special value 1" || data.get("propertyH") == "a special value 1")) { + return "a special value 1"; + } else { + return "0" + }; + } + """; + + // with delight-nashorn-sandbox 0.4.2, this would throw delight.nashornsandbox.exceptions.ScriptCPUAbuseException: Regular expression running for too many iterations. The operation could NOT be gracefully interrupted. + assertDoesNotThrow(() -> { + evalScript(script); + }); + } + private void assertThatScriptIsBlocked(UUID scriptId) { assertThatThrownBy(() -> { invokeScript(scriptId, "{}"); diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtilsTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtilsTest.java index 7a2a97c169..9f77d1dbc2 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtilsTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtilsTest.java @@ -15,12 +15,12 @@ */ package org.thingsboard.server.service.security.auth.oauth2; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; import org.junit.Test; import org.mockito.Mockito; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; import java.util.LinkedHashMap; import java.util.Map; diff --git a/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java b/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java index bae67fe3a3..f16c5211ae 100644 --- a/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java @@ -27,7 +27,6 @@ import org.springframework.test.context.TestPropertySource; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AdminSettings; -import org.thingsboard.server.common.data.FeaturesInfo; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; diff --git a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java index f80c8c4c82..9833396a0a 100644 --- a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java @@ -27,7 +27,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.server.cluster.TbClusterService; -import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DeviceIdInfo; import org.thingsboard.server.common.data.id.DeviceId; @@ -79,7 +78,6 @@ import static org.mockito.Mockito.reset; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.thingsboard.server.common.data.DataConstants.SERVER_SCOPE; import static org.thingsboard.server.service.state.DefaultDeviceStateService.ACTIVITY_STATE; import static org.thingsboard.server.service.state.DefaultDeviceStateService.INACTIVITY_ALARM_TIME; import static org.thingsboard.server.service.state.DefaultDeviceStateService.INACTIVITY_TIMEOUT; diff --git a/application/src/test/java/org/thingsboard/server/service/sync/ie/BaseExportImportServiceTest.java b/application/src/test/java/org/thingsboard/server/service/sync/ie/BaseExportImportServiceTest.java deleted file mode 100644 index 9c5de39161..0000000000 --- a/application/src/test/java/org/thingsboard/server/service/sync/ie/BaseExportImportServiceTest.java +++ /dev/null @@ -1,478 +0,0 @@ -/** - * Copyright © 2016-2024 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.service.sync.ie; - -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fasterxml.jackson.databind.node.TextNode; -import org.junit.After; -import org.junit.Before; -import org.springframework.beans.factory.annotation.Autowired; -import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.rule.engine.debug.TbMsgGeneratorNode; -import org.thingsboard.rule.engine.debug.TbMsgGeneratorNodeConfiguration; -import org.thingsboard.rule.engine.metadata.TbGetAttributesNode; -import org.thingsboard.rule.engine.metadata.TbGetAttributesNodeConfiguration; -import org.thingsboard.server.common.data.Customer; -import org.thingsboard.server.common.data.Dashboard; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.DeviceProfile; -import org.thingsboard.server.common.data.DeviceProfileType; -import org.thingsboard.server.common.data.DeviceTransportType; -import org.thingsboard.server.common.data.EntityView; -import org.thingsboard.server.common.data.ExportableEntity; -import org.thingsboard.server.common.data.HasTenantId; -import org.thingsboard.server.common.data.OtaPackage; -import org.thingsboard.server.common.data.Tenant; -import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.asset.Asset; -import org.thingsboard.server.common.data.asset.AssetProfile; -import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; -import org.thingsboard.server.common.data.device.data.DeviceData; -import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; -import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileTransportConfiguration; -import org.thingsboard.server.common.data.device.profile.DeviceProfileData; -import org.thingsboard.server.common.data.id.AssetId; -import org.thingsboard.server.common.data.id.AssetProfileId; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DashboardId; -import org.thingsboard.server.common.data.id.DeviceProfileId; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.id.RuleChainId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.msg.TbNodeConnectionType; -import org.thingsboard.server.common.data.ota.ChecksumAlgorithm; -import org.thingsboard.server.common.data.ota.OtaPackageType; -import org.thingsboard.server.common.data.relation.EntityRelation; -import org.thingsboard.server.common.data.relation.RelationTypeGroup; -import org.thingsboard.server.common.data.rule.RuleChain; -import org.thingsboard.server.common.data.rule.RuleChainMetaData; -import org.thingsboard.server.common.data.rule.RuleChainType; -import org.thingsboard.server.common.data.rule.RuleNode; -import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.common.data.sync.ie.EntityExportData; -import org.thingsboard.server.common.data.sync.ie.EntityExportSettings; -import org.thingsboard.server.common.data.sync.ie.EntityImportResult; -import org.thingsboard.server.common.data.sync.ie.EntityImportSettings; -import org.thingsboard.server.common.data.util.ThrowingRunnable; -import org.thingsboard.server.controller.AbstractControllerTest; -import org.thingsboard.server.dao.asset.AssetProfileService; -import org.thingsboard.server.dao.asset.AssetService; -import org.thingsboard.server.dao.customer.CustomerService; -import org.thingsboard.server.dao.dashboard.DashboardService; -import org.thingsboard.server.dao.device.DeviceProfileService; -import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.dao.entityview.EntityViewService; -import org.thingsboard.server.dao.ota.OtaPackageService; -import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.dao.rule.RuleChainService; -import org.thingsboard.server.dao.tenant.TenantService; -import org.thingsboard.server.service.security.model.SecurityUser; -import org.thingsboard.server.service.security.model.UserPrincipal; -import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; -import org.thingsboard.server.service.sync.vc.data.SimpleEntitiesExportCtx; - -import java.nio.ByteBuffer; -import java.util.Arrays; -import java.util.Collections; -import java.util.UUID; -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; - -public abstract class BaseExportImportServiceTest extends AbstractControllerTest { - - @Autowired - protected EntitiesExportImportService exportImportService; - @Autowired - protected DeviceService deviceService; - @Autowired - protected OtaPackageService otaPackageService; - @Autowired - protected DeviceProfileService deviceProfileService; - @Autowired - protected AssetProfileService assetProfileService; - @Autowired - protected AssetService assetService; - @Autowired - protected CustomerService customerService; - @Autowired - protected RuleChainService ruleChainService; - @Autowired - protected DashboardService dashboardService; - @Autowired - protected RelationService relationService; - @Autowired - protected TenantService tenantService; - @Autowired - protected EntityViewService entityViewService; - - protected TenantId tenantId1; - protected User tenantAdmin1; - - protected TenantId tenantId2; - protected User tenantAdmin2; - - @Before - public void beforeEach() throws Exception { - loginSysAdmin(); - Tenant tenant1 = new Tenant(); - tenant1.setTitle("Tenant 1"); - tenant1.setEmail("tenant1@thingsboard.org"); - this.tenantId1 = tenantService.saveTenant(tenant1).getId(); - User tenantAdmin1 = new User(); - tenantAdmin1.setTenantId(tenantId1); - tenantAdmin1.setAuthority(Authority.TENANT_ADMIN); - tenantAdmin1.setEmail("tenant1-admin@thingsboard.org"); - this.tenantAdmin1 = createUser(tenantAdmin1, "12345678"); - Tenant tenant2 = new Tenant(); - tenant2.setTitle("Tenant 2"); - tenant2.setEmail("tenant2@thingsboard.org"); - this.tenantId2 = tenantService.saveTenant(tenant2).getId(); - User tenantAdmin2 = new User(); - tenantAdmin2.setTenantId(tenantId2); - tenantAdmin2.setAuthority(Authority.TENANT_ADMIN); - tenantAdmin2.setEmail("tenant2-admin@thingsboard.org"); - this.tenantAdmin2 = createUser(tenantAdmin2, "12345678"); - } - - @After - public void afterEach() { - tenantService.deleteTenant(tenantId1); - tenantService.deleteTenant(tenantId2); - } - - protected Device createDevice(TenantId tenantId, CustomerId customerId, DeviceProfileId deviceProfileId, String name) { - Device device = new Device(); - device.setTenantId(tenantId); - device.setCustomerId(customerId); - device.setName(name); - device.setLabel("lbl"); - device.setDeviceProfileId(deviceProfileId); - DeviceData deviceData = new DeviceData(); - deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); - device.setDeviceData(deviceData); - return deviceService.saveDevice(device); - } - - protected OtaPackage createOtaPackage(TenantId tenantId, DeviceProfileId deviceProfileId, OtaPackageType type) { - OtaPackage otaPackage = new OtaPackage(); - otaPackage.setTenantId(tenantId); - otaPackage.setDeviceProfileId(deviceProfileId); - otaPackage.setType(type); - otaPackage.setTitle("My " + type); - otaPackage.setVersion("v1.0"); - otaPackage.setFileName("filename.txt"); - otaPackage.setContentType("text/plain"); - otaPackage.setChecksumAlgorithm(ChecksumAlgorithm.SHA256); - otaPackage.setChecksum("4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a"); - otaPackage.setDataSize(1L); - otaPackage.setData(ByteBuffer.wrap(new byte[]{(int) 1})); - return otaPackageService.saveOtaPackage(otaPackage); - } - - protected void checkImportedDeviceData(Device initialDevice, Device importedDevice) { - assertThat(importedDevice.getName()).isEqualTo(initialDevice.getName()); - assertThat(importedDevice.getType()).isEqualTo(initialDevice.getType()); - assertThat(importedDevice.getDeviceData()).isEqualTo(initialDevice.getDeviceData()); - assertThat(importedDevice.getLabel()).isEqualTo(initialDevice.getLabel()); - } - - protected DeviceProfile createDeviceProfile(TenantId tenantId, RuleChainId defaultRuleChainId, DashboardId defaultDashboardId, String name) { - DeviceProfile deviceProfile = new DeviceProfile(); - deviceProfile.setTenantId(tenantId); - deviceProfile.setName(name); - deviceProfile.setDescription("dscrptn"); - deviceProfile.setType(DeviceProfileType.DEFAULT); - deviceProfile.setTransportType(DeviceTransportType.DEFAULT); - deviceProfile.setDefaultRuleChainId(defaultRuleChainId); - deviceProfile.setDefaultDashboardId(defaultDashboardId); - DeviceProfileData profileData = new DeviceProfileData(); - profileData.setConfiguration(new DefaultDeviceProfileConfiguration()); - profileData.setTransportConfiguration(new DefaultDeviceProfileTransportConfiguration()); - deviceProfile.setProfileData(profileData); - return deviceProfileService.saveDeviceProfile(deviceProfile); - } - - protected void checkImportedDeviceProfileData(DeviceProfile initialProfile, DeviceProfile importedProfile) { - assertThat(initialProfile.getName()).isEqualTo(importedProfile.getName()); - assertThat(initialProfile.getType()).isEqualTo(importedProfile.getType()); - assertThat(initialProfile.getTransportType()).isEqualTo(importedProfile.getTransportType()); - assertThat(initialProfile.getProfileData()).isEqualTo(importedProfile.getProfileData()); - assertThat(initialProfile.getDescription()).isEqualTo(importedProfile.getDescription()); - } - - protected AssetProfile createAssetProfile(TenantId tenantId, RuleChainId defaultRuleChainId, DashboardId defaultDashboardId, String name) { - AssetProfile assetProfile = new AssetProfile(); - assetProfile.setTenantId(tenantId); - assetProfile.setName(name); - assetProfile.setDescription("dscrptn"); - assetProfile.setDefaultRuleChainId(defaultRuleChainId); - assetProfile.setDefaultDashboardId(defaultDashboardId); - return assetProfileService.saveAssetProfile(assetProfile); - } - - protected void checkImportedAssetProfileData(AssetProfile initialProfile, AssetProfile importedProfile) { - assertThat(initialProfile.getName()).isEqualTo(importedProfile.getName()); - assertThat(initialProfile.getDescription()).isEqualTo(importedProfile.getDescription()); - } - - protected Asset createAsset(TenantId tenantId, CustomerId customerId, AssetProfileId assetProfileId, String name) { - Asset asset = new Asset(); - asset.setTenantId(tenantId); - asset.setCustomerId(customerId); - asset.setAssetProfileId(assetProfileId); - asset.setName(name); - asset.setLabel("lbl"); - asset.setAdditionalInfo(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); - return assetService.saveAsset(asset); - } - - protected void checkImportedAssetData(Asset initialAsset, Asset importedAsset) { - assertThat(importedAsset.getName()).isEqualTo(initialAsset.getName()); - assertThat(importedAsset.getType()).isEqualTo(initialAsset.getType()); - assertThat(importedAsset.getLabel()).isEqualTo(initialAsset.getLabel()); - assertThat(importedAsset.getAdditionalInfo()).isEqualTo(initialAsset.getAdditionalInfo()); - } - - protected Customer createCustomer(TenantId tenantId, String name) { - Customer customer = new Customer(); - customer.setTenantId(tenantId); - customer.setTitle(name); - customer.setCountry("ua"); - customer.setAddress("abb"); - customer.setEmail("ccc@aa.org"); - customer.setAdditionalInfo(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); - return customerService.saveCustomer(customer); - } - - protected void checkImportedCustomerData(Customer initialCustomer, Customer importedCustomer) { - assertThat(importedCustomer.getTitle()).isEqualTo(initialCustomer.getTitle()); - assertThat(importedCustomer.getCountry()).isEqualTo(initialCustomer.getCountry()); - assertThat(importedCustomer.getAddress()).isEqualTo(initialCustomer.getAddress()); - assertThat(importedCustomer.getEmail()).isEqualTo(initialCustomer.getEmail()); - } - - protected Dashboard createDashboard(TenantId tenantId, CustomerId customerId, String name) { - Dashboard dashboard = new Dashboard(); - dashboard.setTenantId(tenantId); - dashboard.setTitle(name); - dashboard.setConfiguration(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); - dashboard.setImage("abvregewrg"); - dashboard.setMobileHide(true); - dashboard = dashboardService.saveDashboard(dashboard); - if (customerId != null) { - dashboardService.assignDashboardToCustomer(tenantId, dashboard.getId(), customerId); - return dashboardService.findDashboardById(tenantId, dashboard.getId()); - } - return dashboard; - } - - protected Dashboard createDashboard(TenantId tenantId, CustomerId customerId, String name, AssetId assetForEntityAlias) { - Dashboard dashboard = createDashboard(tenantId, customerId, name); - String entityAliases = "{\n" + - "\t\"23c4185d-1497-9457-30b2-6d91e69a5b2c\": {\n" + - "\t\t\"alias\": \"assets\",\n" + - "\t\t\"filter\": {\n" + - "\t\t\t\"entityList\": [\n" + - "\t\t\t\t\"" + assetForEntityAlias.getId().toString() + "\"\n" + - "\t\t\t],\n" + - "\t\t\t\"entityType\": \"ASSET\",\n" + - "\t\t\t\"resolveMultiple\": true,\n" + - "\t\t\t\"type\": \"entityList\"\n" + - "\t\t},\n" + - "\t\t\"id\": \"23c4185d-1497-9457-30b2-6d91e69a5b2c\"\n" + - "\t}\n" + - "}"; - ObjectNode dashboardConfiguration = JacksonUtil.newObjectNode(); - dashboardConfiguration.set("entityAliases", JacksonUtil.toJsonNode(entityAliases)); - dashboardConfiguration.set("description", new TextNode("hallo")); - dashboard.setConfiguration(dashboardConfiguration); - return dashboardService.saveDashboard(dashboard); - } - - protected void checkImportedDashboardData(Dashboard initialDashboard, Dashboard importedDashboard) { - assertThat(importedDashboard.getTitle()).isEqualTo(initialDashboard.getTitle()); - assertThat(importedDashboard.getConfiguration()).isEqualTo(initialDashboard.getConfiguration()); - assertThat(importedDashboard.getImage()).isEqualTo(initialDashboard.getImage()); - assertThat(importedDashboard.isMobileHide()).isEqualTo(initialDashboard.isMobileHide()); - if (initialDashboard.getAssignedCustomers() != null) { - assertThat(importedDashboard.getAssignedCustomers()).containsAll(initialDashboard.getAssignedCustomers()); - } - } - protected RuleChain createRuleChain(TenantId tenantId, String name, EntityId originatorId) { - RuleChain ruleChain = new RuleChain(); - ruleChain.setTenantId(tenantId); - ruleChain.setName(name); - ruleChain.setType(RuleChainType.CORE); - ruleChain.setDebugMode(true); - ruleChain.setConfiguration(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); - ruleChain = ruleChainService.saveRuleChain(ruleChain); - - RuleChainMetaData metaData = new RuleChainMetaData(); - metaData.setRuleChainId(ruleChain.getId()); - - RuleNode ruleNode1 = new RuleNode(); - ruleNode1.setName("Generator 1"); - ruleNode1.setType(TbMsgGeneratorNode.class.getName()); - ruleNode1.setDebugMode(true); - TbMsgGeneratorNodeConfiguration configuration1 = new TbMsgGeneratorNodeConfiguration(); - configuration1.setOriginatorType(originatorId.getEntityType()); - configuration1.setOriginatorId(originatorId.getId().toString()); - ruleNode1.setConfiguration(JacksonUtil.valueToTree(configuration1)); - - RuleNode ruleNode2 = new RuleNode(); - ruleNode2.setName("Simple Rule Node 2"); - ruleNode2.setType(org.thingsboard.rule.engine.metadata.TbGetAttributesNode.class.getName()); - ruleNode2.setConfigurationVersion(TbGetAttributesNode.class.getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class).version()); - ruleNode2.setDebugMode(true); - TbGetAttributesNodeConfiguration configuration2 = new TbGetAttributesNodeConfiguration(); - configuration2.setServerAttributeNames(Collections.singletonList("serverAttributeKey2")); - ruleNode2.setConfiguration(JacksonUtil.valueToTree(configuration2)); - - metaData.setNodes(Arrays.asList(ruleNode1, ruleNode2)); - metaData.setFirstNodeIndex(0); - metaData.addConnectionInfo(0, 1, TbNodeConnectionType.SUCCESS); - ruleChainService.saveRuleChainMetaData(tenantId, metaData, Function.identity()); - - return ruleChainService.findRuleChainById(tenantId, ruleChain.getId()); - } - - protected RuleChain createRuleChain(TenantId tenantId, String name) { - RuleChain ruleChain = new RuleChain(); - ruleChain.setTenantId(tenantId); - ruleChain.setName(name); - ruleChain.setType(RuleChainType.CORE); - ruleChain.setDebugMode(true); - ruleChain.setConfiguration(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); - ruleChain = ruleChainService.saveRuleChain(ruleChain); - - RuleChainMetaData metaData = new RuleChainMetaData(); - metaData.setRuleChainId(ruleChain.getId()); - - RuleNode ruleNode1 = new RuleNode(); - ruleNode1.setName("Simple Rule Node 1"); - ruleNode1.setType(org.thingsboard.rule.engine.metadata.TbGetAttributesNode.class.getName()); - ruleNode1.setConfigurationVersion(TbGetAttributesNode.class.getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class).version()); - ruleNode1.setDebugMode(true); - TbGetAttributesNodeConfiguration configuration1 = new TbGetAttributesNodeConfiguration(); - configuration1.setServerAttributeNames(Collections.singletonList("serverAttributeKey1")); - ruleNode1.setConfiguration(JacksonUtil.valueToTree(configuration1)); - - RuleNode ruleNode2 = new RuleNode(); - ruleNode2.setName("Simple Rule Node 2"); - ruleNode2.setType(org.thingsboard.rule.engine.metadata.TbGetAttributesNode.class.getName()); - ruleNode2.setConfigurationVersion(TbGetAttributesNode.class.getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class).version()); - ruleNode2.setDebugMode(true); - TbGetAttributesNodeConfiguration configuration2 = new TbGetAttributesNodeConfiguration(); - configuration2.setServerAttributeNames(Collections.singletonList("serverAttributeKey2")); - ruleNode2.setConfiguration(JacksonUtil.valueToTree(configuration2)); - - metaData.setNodes(Arrays.asList(ruleNode1, ruleNode2)); - metaData.setFirstNodeIndex(0); - metaData.addConnectionInfo(0, 1, TbNodeConnectionType.SUCCESS); - ruleChainService.saveRuleChainMetaData(tenantId, metaData, Function.identity()); - - return ruleChainService.findRuleChainById(tenantId, ruleChain.getId()); - } - - protected void checkImportedRuleChainData(RuleChain initialRuleChain, RuleChainMetaData initialMetaData, RuleChain importedRuleChain, RuleChainMetaData importedMetaData) { - assertThat(importedRuleChain.getType()).isEqualTo(initialRuleChain.getType()); - assertThat(importedRuleChain.getName()).isEqualTo(initialRuleChain.getName()); - assertThat(importedRuleChain.isDebugMode()).isEqualTo(initialRuleChain.isDebugMode()); - assertThat(importedRuleChain.getConfiguration()).isEqualTo(initialRuleChain.getConfiguration()); - - assertThat(importedMetaData.getConnections()).isEqualTo(initialMetaData.getConnections()); - assertThat(importedMetaData.getFirstNodeIndex()).isEqualTo(initialMetaData.getFirstNodeIndex()); - for (int i = 0; i < initialMetaData.getNodes().size(); i++) { - RuleNode initialNode = initialMetaData.getNodes().get(i); - RuleNode importedNode = importedMetaData.getNodes().get(i); - assertThat(importedNode.getRuleChainId()).isEqualTo(importedRuleChain.getId()); - assertThat(importedNode.getName()).isEqualTo(initialNode.getName()); - assertThat(importedNode.getType()).isEqualTo(initialNode.getType()); - assertThat(importedNode.getConfiguration()).isEqualTo(initialNode.getConfiguration()); - assertThat(importedNode.getAdditionalInfo()).isEqualTo(initialNode.getAdditionalInfo()); - } - } - - protected EntityView createEntityView(TenantId tenantId, CustomerId customerId, EntityId entityId, String name) { - EntityView entityView = new EntityView(); - entityView.setTenantId(tenantId); - entityView.setEntityId(entityId); - entityView.setCustomerId(customerId); - entityView.setName(name); - entityView.setType("A"); - return entityViewService.saveEntityView(entityView); - } - - protected EntityRelation createRelation(EntityId from, EntityId to) { - EntityRelation relation = new EntityRelation(); - relation.setFrom(from); - relation.setTo(to); - relation.setType(EntityRelation.MANAGES_TYPE); - relation.setAdditionalInfo(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); - relation.setTypeGroup(RelationTypeGroup.COMMON); - relationService.saveRelation(TenantId.SYS_TENANT_ID, relation); - return relation; - } - - protected & HasTenantId> void checkImportedEntity(TenantId tenantId1, E initialEntity, TenantId tenantId2, E importedEntity) { - assertThat(initialEntity.getTenantId()).isEqualTo(tenantId1); - assertThat(importedEntity.getTenantId()).isEqualTo(tenantId2); - - assertThat(importedEntity.getExternalId()).isEqualTo(initialEntity.getId()); - - boolean sameTenant = tenantId1.equals(tenantId2); - if (!sameTenant) { - assertThat(importedEntity.getId()).isNotEqualTo(initialEntity.getId()); - } else { - assertThat(importedEntity.getId()).isEqualTo(initialEntity.getId()); - } - } - - - protected , I extends EntityId> EntityExportData exportEntity(User user, I entityId) throws Exception { - return exportEntity(user, entityId, EntityExportSettings.builder() - .exportCredentials(true) - .build()); - } - - protected , I extends EntityId> EntityExportData exportEntity(User user, I entityId, EntityExportSettings exportSettings) throws Exception { - return exportImportService.exportEntity(new SimpleEntitiesExportCtx(getSecurityUser(user), null, null, exportSettings), entityId); - } - - protected , I extends EntityId> EntityImportResult importEntity(User user, EntityExportData exportData) throws Exception { - return importEntity(user, exportData, EntityImportSettings.builder() - .saveCredentials(true) - .build()); - } - - protected , I extends EntityId> EntityImportResult importEntity(User user, EntityExportData exportData, EntityImportSettings importSettings) throws Exception { - EntitiesImportCtx ctx = new EntitiesImportCtx(UUID.randomUUID(), getSecurityUser(user), null, importSettings); - ctx.setFinalImportAttempt(true); - exportData = JacksonUtil.treeToValue(JacksonUtil.valueToTree(exportData), EntityExportData.class); - EntityImportResult importResult = exportImportService.importEntity(ctx, exportData); - exportImportService.saveReferencesAndRelations(ctx); - for (ThrowingRunnable throwingRunnable : ctx.getEventCallbacks()) { - throwingRunnable.run(); - } - return importResult; - } - - protected SecurityUser getSecurityUser(User user) { - return new SecurityUser(user, true, new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail())); - } - -} diff --git a/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java index f20f1d5370..55f15516f4 100644 --- a/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java @@ -15,10 +15,10 @@ */ package org.thingsboard.server.service.sync.ie; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; -import com.google.common.collect.Streams; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -26,19 +26,32 @@ import org.springframework.boot.test.mock.mockito.SpyBean; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.debug.TbMsgGeneratorNode; import org.thingsboard.rule.engine.debug.TbMsgGeneratorNodeConfiguration; +import org.thingsboard.rule.engine.metadata.TbGetAttributesNode; +import org.thingsboard.rule.engine.metadata.TbGetAttributesNodeConfiguration; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileType; +import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.ExportableEntity; import org.thingsboard.server.common.data.OtaPackage; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; +import org.thingsboard.server.common.data.device.data.DeviceData; +import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; +import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileTransportConfiguration; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; @@ -46,27 +59,46 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbNodeConnectionType; +import org.thingsboard.server.common.data.ota.ChecksumAlgorithm; import org.thingsboard.server.common.data.ota.OtaPackageType; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.data.rule.RuleNode; -import org.thingsboard.server.common.data.script.ScriptLanguage; -import org.thingsboard.server.common.data.security.DeviceCredentials; -import org.thingsboard.server.common.data.sync.ie.DeviceExportData; +import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.sync.ie.EntityExportData; import org.thingsboard.server.common.data.sync.ie.EntityExportSettings; import org.thingsboard.server.common.data.sync.ie.EntityImportResult; import org.thingsboard.server.common.data.sync.ie.EntityImportSettings; import org.thingsboard.server.common.data.sync.ie.RuleChainExportData; -import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.common.data.util.ThrowingRunnable; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.service.action.EntityActionService; import org.thingsboard.server.service.ota.OtaPackageStateService; - -import java.util.ArrayList; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; +import org.thingsboard.server.service.sync.vc.data.SimpleEntitiesExportCtx; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -76,481 +108,77 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.verify; @DaoSqlTest -public class ExportImportServiceSqlTest extends BaseExportImportServiceTest { +public class ExportImportServiceSqlTest extends AbstractControllerTest { - @Autowired - private DeviceCredentialsService deviceCredentialsService; @SpyBean private EntityActionService entityActionService; @SpyBean private OtaPackageStateService otaPackageStateService; - @Test - public void testExportImportAssetWithProfile_betweenTenants() throws Exception { - AssetProfile assetProfile = createAssetProfile(tenantId1, null, null, "Asset profile of tenant 1"); - Asset asset = createAsset(tenantId1, null, assetProfile.getId(), "Asset of tenant 1"); - - EntityExportData profileExportData = exportEntity(tenantAdmin1, assetProfile.getId()); - - EntityExportData assetExportData = exportEntity(tenantAdmin1, asset.getId()); - - EntityImportResult profileImportResult = importEntity(tenantAdmin2, profileExportData); - checkImportedEntity(tenantId1, assetProfile, tenantId2, profileImportResult.getSavedEntity()); - checkImportedAssetProfileData(assetProfile, profileImportResult.getSavedEntity()); - - EntityImportResult assetImportResult = importEntity(tenantAdmin2, assetExportData); - Asset importedAsset = assetImportResult.getSavedEntity(); - checkImportedEntity(tenantId1, asset, tenantId2, importedAsset); - checkImportedAssetData(asset, importedAsset); - - assertThat(importedAsset.getAssetProfileId()).isEqualTo(profileImportResult.getSavedEntity().getId()); - } - - @Test - public void testExportImportAsset_sameTenant() throws Exception { - AssetProfile assetProfile = createAssetProfile(tenantId1, null, null, "Asset profile v1.0"); - Asset asset = createAsset(tenantId1, null, assetProfile.getId(), "Asset v1.0"); - EntityExportData exportData = exportEntity(tenantAdmin1, asset.getId()); - - EntityImportResult importResult = importEntity(tenantAdmin1, exportData); - checkImportedEntity(tenantId1, asset, tenantId1, importResult.getSavedEntity()); - checkImportedAssetData(asset, importResult.getSavedEntity()); - } - - @Test - public void testExportImportAsset_sameTenant_withCustomer() throws Exception { - AssetProfile assetProfile = createAssetProfile(tenantId1, null, null, "Asset profile v1.0"); - Customer customer = createCustomer(tenantId1, "My customer"); - Asset asset = createAsset(tenantId1, customer.getId(), assetProfile.getId(), "My asset"); - - Asset importedAsset = importEntity(tenantAdmin1, this.exportEntity(tenantAdmin1, asset.getId())).getSavedEntity(); - assertThat(importedAsset.getCustomerId()).isEqualTo(asset.getCustomerId()); - } - - - @Test - public void testExportImportCustomer_betweenTenants() throws Exception { - Customer customer = createCustomer(tenantAdmin1.getTenantId(), "Customer of tenant 1"); - EntityExportData exportData = exportEntity(tenantAdmin1, customer.getId()); - - EntityImportResult importResult = importEntity(tenantAdmin2, exportData); - checkImportedEntity(tenantId1, customer, tenantId2, importResult.getSavedEntity()); - checkImportedCustomerData(customer, importResult.getSavedEntity()); - } - - @Test - public void testExportImportCustomer_sameTenant() throws Exception { - Customer customer = createCustomer(tenantAdmin1.getTenantId(), "Customer v1.0"); - EntityExportData exportData = exportEntity(tenantAdmin1, customer.getId()); - - EntityImportResult importResult = importEntity(tenantAdmin1, exportData); - checkImportedEntity(tenantId1, customer, tenantId1, importResult.getSavedEntity()); - checkImportedCustomerData(customer, importResult.getSavedEntity()); - } - - - @Test - public void testExportImportDeviceWithProfile_betweenTenants() throws Exception { - DeviceProfile deviceProfile = createDeviceProfile(tenantId1, null, null, "Device profile of tenant 1"); - Device device = createDevice(tenantId1, null, deviceProfile.getId(), "Device of tenant 1"); - DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(tenantId1, device.getId()); - - EntityExportData profileExportData = exportEntity(tenantAdmin1, deviceProfile.getId()); - - EntityExportData deviceExportData = exportEntity(tenantAdmin1, device.getId()); - DeviceCredentials exportedCredentials = ((DeviceExportData) deviceExportData).getCredentials(); - exportedCredentials.setCredentialsId(credentials.getCredentialsId() + "a"); - - EntityImportResult profileImportResult = importEntity(tenantAdmin2, profileExportData); - checkImportedEntity(tenantId1, deviceProfile, tenantId2, profileImportResult.getSavedEntity()); - checkImportedDeviceProfileData(deviceProfile, profileImportResult.getSavedEntity()); - - EntityImportResult deviceImportResult = importEntity(tenantAdmin2, deviceExportData); - Device importedDevice = deviceImportResult.getSavedEntity(); - checkImportedEntity(tenantId1, device, tenantId2, deviceImportResult.getSavedEntity()); - checkImportedDeviceData(device, importedDevice); - - assertThat(importedDevice.getDeviceProfileId()).isEqualTo(profileImportResult.getSavedEntity().getId()); - - DeviceCredentials importedCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(tenantId2, importedDevice.getId()); - assertThat(importedCredentials.getId()).isNotEqualTo(credentials.getId()); - assertThat(importedCredentials.getCredentialsId()).isEqualTo(exportedCredentials.getCredentialsId()); - assertThat(importedCredentials.getCredentialsValue()).isEqualTo(credentials.getCredentialsValue()); - assertThat(importedCredentials.getCredentialsType()).isEqualTo(credentials.getCredentialsType()); - } - - @Test - public void testExportImportDevice_sameTenant() throws Exception { - DeviceProfile deviceProfile = createDeviceProfile(tenantId1, null, null, "Device profile v1.0"); - OtaPackage firmware = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.FIRMWARE); - OtaPackage software = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.SOFTWARE); - Device device = createDevice(tenantId1, null, deviceProfile.getId(), "Device v1.0"); - device.setFirmwareId(firmware.getId()); - device.setSoftwareId(software.getId()); - device = deviceService.saveDevice(device); - - DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(tenantId1, device.getId()); - - EntityExportData deviceExportData = exportEntity(tenantAdmin1, device.getId()); - - EntityImportResult importResult = importEntity(tenantAdmin1, deviceExportData); - Device importedDevice = importResult.getSavedEntity(); - - checkImportedEntity(tenantId1, device, tenantId1, importResult.getSavedEntity()); - assertThat(importedDevice.getDeviceProfileId()).isEqualTo(device.getDeviceProfileId()); - assertThat(deviceCredentialsService.findDeviceCredentialsByDeviceId(tenantId1, device.getId())).isEqualTo(credentials); - assertThat(importedDevice.getFirmwareId()).isEqualTo(firmware.getId()); - assertThat(importedDevice.getSoftwareId()).isEqualTo(software.getId()); - } - - - @Test - public void testExportImportDashboard_betweenTenants() throws Exception { - Dashboard dashboard = createDashboard(tenantAdmin1.getTenantId(), null, "Dashboard of tenant 1"); - EntityExportData exportData = exportEntity(tenantAdmin1, dashboard.getId()); - - EntityImportResult importResult = importEntity(tenantAdmin2, exportData); - checkImportedEntity(tenantId1, dashboard, tenantId2, importResult.getSavedEntity()); - checkImportedDashboardData(dashboard, importResult.getSavedEntity()); - } - - @Test - public void testExportImportDashboard_sameTenant() throws Exception { - Dashboard dashboard = createDashboard(tenantAdmin1.getTenantId(), null, "Dashboard v1.0"); - EntityExportData exportData = exportEntity(tenantAdmin1, dashboard.getId()); - - EntityImportResult importResult = importEntity(tenantAdmin1, exportData); - checkImportedEntity(tenantId1, dashboard, tenantId1, importResult.getSavedEntity()); - checkImportedDashboardData(dashboard, importResult.getSavedEntity()); - } - - @Test - public void testExportImportDashboard_betweenTenants_withCustomer_updated() throws Exception { - Dashboard dashboard = createDashboard(tenantAdmin1.getTenantId(), null, "Dashboard of tenant 1"); - EntityExportData exportData = exportEntity(tenantAdmin1, dashboard.getId()); - - Dashboard importedDashboard = importEntity(tenantAdmin2, exportData).getSavedEntity(); - checkImportedEntity(tenantId1, dashboard, tenantId2, importedDashboard); - - Customer customer = createCustomer(tenantId1, "Customer 1"); - EntityExportData customerExportData = exportEntity(tenantAdmin1, customer.getId()); - dashboardService.assignDashboardToCustomer(tenantId1, dashboard.getId(), customer.getId()); - exportData = exportEntity(tenantAdmin1, dashboard.getId()); - - Customer importedCustomer = importEntity(tenantAdmin2, customerExportData).getSavedEntity(); - importedDashboard = importEntity(tenantAdmin2, exportData).getSavedEntity(); - assertThat(importedDashboard.getAssignedCustomers()).hasOnlyOneElementSatisfying(customerInfo -> { - assertThat(customerInfo.getCustomerId()).isEqualTo(importedCustomer.getId()); - }); - } - - @Test - public void testExportImportDashboard_betweenTenants_withEntityAliases() throws Exception { - AssetProfile assetProfile = createAssetProfile(tenantId1, null, null, "A"); - Asset asset1 = createAsset(tenantId1, null, assetProfile.getId(), "Asset 1"); - Asset asset2 = createAsset(tenantId1, null, assetProfile.getId(), "Asset 2"); - Dashboard dashboard = createDashboard(tenantId1, null, "Dashboard 1"); - Dashboard otherDashboard = createDashboard(tenantId1, null, "Dashboard 2"); - DeviceProfile existingDeviceProfile = createDeviceProfile(tenantId2, null, null, "Existing"); - - String aliasId = "23c4185d-1497-9457-30b2-6d91e69a5b2c"; - String unknownUuid = "ea0dc8b0-3d85-11ed-9200-77fc04fa14fa"; - String entityAliases = "{\n" + - "\"" + aliasId + "\": {\n" + - "\"alias\": \"assets\",\n" + - "\"filter\": {\n" + - " \"entityList\": [\n" + - " \"" + asset1.getId() + "\",\n" + - " \"" + asset2.getId() + "\",\n" + - " \"" + tenantId1.getId() + "\",\n" + - " \"" + existingDeviceProfile.getId() + "\",\n" + - " \"" + unknownUuid + "\"\n" + - " ],\n" + - " \"id\":\"" + asset1.getId() + "\",\n" + - " \"resolveMultiple\": true\n" + - "},\n" + - "\"id\": \"" + aliasId + "\"\n" + - "}\n" + - "}"; - String widgetId = "ea8f34a0-264a-f11f-cde3-05201bb4ff4b"; - String actionId = "4a8e6efa-3e68-fa59-7feb-d83366130cae"; - String widgets = "{\n" + - " \"" + widgetId + "\": {\n" + - " \"config\": {\n" + - " \"actions\": {\n" + - " \"rowClick\": [\n" + - " {\n" + - " \"name\": \"go to dashboard\",\n" + - " \"targetDashboardId\": \"" + otherDashboard.getId() + "\",\n" + - " \"id\": \"" + actionId + "\"\n" + - " }\n" + - " ]\n" + - " }\n" + - " },\n" + - " \"row\": 0,\n" + - " \"col\": 0,\n" + - " \"id\": \"" + widgetId + "\"\n" + - " }\n" + - "}"; - - ObjectNode dashboardConfiguration = JacksonUtil.newObjectNode(); - dashboardConfiguration.set("entityAliases", JacksonUtil.toJsonNode(entityAliases)); - dashboardConfiguration.set("widgets", JacksonUtil.toJsonNode(widgets)); - dashboardConfiguration.set("description", new TextNode("hallo")); - dashboard.setConfiguration(dashboardConfiguration); - dashboard = dashboardService.saveDashboard(dashboard); - - EntityExportData profileExportData = exportEntity(tenantAdmin1, assetProfile.getId()); - - EntityExportData asset1ExportData = exportEntity(tenantAdmin1, asset1.getId()); - EntityExportData asset2ExportData = exportEntity(tenantAdmin1, asset2.getId()); - EntityExportData dashboardExportData = exportEntity(tenantAdmin1, dashboard.getId()); - EntityExportData otherDashboardExportData = exportEntity(tenantAdmin1, otherDashboard.getId()); - - AssetProfile importedProfile = importEntity(tenantAdmin2, profileExportData).getSavedEntity(); - Asset importedAsset1 = importEntity(tenantAdmin2, asset1ExportData).getSavedEntity(); - Asset importedAsset2 = importEntity(tenantAdmin2, asset2ExportData).getSavedEntity(); - Dashboard importedOtherDashboard = importEntity(tenantAdmin2, otherDashboardExportData).getSavedEntity(); - Dashboard importedDashboard = importEntity(tenantAdmin2, dashboardExportData).getSavedEntity(); - - Map.Entry entityAlias = importedDashboard.getConfiguration().get("entityAliases").fields().next(); - assertThat(entityAlias.getKey()).isEqualTo(aliasId); - assertThat(entityAlias.getValue().get("id").asText()).isEqualTo(aliasId); - - List aliasEntitiesIds = Streams.stream(entityAlias.getValue().get("filter").get("entityList").elements()) - .map(JsonNode::asText).collect(Collectors.toList()); - assertThat(aliasEntitiesIds).size().isEqualTo(5); - assertThat(aliasEntitiesIds).element(0).as("external asset 1 was replaced with imported one") - .isEqualTo(importedAsset1.getId().toString()); - assertThat(aliasEntitiesIds).element(1).as("external asset 2 was replaced with imported one") - .isEqualTo(importedAsset2.getId().toString()); - assertThat(aliasEntitiesIds).element(2).as("external tenant id was replaced with new tenant id") - .isEqualTo(tenantId2.toString()); - assertThat(aliasEntitiesIds).element(3).as("existing device profile id was left as is") - .isEqualTo(existingDeviceProfile.getId().toString()); - assertThat(aliasEntitiesIds).element(4).as("unresolved uuid was replaced with tenant id") - .isEqualTo(tenantId2.toString()); - assertThat(entityAlias.getValue().get("filter").get("id").asText()).as("external asset 1 was replaced with imported one") - .isEqualTo(importedAsset1.getId().toString()); - - ObjectNode widgetConfig = importedDashboard.getWidgetsConfig().get(0); - assertThat(widgetConfig.get("id").asText()).as("widget id is not replaced") - .isEqualTo(widgetId); - JsonNode actionConfig = widgetConfig.get("config").get("actions").get("rowClick").get(0); - assertThat(actionConfig.get("id").asText()).as("action id is not replaced") - .isEqualTo(actionId); - assertThat(actionConfig.get("targetDashboardId").asText()).as("dashboard id is replaced with imported one") - .isEqualTo(importedOtherDashboard.getId().toString()); - } - - - @Test - public void testExportImportRuleChain_betweenTenants() throws Exception { - RuleChain ruleChain = createRuleChain(tenantId1, "Rule chain of tenant 1"); - RuleChainMetaData metaData = ruleChainService.loadRuleChainMetaData(tenantId1, ruleChain.getId()); - EntityExportData exportData = exportEntity(tenantAdmin1, ruleChain.getId()); - - EntityImportResult importResult = importEntity(tenantAdmin2, exportData); - RuleChain importedRuleChain = importResult.getSavedEntity(); - RuleChainMetaData importedMetaData = ruleChainService.loadRuleChainMetaData(tenantId2, importedRuleChain.getId()); - - checkImportedEntity(tenantId1, ruleChain, tenantId2, importResult.getSavedEntity()); - checkImportedRuleChainData(ruleChain, metaData, importedRuleChain, importedMetaData); - } - - @Test - public void testExportImportRuleChain_sameTenant() throws Exception { - RuleChain ruleChain = createRuleChain(tenantId1, "Rule chain v1.0"); - RuleChainMetaData metaData = ruleChainService.loadRuleChainMetaData(tenantId1, ruleChain.getId()); - EntityExportData exportData = exportEntity(tenantAdmin1, ruleChain.getId()); - - EntityImportResult importResult = importEntity(tenantAdmin1, exportData); - RuleChain importedRuleChain = importResult.getSavedEntity(); - RuleChainMetaData importedMetaData = ruleChainService.loadRuleChainMetaData(tenantId1, importedRuleChain.getId()); - - checkImportedEntity(tenantId1, ruleChain, tenantId1, importResult.getSavedEntity()); - checkImportedRuleChainData(ruleChain, metaData, importedRuleChain, importedMetaData); - } - - @Test - public void testImportRuleChain_ruleNodesConfigs() throws Exception { - Customer customer = createCustomer(tenantId1, "Customer 1"); - RuleChain ruleChain = createRuleChain(tenantId1, "Rule chain 1"); - RuleChainMetaData metaData = ruleChainService.loadRuleChainMetaData(tenantId1, ruleChain.getId()); - - List nodes = new ArrayList<>(metaData.getNodes()); - RuleNode generatorNode = new RuleNode(); - generatorNode.setName("Generator"); - generatorNode.setType(TbMsgGeneratorNode.class.getName()); - TbMsgGeneratorNodeConfiguration generatorNodeConfig = new TbMsgGeneratorNodeConfiguration(); - generatorNodeConfig.setOriginatorType(EntityType.ASSET_PROFILE); - generatorNodeConfig.setOriginatorId(customer.getId().toString()); - generatorNodeConfig.setPeriodInSeconds(5); - generatorNodeConfig.setMsgCount(1); - generatorNodeConfig.setScriptLang(ScriptLanguage.JS); - UUID someUuid = UUID.randomUUID(); - generatorNodeConfig.setJsScript("var msg = { temp: 42, humidity: 77 };\n" + - "var metadata = { data: 40 };\n" + - "var msgType = \"POST_TELEMETRY_REQUEST\";\n" + - "var someUuid = \"" + someUuid + "\";\n" + - "return { msg: msg, metadata: metadata, msgType: msgType };"); - generatorNode.setConfiguration(JacksonUtil.valueToTree(generatorNodeConfig)); - nodes.add(generatorNode); - metaData.setNodes(nodes); - ruleChainService.saveRuleChainMetaData(tenantId1, metaData, Function.identity()); - - EntityExportData ruleChainExportData = exportEntity(tenantAdmin1, ruleChain.getId()); - EntityExportData customerExportData = exportEntity(tenantAdmin1, customer.getId()); - - Customer importedCustomer = importEntity(tenantAdmin2, customerExportData).getSavedEntity(); - RuleChain importedRuleChain = importEntity(tenantAdmin2, ruleChainExportData).getSavedEntity(); - RuleChainMetaData importedMetaData = ruleChainService.loadRuleChainMetaData(tenantId2, importedRuleChain.getId()); - - TbMsgGeneratorNodeConfiguration importedGeneratorNodeConfig = JacksonUtil.treeToValue(importedMetaData.getNodes().stream() - .filter(node -> node.getName().equals(generatorNode.getName())) - .findFirst().get().getConfiguration(), TbMsgGeneratorNodeConfiguration.class); - assertThat(importedGeneratorNodeConfig.getOriginatorId()).isEqualTo(importedCustomer.getId().toString()); - assertThat(importedGeneratorNodeConfig.getJsScript()).contains("var someUuid = \"" + someUuid + "\";"); - } - - - @Test - public void testExportImportWithInboundRelations_betweenTenants() throws Exception { - Asset asset = createAsset(tenantId1, null, null, "Asset 1"); - Device device = createDevice(tenantId1, null, null, "Device 1"); - EntityRelation relation = createRelation(asset.getId(), device.getId()); - - EntityExportData assetExportData = exportEntity(tenantAdmin1, asset.getId()); - EntityExportData deviceExportData = exportEntity(tenantAdmin1, device.getId(), EntityExportSettings.builder() - .exportRelations(true) - .exportCredentials(false) - .build()); - - assertThat(deviceExportData.getRelations()).size().isOne(); - assertThat(deviceExportData.getRelations().get(0)).matches(entityRelation -> { - return entityRelation.getFrom().equals(asset.getId()) && entityRelation.getTo().equals(device.getId()); - }); - ((Asset) assetExportData.getEntity()).setAssetProfileId(null); - ((Device) deviceExportData.getEntity()).setDeviceProfileId(null); - - Asset importedAsset = importEntity(tenantAdmin2, assetExportData).getSavedEntity(); - Device importedDevice = importEntity(tenantAdmin2, deviceExportData, EntityImportSettings.builder() - .updateRelations(true) - .build()).getSavedEntity(); - checkImportedEntity(tenantId1, device, tenantId2, importedDevice); - checkImportedEntity(tenantId1, asset, tenantId2, importedAsset); - - List importedRelations = relationService.findByTo(TenantId.SYS_TENANT_ID, importedDevice.getId(), RelationTypeGroup.COMMON); - assertThat(importedRelations).size().isOne(); - assertThat(importedRelations.get(0)).satisfies(importedRelation -> { - assertThat(importedRelation.getFrom()).isEqualTo(importedAsset.getId()); - assertThat(importedRelation.getType()).isEqualTo(relation.getType()); - assertThat(importedRelation.getAdditionalInfo()).isEqualTo(relation.getAdditionalInfo()); - }); - } - - @Test - public void testExportImportWithRelations_betweenTenants() throws Exception { - Asset asset = createAsset(tenantId1, null, null, "Asset 1"); - Device device = createDevice(tenantId1, null, null, "Device 1"); - EntityRelation relation = createRelation(asset.getId(), device.getId()); - - EntityExportData assetExportData = exportEntity(tenantAdmin1, asset.getId()); - EntityExportData deviceExportData = exportEntity(tenantAdmin1, device.getId(), EntityExportSettings.builder() - .exportRelations(true) - .exportCredentials(false) - .build()); - assetExportData.getEntity().setAssetProfileId(null); - deviceExportData.getEntity().setDeviceProfileId(null); - - Asset importedAsset = importEntity(tenantAdmin2, assetExportData).getSavedEntity(); - Device importedDevice = importEntity(tenantAdmin2, deviceExportData, EntityImportSettings.builder() - .updateRelations(true) - .build()).getSavedEntity(); - - List importedRelations = relationService.findByTo(TenantId.SYS_TENANT_ID, importedDevice.getId(), RelationTypeGroup.COMMON); - assertThat(importedRelations).size().isOne(); - assertThat(importedRelations.get(0)).satisfies(importedRelation -> { - assertThat(importedRelation.getFrom()).isEqualTo(importedAsset.getId()); - assertThat(importedRelation.getType()).isEqualTo(relation.getType()); - assertThat(importedRelation.getAdditionalInfo()).isEqualTo(relation.getAdditionalInfo()); - }); - } - - @Test - public void testExportImportWithRelations_sameTenant() throws Exception { - Asset asset = createAsset(tenantId1, null, null, "Asset 1"); - Device device1 = createDevice(tenantId1, null, null, "Device 1"); - EntityRelation relation1 = createRelation(asset.getId(), device1.getId()); - - EntityExportData assetExportData = exportEntity(tenantAdmin1, asset.getId(), EntityExportSettings.builder() - .exportRelations(true) - .build()); - assertThat(assetExportData.getRelations()).size().isOne(); - - Device device2 = createDevice(tenantId1, null, null, "Device 2"); - EntityRelation relation2 = createRelation(asset.getId(), device2.getId()); - - importEntity(tenantAdmin1, assetExportData, EntityImportSettings.builder() - .updateRelations(true) - .build()); - - List relations = relationService.findByFrom(TenantId.SYS_TENANT_ID, asset.getId(), RelationTypeGroup.COMMON); - assertThat(relations).contains(relation1); - assertThat(relations).doesNotContain(relation2); - } - - @Test - public void textExportImportWithRelations_sameTenant_removeExisting() throws Exception { - Asset asset1 = createAsset(tenantId1, null, null, "Asset 1"); - Device device = createDevice(tenantId1, null, null, "Device 1"); - EntityRelation relation1 = createRelation(asset1.getId(), device.getId()); - - EntityExportData deviceExportData = exportEntity(tenantAdmin1, device.getId(), EntityExportSettings.builder() - .exportRelations(true) - .build()); - assertThat(deviceExportData.getRelations()).size().isOne(); - - Asset asset2 = createAsset(tenantId1, null, null, "Asset 2"); - EntityRelation relation2 = createRelation(asset2.getId(), device.getId()); - - importEntity(tenantAdmin1, deviceExportData, EntityImportSettings.builder() - .updateRelations(true) - .build()); - - List relations = relationService.findByTo(TenantId.SYS_TENANT_ID, device.getId(), RelationTypeGroup.COMMON); - assertThat(relations).contains(relation1); - assertThat(relations).doesNotContain(relation2); - } - - - @Test - public void testExportImportDefaultDeviceProfile_betweenTenants_findExisting() throws Exception { - DeviceProfile defaultDeviceProfile = deviceProfileService.findDefaultDeviceProfile(tenantId1); - defaultDeviceProfile.setName("non-default-name"); - deviceProfileService.saveDeviceProfile(defaultDeviceProfile); - EntityExportData deviceProfileExportData = exportEntity(tenantAdmin1, defaultDeviceProfile.getId()); - - importEntity(tenantAdmin2, deviceProfileExportData, EntityImportSettings.builder() - .findExistingByName(false) - .build()); - DeviceProfile importedDeviceProfile = deviceProfileService.findDefaultDeviceProfile(tenantId2); - assertThat(importedDeviceProfile.isDefault()).isTrue(); - assertThat(importedDeviceProfile.getName()).isEqualTo(defaultDeviceProfile.getName()); - checkImportedEntity(tenantId1, defaultDeviceProfile, tenantId2, importedDeviceProfile); + @Autowired + protected EntitiesExportImportService exportImportService; + @Autowired + protected DeviceService deviceService; + @Autowired + protected OtaPackageService otaPackageService; + @Autowired + protected DeviceProfileService deviceProfileService; + @Autowired + protected AssetProfileService assetProfileService; + @Autowired + protected AssetService assetService; + @Autowired + protected CustomerService customerService; + @Autowired + protected RuleChainService ruleChainService; + @Autowired + protected DashboardService dashboardService; + @Autowired + protected RelationService relationService; + @Autowired + protected TenantService tenantService; + @Autowired + protected EntityViewService entityViewService; + + protected TenantId tenantId1; + protected User tenantAdmin1; + + protected TenantId tenantId2; + protected User tenantAdmin2; + + @Before + public void beforeEach() throws Exception { + loginSysAdmin(); + Tenant tenant1 = new Tenant(); + tenant1.setTitle("Tenant 1"); + tenant1.setEmail("tenant1@thingsboard.org"); + this.tenantId1 = tenantService.saveTenant(tenant1).getId(); + User tenantAdmin1 = new User(); + tenantAdmin1.setTenantId(tenantId1); + tenantAdmin1.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin1.setEmail("tenant1-admin@thingsboard.org"); + this.tenantAdmin1 = createUser(tenantAdmin1, "12345678"); + Tenant tenant2 = new Tenant(); + tenant2.setTitle("Tenant 2"); + tenant2.setEmail("tenant2@thingsboard.org"); + this.tenantId2 = tenantService.saveTenant(tenant2).getId(); + User tenantAdmin2 = new User(); + tenantAdmin2.setTenantId(tenantId2); + tenantAdmin2.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin2.setEmail("tenant2-admin@thingsboard.org"); + this.tenantAdmin2 = createUser(tenantAdmin2, "12345678"); } - - @SuppressWarnings("rawTypes") - private static EntityExportData getAndClone(Map map, EntityType entityType) { - return JacksonUtil.clone(map.get(entityType)); + @After + public void afterEach() { + tenantService.deleteTenant(tenantId1); + tenantService.deleteTenant(tenantId2); } @SuppressWarnings({"rawTypes", "unchecked"}) @@ -712,4 +340,255 @@ public class ExportImportServiceSqlTest extends BaseExportImportServiceTest { deviceProfileService.saveDeviceProfile(importedDeviceProfile); } + + protected Device createDevice(TenantId tenantId, CustomerId customerId, DeviceProfileId deviceProfileId, String name) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setCustomerId(customerId); + device.setName(name); + device.setLabel("lbl"); + device.setDeviceProfileId(deviceProfileId); + DeviceData deviceData = new DeviceData(); + deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); + device.setDeviceData(deviceData); + return deviceService.saveDevice(device); + } + + protected OtaPackage createOtaPackage(TenantId tenantId, DeviceProfileId deviceProfileId, OtaPackageType type) { + OtaPackage otaPackage = new OtaPackage(); + otaPackage.setTenantId(tenantId); + otaPackage.setDeviceProfileId(deviceProfileId); + otaPackage.setType(type); + otaPackage.setTitle("My " + type); + otaPackage.setVersion("v1.0"); + otaPackage.setFileName("filename.txt"); + otaPackage.setContentType("text/plain"); + otaPackage.setChecksumAlgorithm(ChecksumAlgorithm.SHA256); + otaPackage.setChecksum("4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a"); + otaPackage.setDataSize(1L); + otaPackage.setData(ByteBuffer.wrap(new byte[]{(int) 1})); + return otaPackageService.saveOtaPackage(otaPackage); + } + + protected DeviceProfile createDeviceProfile(TenantId tenantId, RuleChainId defaultRuleChainId, DashboardId defaultDashboardId, String name) { + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setTenantId(tenantId); + deviceProfile.setName(name); + deviceProfile.setDescription("dscrptn"); + deviceProfile.setType(DeviceProfileType.DEFAULT); + deviceProfile.setTransportType(DeviceTransportType.DEFAULT); + deviceProfile.setDefaultRuleChainId(defaultRuleChainId); + deviceProfile.setDefaultDashboardId(defaultDashboardId); + DeviceProfileData profileData = new DeviceProfileData(); + profileData.setConfiguration(new DefaultDeviceProfileConfiguration()); + profileData.setTransportConfiguration(new DefaultDeviceProfileTransportConfiguration()); + deviceProfile.setProfileData(profileData); + return deviceProfileService.saveDeviceProfile(deviceProfile); + } + + protected AssetProfile createAssetProfile(TenantId tenantId, RuleChainId defaultRuleChainId, DashboardId defaultDashboardId, String name) { + AssetProfile assetProfile = new AssetProfile(); + assetProfile.setTenantId(tenantId); + assetProfile.setName(name); + assetProfile.setDescription("dscrptn"); + assetProfile.setDefaultRuleChainId(defaultRuleChainId); + assetProfile.setDefaultDashboardId(defaultDashboardId); + return assetProfileService.saveAssetProfile(assetProfile); + } + + protected Asset createAsset(TenantId tenantId, CustomerId customerId, AssetProfileId assetProfileId, String name) { + Asset asset = new Asset(); + asset.setTenantId(tenantId); + asset.setCustomerId(customerId); + asset.setAssetProfileId(assetProfileId); + asset.setName(name); + asset.setLabel("lbl"); + asset.setAdditionalInfo(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); + return assetService.saveAsset(asset); + } + + protected Customer createCustomer(TenantId tenantId, String name) { + Customer customer = new Customer(); + customer.setTenantId(tenantId); + customer.setTitle(name); + customer.setCountry("ua"); + customer.setAddress("abb"); + customer.setEmail("ccc@aa.org"); + customer.setAdditionalInfo(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); + return customerService.saveCustomer(customer); + } + + protected Dashboard createDashboard(TenantId tenantId, CustomerId customerId, String name) { + Dashboard dashboard = new Dashboard(); + dashboard.setTenantId(tenantId); + dashboard.setTitle(name); + dashboard.setConfiguration(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); + dashboard.setImage("abvregewrg"); + dashboard.setMobileHide(true); + dashboard = dashboardService.saveDashboard(dashboard); + if (customerId != null) { + dashboardService.assignDashboardToCustomer(tenantId, dashboard.getId(), customerId); + return dashboardService.findDashboardById(tenantId, dashboard.getId()); + } + return dashboard; + } + + protected Dashboard createDashboard(TenantId tenantId, CustomerId customerId, String name, AssetId assetForEntityAlias) { + Dashboard dashboard = createDashboard(tenantId, customerId, name); + String entityAliases = "{\n" + + "\t\"23c4185d-1497-9457-30b2-6d91e69a5b2c\": {\n" + + "\t\t\"alias\": \"assets\",\n" + + "\t\t\"filter\": {\n" + + "\t\t\t\"entityList\": [\n" + + "\t\t\t\t\"" + assetForEntityAlias.getId().toString() + "\"\n" + + "\t\t\t],\n" + + "\t\t\t\"entityType\": \"ASSET\",\n" + + "\t\t\t\"resolveMultiple\": true,\n" + + "\t\t\t\"type\": \"entityList\"\n" + + "\t\t},\n" + + "\t\t\"id\": \"23c4185d-1497-9457-30b2-6d91e69a5b2c\"\n" + + "\t}\n" + + "}"; + ObjectNode dashboardConfiguration = JacksonUtil.newObjectNode(); + dashboardConfiguration.set("entityAliases", JacksonUtil.toJsonNode(entityAliases)); + dashboardConfiguration.set("description", new TextNode("hallo")); + dashboard.setConfiguration(dashboardConfiguration); + return dashboardService.saveDashboard(dashboard); + } + + protected RuleChain createRuleChain(TenantId tenantId, String name, EntityId originatorId) { + RuleChain ruleChain = new RuleChain(); + ruleChain.setTenantId(tenantId); + ruleChain.setName(name); + ruleChain.setType(RuleChainType.CORE); + ruleChain.setDebugMode(true); + ruleChain.setConfiguration(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); + ruleChain = ruleChainService.saveRuleChain(ruleChain); + + RuleChainMetaData metaData = new RuleChainMetaData(); + metaData.setRuleChainId(ruleChain.getId()); + + RuleNode ruleNode1 = new RuleNode(); + ruleNode1.setName("Generator 1"); + ruleNode1.setType(TbMsgGeneratorNode.class.getName()); + ruleNode1.setDebugMode(true); + TbMsgGeneratorNodeConfiguration configuration1 = new TbMsgGeneratorNodeConfiguration(); + configuration1.setOriginatorType(originatorId.getEntityType()); + configuration1.setOriginatorId(originatorId.getId().toString()); + ruleNode1.setConfiguration(JacksonUtil.valueToTree(configuration1)); + + RuleNode ruleNode2 = new RuleNode(); + ruleNode2.setName("Simple Rule Node 2"); + ruleNode2.setType(org.thingsboard.rule.engine.metadata.TbGetAttributesNode.class.getName()); + ruleNode2.setConfigurationVersion(TbGetAttributesNode.class.getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class).version()); + ruleNode2.setDebugMode(true); + TbGetAttributesNodeConfiguration configuration2 = new TbGetAttributesNodeConfiguration(); + configuration2.setServerAttributeNames(Collections.singletonList("serverAttributeKey2")); + ruleNode2.setConfiguration(JacksonUtil.valueToTree(configuration2)); + + metaData.setNodes(Arrays.asList(ruleNode1, ruleNode2)); + metaData.setFirstNodeIndex(0); + metaData.addConnectionInfo(0, 1, TbNodeConnectionType.SUCCESS); + ruleChainService.saveRuleChainMetaData(tenantId, metaData, Function.identity()); + + return ruleChainService.findRuleChainById(tenantId, ruleChain.getId()); + } + + protected RuleChain createRuleChain(TenantId tenantId, String name) { + RuleChain ruleChain = new RuleChain(); + ruleChain.setTenantId(tenantId); + ruleChain.setName(name); + ruleChain.setType(RuleChainType.CORE); + ruleChain.setDebugMode(true); + ruleChain.setConfiguration(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); + ruleChain = ruleChainService.saveRuleChain(ruleChain); + + RuleChainMetaData metaData = new RuleChainMetaData(); + metaData.setRuleChainId(ruleChain.getId()); + + RuleNode ruleNode1 = new RuleNode(); + ruleNode1.setName("Simple Rule Node 1"); + ruleNode1.setType(org.thingsboard.rule.engine.metadata.TbGetAttributesNode.class.getName()); + ruleNode1.setConfigurationVersion(TbGetAttributesNode.class.getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class).version()); + ruleNode1.setDebugMode(true); + TbGetAttributesNodeConfiguration configuration1 = new TbGetAttributesNodeConfiguration(); + configuration1.setServerAttributeNames(Collections.singletonList("serverAttributeKey1")); + ruleNode1.setConfiguration(JacksonUtil.valueToTree(configuration1)); + + RuleNode ruleNode2 = new RuleNode(); + ruleNode2.setName("Simple Rule Node 2"); + ruleNode2.setType(org.thingsboard.rule.engine.metadata.TbGetAttributesNode.class.getName()); + ruleNode2.setConfigurationVersion(TbGetAttributesNode.class.getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class).version()); + ruleNode2.setDebugMode(true); + TbGetAttributesNodeConfiguration configuration2 = new TbGetAttributesNodeConfiguration(); + configuration2.setServerAttributeNames(Collections.singletonList("serverAttributeKey2")); + ruleNode2.setConfiguration(JacksonUtil.valueToTree(configuration2)); + + metaData.setNodes(Arrays.asList(ruleNode1, ruleNode2)); + metaData.setFirstNodeIndex(0); + metaData.addConnectionInfo(0, 1, TbNodeConnectionType.SUCCESS); + ruleChainService.saveRuleChainMetaData(tenantId, metaData, Function.identity()); + + return ruleChainService.findRuleChainById(tenantId, ruleChain.getId()); + } + + protected EntityView createEntityView(TenantId tenantId, CustomerId customerId, EntityId entityId, String name) { + EntityView entityView = new EntityView(); + entityView.setTenantId(tenantId); + entityView.setEntityId(entityId); + entityView.setCustomerId(customerId); + entityView.setName(name); + entityView.setType("A"); + return entityViewService.saveEntityView(entityView); + } + + protected EntityRelation createRelation(EntityId from, EntityId to) { + EntityRelation relation = new EntityRelation(); + relation.setFrom(from); + relation.setTo(to); + relation.setType(EntityRelation.MANAGES_TYPE); + relation.setAdditionalInfo(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); + relation.setTypeGroup(RelationTypeGroup.COMMON); + relationService.saveRelation(TenantId.SYS_TENANT_ID, relation); + return relation; + } + + protected , I extends EntityId> EntityExportData exportEntity(User user, I entityId) throws Exception { + return exportEntity(user, entityId, EntityExportSettings.builder() + .exportCredentials(true) + .build()); + } + + protected , I extends EntityId> EntityExportData exportEntity(User user, I entityId, EntityExportSettings exportSettings) throws Exception { + return exportImportService.exportEntity(new SimpleEntitiesExportCtx(getSecurityUser(user), null, null, exportSettings), entityId); + } + + protected , I extends EntityId> EntityImportResult importEntity(User user, EntityExportData exportData) throws Exception { + return importEntity(user, exportData, EntityImportSettings.builder() + .saveCredentials(true) + .build()); + } + + protected , I extends EntityId> EntityImportResult importEntity(User user, EntityExportData exportData, EntityImportSettings importSettings) throws Exception { + EntitiesImportCtx ctx = new EntitiesImportCtx(UUID.randomUUID(), getSecurityUser(user), null, importSettings); + ctx.setFinalImportAttempt(true); + exportData = JacksonUtil.treeToValue(JacksonUtil.valueToTree(exportData), EntityExportData.class); + EntityImportResult importResult = exportImportService.importEntity(ctx, exportData); + exportImportService.saveReferencesAndRelations(ctx); + for (ThrowingRunnable throwingRunnable : ctx.getEventCallbacks()) { + throwingRunnable.run(); + } + return importResult; + } + + + @SuppressWarnings("rawTypes") + private static EntityExportData getAndClone(Map map, EntityType entityType) { + return JacksonUtil.clone(map.get(entityType)); + } + + protected SecurityUser getSecurityUser(User user) { + return new SecurityUser(user, true, new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail())); + } + } diff --git a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java new file mode 100644 index 0000000000..ffc88f7f5a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java @@ -0,0 +1,1004 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.google.common.collect.Streams; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.debug.TbMsgGeneratorNode; +import org.thingsboard.rule.engine.debug.TbMsgGeneratorNodeConfiguration; +import org.thingsboard.rule.engine.metadata.TbGetAttributesNode; +import org.thingsboard.rule.engine.metadata.TbGetAttributesNodeConfiguration; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.DashboardInfo; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileType; +import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.OtaPackage; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; +import org.thingsboard.server.common.data.device.data.DeviceData; +import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; +import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileTransportConfiguration; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbNodeConnectionType; +import org.thingsboard.server.common.data.ota.ChecksumAlgorithm; +import org.thingsboard.server.common.data.ota.OtaPackageType; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.script.ScriptLanguage; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.sync.vc.EntityTypeLoadResult; +import org.thingsboard.server.common.data.sync.vc.EntityVersion; +import org.thingsboard.server.common.data.sync.vc.RepositoryAuthMethod; +import org.thingsboard.server.common.data.sync.vc.RepositorySettings; +import org.thingsboard.server.common.data.sync.vc.VersionCreationResult; +import org.thingsboard.server.common.data.sync.vc.VersionLoadResult; +import org.thingsboard.server.common.data.sync.vc.request.create.ComplexVersionCreateRequest; +import org.thingsboard.server.common.data.sync.vc.request.create.EntityTypeVersionCreateConfig; +import org.thingsboard.server.common.data.sync.vc.request.create.SyncStrategy; +import org.thingsboard.server.common.data.sync.vc.request.create.VersionCreateRequest; +import org.thingsboard.server.common.data.sync.vc.request.load.EntityTypeVersionLoadConfig; +import org.thingsboard.server.common.data.sync.vc.request.load.EntityTypeVersionLoadRequest; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class VersionControlTest extends AbstractControllerTest { + + @Autowired + private EntitiesVersionControlService versionControlService; + @Autowired + private OtaPackageService otaPackageService; + + private TenantId tenantId1; + protected User tenantAdmin1; + + private TenantId tenantId2; + protected User tenantAdmin2; + + private String repoKey; + private String branch; + + @Before + public void beforeEach() throws Exception { + loginSysAdmin(); + Tenant tenant1 = new Tenant(); + tenant1.setTitle("Tenant 1"); + tenant1.setEmail("tenant1@thingsboard.org"); + tenant1 = saveTenant(tenant1); + this.tenantId1 = tenant1.getId(); + User tenantAdmin1 = new User(); + tenantAdmin1.setTenantId(tenantId1); + tenantAdmin1.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin1.setEmail("tenant1-admin@thingsboard.org"); + this.tenantAdmin1 = createUser(tenantAdmin1, tenantAdmin1.getEmail()); + + Tenant tenant2 = new Tenant(); + tenant2.setTitle("Tenant 2"); + tenant2.setEmail("tenant2@thingsboard.org"); + tenant2 = saveTenant(tenant2); + this.tenantId2 = tenant2.getId(); + User tenantAdmin2 = new User(); + tenantAdmin2.setTenantId(tenantId2); + tenantAdmin2.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin2.setEmail("tenant2-admin@thingsboard.org"); + this.tenantAdmin2 = createUser(tenantAdmin2, tenantAdmin2.getEmail()); + + this.repoKey = UUID.randomUUID().toString(); + this.branch = "test_" + repoKey; + configureRepository(tenantId1); + configureRepository(tenantId2); + + loginTenant1(); + } + + @Test + public void testAssetVc_withProfile_betweenTenants() throws Exception { + AssetProfile assetProfile = createAssetProfile(null, null, "Asset profile of tenant 1"); + Asset asset = createAsset(null, assetProfile.getId(), "Asset of tenant 1"); + String versionId = createVersion("assets and profiles", EntityType.ASSET, EntityType.ASSET_PROFILE); + assertThat(listVersions()).extracting(EntityVersion::getName).containsExactly("assets and profiles"); + + loginTenant2(); + Map result = loadVersion(versionId, EntityType.ASSET, EntityType.ASSET_PROFILE); + assertThat(result.get(EntityType.ASSET).getCreated()).isEqualTo(1); + assertThat(result.get(EntityType.ASSET_PROFILE).getCreated()).isEqualTo(1); + + Asset importedAsset = findAsset(asset.getName()); + checkImportedEntity(tenantId1, asset, tenantId2, importedAsset); + checkImportedAssetData(asset, importedAsset); + + AssetProfile importedAssetProfile = findAssetProfile(assetProfile.getName()); + checkImportedEntity(tenantId1, assetProfile, tenantId2, importedAssetProfile); + checkImportedAssetProfileData(assetProfile, importedAssetProfile); + + assertThat(importedAsset.getAssetProfileId()).isEqualTo(importedAssetProfile.getId()); + } + + @Test + public void testAssetVc_sameTenant() throws Exception { + AssetProfile assetProfile = createAssetProfile(null, null, "Asset profile v1.0"); + Asset asset = createAsset(null, assetProfile.getId(), "Asset v1.0"); + String versionId = createVersion("assets", EntityType.ASSET); + + loadVersion(versionId, EntityType.ASSET); + Asset importedAsset = findAsset(asset.getName()); + checkImportedEntity(tenantId1, asset, tenantId1, importedAsset); + checkImportedAssetData(asset, importedAsset); + } + + @Test + public void testAssetVc_sameTenant_withCustomer() throws Exception { + AssetProfile assetProfile = createAssetProfile(null, null, "Asset profile v1.0"); + Customer customer = createCustomer("My customer"); + Asset asset = createAsset(customer.getId(), assetProfile.getId(), "My asset"); + String versionId = createVersion("assets", EntityType.ASSET); + + loadVersion(versionId, EntityType.ASSET); + Asset importedAsset = findAsset(asset.getName()); + assertThat(importedAsset.getCustomerId()).isEqualTo(asset.getCustomerId()); + } + + @Test + public void testCustomerVc_sameTenant() throws Exception { + Customer customer = createCustomer("Customer v1.0"); + String versionId = createVersion("customers", EntityType.CUSTOMER); + + loadVersion(versionId, EntityType.CUSTOMER); + Customer importedCustomer = findCustomer(customer.getName()); + checkImportedEntity(tenantId1, customer, tenantId1, importedCustomer); + checkImportedCustomerData(customer, importedCustomer); + } + + @Test + public void testCustomerVc_betweenTenants() throws Exception { + Customer customer = createCustomer("Customer of tenant 1"); + String versionId = createVersion("customers", EntityType.CUSTOMER); + + loginTenant2(); + loadVersion(versionId, EntityType.CUSTOMER); + Customer importedCustomer = findCustomer(customer.getName()); + checkImportedEntity(tenantId1, customer, tenantId2, importedCustomer); + checkImportedCustomerData(customer, importedCustomer); + } + + @Test + public void testDeviceVc_sameTenant() throws Exception { + DeviceProfile deviceProfile = createDeviceProfile(null, null, "Device profile v1.0"); + OtaPackage firmware = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.FIRMWARE); + OtaPackage software = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.SOFTWARE); + Device device = createDevice(null, deviceProfile.getId(), "Device v1.0", "test1", newDevice -> { + newDevice.setFirmwareId(firmware.getId()); + newDevice.setSoftwareId(software.getId()); + }); + DeviceCredentials deviceCredentials = findDeviceCredentials(device.getId()); + String versionId = createVersion("devices", EntityType.DEVICE); + + loadVersion(versionId, EntityType.DEVICE); + Device importedDevice = findDevice(device.getName()); + + checkImportedEntity(tenantId1, device, tenantId1, importedDevice); + assertThat(importedDevice.getDeviceProfileId()).isEqualTo(device.getDeviceProfileId()); + assertThat(findDeviceCredentials(device.getId())).isEqualTo(deviceCredentials); + assertThat(importedDevice.getFirmwareId()).isEqualTo(firmware.getId()); + assertThat(importedDevice.getSoftwareId()).isEqualTo(software.getId()); + } + + @Test + public void testDeviceVc_withProfile_betweenTenants() throws Exception { + DeviceProfile deviceProfile = createDeviceProfile(null, null, "Device profile of tenant 1"); + createVersion("profiles", EntityType.DEVICE_PROFILE); + Device device = createDevice(null, deviceProfile.getId(), "Device of tenant 1", "test1"); + String versionId = createVersion("devices", EntityType.DEVICE); + DeviceCredentials deviceCredentials = findDeviceCredentials(device.getId()); + DeviceCredentials newCredentials = new DeviceCredentials(deviceCredentials); + newCredentials.setCredentialsId("new access token"); // updating access token to avoid constraint errors on import + doPost("/api/device/credentials", newCredentials, DeviceCredentials.class); + assertThat(listVersions()).extracting(EntityVersion::getName).containsExactly("devices", "profiles"); + + loginTenant2(); + Map result = loadVersion(versionId, EntityType.DEVICE, EntityType.DEVICE_PROFILE); + assertThat(result.get(EntityType.DEVICE).getCreated()).isEqualTo(1); + assertThat(result.get(EntityType.DEVICE_PROFILE).getCreated()).isEqualTo(1); + + Device importedDevice = findDevice(device.getName()); + checkImportedEntity(tenantId1, device, tenantId2, importedDevice); + checkImportedDeviceData(device, importedDevice); + + DeviceProfile importedDeviceProfile = findDeviceProfile(deviceProfile.getName()); + checkImportedEntity(tenantId1, deviceProfile, tenantId2, importedDeviceProfile); + checkImportedDeviceProfileData(deviceProfile, importedDeviceProfile); + + assertThat(importedDevice.getDeviceProfileId()).isEqualTo(importedDeviceProfile.getId()); + + DeviceCredentials importedCredentials = findDeviceCredentials(importedDevice.getId()); + assertThat(importedCredentials.getId()).isNotEqualTo(deviceCredentials.getId()); + assertThat(importedCredentials.getCredentialsId()).isEqualTo(deviceCredentials.getCredentialsId()); + assertThat(importedCredentials.getCredentialsValue()).isEqualTo(deviceCredentials.getCredentialsValue()); + assertThat(importedCredentials.getCredentialsType()).isEqualTo(deviceCredentials.getCredentialsType()); + } + + @Test + public void testDashboardVc_betweenTenants() throws Exception { + Dashboard dashboard = createDashboard(null, "Dashboard of tenant 1"); + String versionId = createVersion("dashboards", EntityType.DASHBOARD); + + loginTenant2(); + loadVersion(versionId, EntityType.DASHBOARD); + Dashboard importedDashboard = findDashboard(dashboard.getName()); + checkImportedEntity(tenantId1, dashboard, tenantId2, importedDashboard); + checkImportedDashboardData(dashboard, importedDashboard); + } + + @Test + public void testDashboardVc_sameTenant() throws Exception { + Dashboard dashboard = createDashboard(null, "Dashboard v1.0"); + String versionId = createVersion("dashboards", EntityType.DASHBOARD); + + loadVersion(versionId, EntityType.DASHBOARD); + Dashboard importedDashboard = findDashboard(dashboard.getName()); + checkImportedEntity(tenantId1, dashboard, tenantId1, importedDashboard); + checkImportedDashboardData(dashboard, importedDashboard); + } + + @Test + public void testDashboardVc_betweenTenants_withCustomer_updated() throws Exception { + Dashboard dashboard = createDashboard(null, "Dashboard of tenant 1"); + String versionId = createVersion("dashboards", EntityType.DASHBOARD); + + loginTenant2(); + loadVersion(versionId, EntityType.DASHBOARD); + Dashboard importedDashboard = findDashboard(dashboard.getName()); + checkImportedEntity(tenantId1, dashboard, tenantId2, importedDashboard); + + loginTenant1(); + Customer customer = createCustomer("Customer 1"); + versionId = createVersion("customers", EntityType.CUSTOMER); + assignDashboardToCustomer(dashboard.getId(), customer.getId()); + versionId = createVersion("assign dashboard", EntityType.DASHBOARD); + + loginTenant2(); + loadVersion(versionId, EntityType.DASHBOARD, EntityType.CUSTOMER); + Customer importedCustomer = findCustomer(customer.getName()); + importedDashboard = findDashboard(dashboard.getName()); + assertThat(importedDashboard.getAssignedCustomers()).hasOnlyOneElementSatisfying(customerInfo -> { + assertThat(customerInfo.getCustomerId()).isEqualTo(importedCustomer.getId()); + }); + } + + @Test + public void testDashboardVc_betweenTenants_withEntityAliases() throws Exception { + AssetProfile assetProfile = createAssetProfile(null, null, "A"); + Asset asset1 = createAsset(null, assetProfile.getId(), "Asset 1"); + Asset asset2 = createAsset(null, assetProfile.getId(), "Asset 2"); + Dashboard dashboard = createDashboard(null, "Dashboard 1"); + Dashboard otherDashboard = createDashboard(null, "Dashboard 2"); + loginTenant2(); + DeviceProfile existingDeviceProfile = createDeviceProfile(null, null, "Existing"); + + loginTenant1(); + String aliasId = "23c4185d-1497-9457-30b2-6d91e69a5b2c"; + String unknownUuid = "ea0dc8b0-3d85-11ed-9200-77fc04fa14fa"; + String entityAliases = "{\n" + + "\"" + aliasId + "\": {\n" + + "\"alias\": \"assets\",\n" + + "\"filter\": {\n" + + " \"entityList\": [\n" + + " \"" + asset1.getId() + "\",\n" + + " \"" + asset2.getId() + "\",\n" + + " \"" + tenantId1.getId() + "\",\n" + + " \"" + existingDeviceProfile.getId() + "\",\n" + + " \"" + unknownUuid + "\"\n" + + " ],\n" + + " \"id\":\"" + asset1.getId() + "\",\n" + + " \"resolveMultiple\": true\n" + + "},\n" + + "\"id\": \"" + aliasId + "\"\n" + + "}\n" + + "}"; + String widgetId = "ea8f34a0-264a-f11f-cde3-05201bb4ff4b"; + String actionId = "4a8e6efa-3e68-fa59-7feb-d83366130cae"; + String widgets = "{\n" + + " \"" + widgetId + "\": {\n" + + " \"config\": {\n" + + " \"actions\": {\n" + + " \"rowClick\": [\n" + + " {\n" + + " \"name\": \"go to dashboard\",\n" + + " \"targetDashboardId\": \"" + otherDashboard.getId() + "\",\n" + + " \"id\": \"" + actionId + "\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " },\n" + + " \"row\": 0,\n" + + " \"col\": 0,\n" + + " \"id\": \"" + widgetId + "\"\n" + + " }\n" + + "}"; + + ObjectNode dashboardConfiguration = JacksonUtil.newObjectNode(); + dashboardConfiguration.set("entityAliases", JacksonUtil.toJsonNode(entityAliases)); + dashboardConfiguration.set("widgets", JacksonUtil.toJsonNode(widgets)); + dashboardConfiguration.set("description", new TextNode("hallo")); + dashboard.setConfiguration(dashboardConfiguration); + dashboard = doPost("/api/dashboard", dashboard, Dashboard.class); + + String versionId = createVersion("dashboard with related", EntityType.ASSET, EntityType.ASSET_PROFILE, EntityType.DASHBOARD); + + loginTenant2(); + loadVersion(versionId, EntityType.ASSET, EntityType.ASSET_PROFILE, EntityType.DASHBOARD); + + AssetProfile importedProfile = findAssetProfile(assetProfile.getName()); + Asset importedAsset1 = findAsset(asset1.getName()); + Asset importedAsset2 = findAsset(asset2.getName()); + Dashboard importedOtherDashboard = findDashboard(otherDashboard.getName()); + Dashboard importedDashboard = findDashboard(dashboard.getName()); + + Map.Entry entityAlias = importedDashboard.getConfiguration().get("entityAliases").fields().next(); + assertThat(entityAlias.getKey()).isEqualTo(aliasId); + assertThat(entityAlias.getValue().get("id").asText()).isEqualTo(aliasId); + + List aliasEntitiesIds = Streams.stream(entityAlias.getValue().get("filter").get("entityList").elements()) + .map(JsonNode::asText).collect(Collectors.toList()); + assertThat(aliasEntitiesIds).size().isEqualTo(5); + assertThat(aliasEntitiesIds).element(0).as("external asset 1 was replaced with imported one") + .isEqualTo(importedAsset1.getId().toString()); + assertThat(aliasEntitiesIds).element(1).as("external asset 2 was replaced with imported one") + .isEqualTo(importedAsset2.getId().toString()); + assertThat(aliasEntitiesIds).element(2).as("external tenant id was replaced with new tenant id") + .isEqualTo(tenantId2.toString()); + assertThat(aliasEntitiesIds).element(3).as("existing device profile id was left as is") + .isEqualTo(existingDeviceProfile.getId().toString()); + assertThat(aliasEntitiesIds).element(4).as("unresolved uuid was replaced with tenant id") + .isEqualTo(tenantId2.toString()); + assertThat(entityAlias.getValue().get("filter").get("id").asText()).as("external asset 1 was replaced with imported one") + .isEqualTo(importedAsset1.getId().toString()); + + ObjectNode widgetConfig = importedDashboard.getWidgetsConfig().get(0); + assertThat(widgetConfig.get("id").asText()).as("widget id is not replaced") + .isEqualTo(widgetId); + JsonNode actionConfig = widgetConfig.get("config").get("actions").get("rowClick").get(0); + assertThat(actionConfig.get("id").asText()).as("action id is not replaced") + .isEqualTo(actionId); + assertThat(actionConfig.get("targetDashboardId").asText()).as("dashboard id is replaced with imported one") + .isEqualTo(importedOtherDashboard.getId().toString()); + } + + @Test + public void testRuleChainVc_betweenTenants() throws Exception { + RuleChain ruleChain = createRuleChain("Rule chain of tenant 1"); + RuleChainMetaData metaData = findRuleChainMetaData(ruleChain.getId()); + String versionId = createVersion("rule chains", EntityType.RULE_CHAIN); + + loginTenant2(); + loadVersion(versionId, EntityType.RULE_CHAIN); + RuleChain importedRuleChain = findRuleChain(ruleChain.getName()); + RuleChainMetaData importedMetaData = findRuleChainMetaData(importedRuleChain.getId()); + + checkImportedEntity(tenantId1, ruleChain, tenantId2, importedRuleChain); + checkImportedRuleChainData(ruleChain, metaData, importedRuleChain, importedMetaData); + } + + @Test + public void testRuleChainVc_sameTenant() throws Exception { + RuleChain ruleChain = createRuleChain("Rule chain v1.0"); + RuleChainMetaData metaData = findRuleChainMetaData(ruleChain.getId()); + String versionId = createVersion("rule chains", EntityType.RULE_CHAIN); + + loadVersion(versionId, EntityType.RULE_CHAIN); + RuleChain importedRuleChain = findRuleChain(ruleChain.getName()); + RuleChainMetaData importedMetaData = findRuleChainMetaData(importedRuleChain.getId()); + + checkImportedEntity(tenantId1, ruleChain, tenantId1, importedRuleChain); + checkImportedRuleChainData(ruleChain, metaData, importedRuleChain, importedMetaData); + } + + @Test + public void testRuleChainVc_ruleNodesConfigs() throws Exception { + Customer customer = createCustomer("Customer 1"); + RuleChain ruleChain = createRuleChain("Rule chain 1"); + RuleChainMetaData metaData = findRuleChainMetaData(ruleChain.getId()); + + List nodes = new ArrayList<>(metaData.getNodes()); + RuleNode generatorNode = new RuleNode(); + generatorNode.setName("Generator"); + generatorNode.setType(TbMsgGeneratorNode.class.getName()); + TbMsgGeneratorNodeConfiguration generatorNodeConfig = new TbMsgGeneratorNodeConfiguration(); + generatorNodeConfig.setOriginatorType(EntityType.ASSET_PROFILE); + generatorNodeConfig.setOriginatorId(customer.getId().toString()); + generatorNodeConfig.setPeriodInSeconds(5); + generatorNodeConfig.setMsgCount(1); + generatorNodeConfig.setScriptLang(ScriptLanguage.JS); + UUID someUuid = UUID.randomUUID(); + generatorNodeConfig.setJsScript("var msg = { temp: 42, humidity: 77 };\n" + + "var metadata = { data: 40 };\n" + + "var msgType = \"POST_TELEMETRY_REQUEST\";\n" + + "var someUuid = \"" + someUuid + "\";\n" + + "return { msg: msg, metadata: metadata, msgType: msgType };"); + generatorNode.setConfiguration(JacksonUtil.valueToTree(generatorNodeConfig)); + nodes.add(generatorNode); + metaData.setNodes(nodes); + doPost("/api/ruleChain/metadata", metaData, RuleChainMetaData.class); + + String versionId = createVersion("rule chains with customers", EntityType.RULE_CHAIN, EntityType.CUSTOMER); + + loginTenant2(); + loadVersion(versionId, EntityType.RULE_CHAIN, EntityType.CUSTOMER); + Customer importedCustomer = findCustomer(customer.getName()); + RuleChain importedRuleChain = findRuleChain(ruleChain.getName()); + RuleChainMetaData importedMetaData = findRuleChainMetaData(importedRuleChain.getId()); + + TbMsgGeneratorNodeConfiguration importedGeneratorNodeConfig = JacksonUtil.treeToValue(importedMetaData.getNodes().stream() + .filter(node -> node.getName().equals(generatorNode.getName())) + .findFirst().get().getConfiguration(), TbMsgGeneratorNodeConfiguration.class); + assertThat(importedGeneratorNodeConfig.getOriginatorId()).isEqualTo(importedCustomer.getId().toString()); + assertThat(importedGeneratorNodeConfig.getJsScript()).contains("var someUuid = \"" + someUuid + "\";"); + } + + @Test + public void testVcWithRelations_betweenTenants() throws Exception { + Asset asset = createAsset(null, null, "Asset 1"); + Device device = createDevice(null, null, "Device 1", "test1"); + EntityRelation relation = createRelation(asset.getId(), device.getId()); + String versionId = createVersion("assets and devices", EntityType.ASSET, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE); + + loginTenant2(); + loadVersion(versionId, config -> { + config.setLoadCredentials(false); + }, EntityType.ASSET, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE); + + Asset importedAsset = findAsset(asset.getName()); + Device importedDevice = findDevice(device.getName()); + checkImportedEntity(tenantId1, device, tenantId2, importedDevice); + checkImportedEntity(tenantId1, asset, tenantId2, importedAsset); + + List importedRelations = findRelationsByTo(importedDevice.getId()); + assertThat(importedRelations).size().isOne(); + assertThat(importedRelations.get(0)).satisfies(importedRelation -> { + assertThat(importedRelation.getFrom()).isEqualTo(importedAsset.getId()); + assertThat(importedRelation.getType()).isEqualTo(relation.getType()); + assertThat(importedRelation.getAdditionalInfo()).isEqualTo(relation.getAdditionalInfo()); + }); + } + + @Test + public void testVcWithRelations_sameTenant() throws Exception { + Asset asset = createAsset(null, null, "Asset 1"); + Device device1 = createDevice(null, null, "Device 1", "test1"); + EntityRelation relation1 = createRelation(device1.getId(), asset.getId()); + String versionId = createVersion("assets", EntityType.ASSET); + + Device device2 = createDevice(null, null, "Device 2", "test2"); + EntityRelation relation2 = createRelation(device2.getId(), asset.getId()); + List relations = findRelationsByTo(asset.getId()); + assertThat(relations).contains(relation1, relation2); + + loadVersion(versionId, EntityType.ASSET); + + relations = findRelationsByTo(asset.getId()); + assertThat(relations).contains(relation1); + assertThat(relations).doesNotContain(relation2); + } + + @Test + public void testDefaultDeviceProfileVc_betweenTenants_findExisting() throws Exception { + DeviceProfile defaultDeviceProfile = findDeviceProfile("default"); + defaultDeviceProfile.setName("non-default-name"); + doPost("/api/deviceProfile", defaultDeviceProfile, DeviceProfile.class); + String versionId = createVersion("device profiles", EntityType.DEVICE_PROFILE); + + loginTenant2(); + loadVersion(versionId, config -> { + config.setFindExistingEntityByName(false); + }, EntityType.DEVICE_PROFILE); + + DeviceProfile importedDeviceProfile = findDeviceProfile(defaultDeviceProfile.getName()); + assertThat(importedDeviceProfile.isDefault()).isTrue(); + assertThat(importedDeviceProfile.getName()).isEqualTo(defaultDeviceProfile.getName()); + checkImportedEntity(tenantId1, defaultDeviceProfile, tenantId2, importedDeviceProfile); + } + + private & HasTenantId> void checkImportedEntity(TenantId tenantId1, E initialEntity, TenantId tenantId2, E importedEntity) { + assertThat(initialEntity.getTenantId()).isEqualTo(tenantId1); + assertThat(importedEntity.getTenantId()).isEqualTo(tenantId2); + assertThat(importedEntity.getExternalId()).isEqualTo(initialEntity.getId()); + boolean sameTenant = tenantId1.equals(tenantId2); + if (sameTenant) { + assertThat(importedEntity.getId()).isEqualTo(initialEntity.getId()); + } else { + assertThat(importedEntity.getId()).isNotEqualTo(initialEntity.getId()); + } + } + + protected void checkImportedAssetData(Asset initialAsset, Asset importedAsset) { + assertThat(importedAsset.getName()).isEqualTo(initialAsset.getName()); + assertThat(importedAsset.getType()).isEqualTo(initialAsset.getType()); + assertThat(importedAsset.getLabel()).isEqualTo(initialAsset.getLabel()); + assertThat(importedAsset.getAdditionalInfo()).isEqualTo(initialAsset.getAdditionalInfo()); + } + + protected void checkImportedAssetProfileData(AssetProfile initialProfile, AssetProfile importedProfile) { + assertThat(initialProfile.getName()).isEqualTo(importedProfile.getName()); + assertThat(initialProfile.getDescription()).isEqualTo(importedProfile.getDescription()); + } + + protected void checkImportedDeviceData(Device initialDevice, Device importedDevice) { + assertThat(importedDevice.getName()).isEqualTo(initialDevice.getName()); + assertThat(importedDevice.getType()).isEqualTo(initialDevice.getType()); + assertThat(importedDevice.getDeviceData()).isEqualTo(initialDevice.getDeviceData()); + assertThat(importedDevice.getLabel()).isEqualTo(initialDevice.getLabel()); + } + + protected void checkImportedDeviceProfileData(DeviceProfile initialProfile, DeviceProfile importedProfile) { + assertThat(initialProfile.getName()).isEqualTo(importedProfile.getName()); + assertThat(initialProfile.getType()).isEqualTo(importedProfile.getType()); + assertThat(initialProfile.getTransportType()).isEqualTo(importedProfile.getTransportType()); + assertThat(initialProfile.getProfileData()).isEqualTo(importedProfile.getProfileData()); + assertThat(initialProfile.getDescription()).isEqualTo(importedProfile.getDescription()); + } + + protected void checkImportedCustomerData(Customer initialCustomer, Customer importedCustomer) { + assertThat(importedCustomer.getTitle()).isEqualTo(initialCustomer.getTitle()); + assertThat(importedCustomer.getCountry()).isEqualTo(initialCustomer.getCountry()); + assertThat(importedCustomer.getAddress()).isEqualTo(initialCustomer.getAddress()); + assertThat(importedCustomer.getEmail()).isEqualTo(initialCustomer.getEmail()); + } + + protected void checkImportedDashboardData(Dashboard initialDashboard, Dashboard importedDashboard) { + assertThat(importedDashboard.getTitle()).isEqualTo(initialDashboard.getTitle()); + assertThat(importedDashboard.getConfiguration()).isEqualTo(initialDashboard.getConfiguration()); + assertThat(importedDashboard.getImage()).isEqualTo(initialDashboard.getImage()); + assertThat(importedDashboard.isMobileHide()).isEqualTo(initialDashboard.isMobileHide()); + if (initialDashboard.getAssignedCustomers() != null) { + assertThat(importedDashboard.getAssignedCustomers()).containsAll(initialDashboard.getAssignedCustomers()); + } + } + + private String createVersion(String name, EntityType... entityTypes) throws Exception { + ComplexVersionCreateRequest request = new ComplexVersionCreateRequest(); + request.setVersionName(name); + request.setBranch(branch); + request.setSyncStrategy(SyncStrategy.MERGE); + request.setEntityTypes(Arrays.stream(entityTypes).collect(Collectors.toMap(t -> t, entityType -> { + EntityTypeVersionCreateConfig config = new EntityTypeVersionCreateConfig(); + config.setAllEntities(true); + + config.setSaveRelations(true); + config.setSaveAttributes(true); + config.setSaveCredentials(true); + return config; + }))); + + UUID requestId = doPostAsync("/api/entities/vc/version", request, UUID.class, status().isOk()); + VersionCreationResult result = await().atMost(30, TimeUnit.SECONDS) + .until(() -> doGet("/api/entities/vc/version/" + requestId + "/status", VersionCreationResult.class), r -> { + if (r.getError() != null) { + throw new RuntimeException("Failed to create version '" + name + "': " + r.getError()); + } + return r.isDone(); + }); + assertThat(result.getVersion()).isNotNull(); + return result.getVersion().getId(); + } + + private String createVersion(String name, EntityId... entities) throws Exception { + ComplexVersionCreateRequest request = new ComplexVersionCreateRequest(); + request.setVersionName(name); + request.setBranch(branch); + request.setSyncStrategy(SyncStrategy.MERGE); + request.setEntityTypes(new HashMap<>()); + Map> entitiesByType = Arrays.stream(entities) + .collect(Collectors.groupingBy(EntityId::getEntityType)); + entitiesByType.forEach((entityType, ids) -> { + EntityTypeVersionCreateConfig config = new EntityTypeVersionCreateConfig(); + config.setAllEntities(false); + config.setEntityIds(ids.stream().map(EntityId::getId).toList()); + + config.setSaveRelations(true); + config.setSaveAttributes(true); + config.setSaveCredentials(true); + request.getEntityTypes().put(entityType, config); + }); + + return createVersion(request); + } + + private String createVersion(VersionCreateRequest request) throws Exception { + UUID requestId = doPostAsync("/api/entities/vc/version", request, UUID.class, status().isOk()); + VersionCreationResult result = await().atMost(60, TimeUnit.SECONDS) + .until(() -> doGet("/api/entities/vc/version/" + requestId + "/status", VersionCreationResult.class), r -> { + if (r.getError() != null) { + throw new RuntimeException("Failed to create version '" + request.getVersionName() + "': " + r.getError()); + } + return r.isDone(); + }); + assertThat(result.getVersion()).isNotNull(); + return result.getVersion().getId(); + } + + private Map loadVersion(String versionId, EntityType... entityTypes) throws Exception { + return loadVersion(versionId, config -> {}, entityTypes); + } + + private Map loadVersion(String versionId, Consumer configModifier, EntityType... entityTypes) throws Exception { + assertThat(listVersions()).extracting(EntityVersion::getId).contains(versionId); + + EntityTypeVersionLoadRequest request = new EntityTypeVersionLoadRequest(); + request.setVersionId(versionId); + request.setRollbackOnError(true); + request.setEntityTypes(Arrays.stream(entityTypes).collect(Collectors.toMap(t -> t, entityType -> { + EntityTypeVersionLoadConfig config = new EntityTypeVersionLoadConfig(); + config.setLoadAttributes(true); + config.setLoadRelations(true); + config.setLoadCredentials(true); + config.setRemoveOtherEntities(false); + config.setFindExistingEntityByName(true); + configModifier.accept(config); + return config; + }))); + + UUID requestId = doPost("/api/entities/vc/entity", request, UUID.class); + VersionLoadResult result = await().atMost(60, TimeUnit.SECONDS) + .until(() -> doGet("/api/entities/vc/entity/" + requestId + "/status", VersionLoadResult.class), VersionLoadResult::isDone); + if (result.getError() != null) { + throw new RuntimeException("Failed to load version: " + result); + } + return result.getResult().stream().collect(Collectors.toMap(EntityTypeLoadResult::getEntityType, r -> r)); + } + + private List listVersions() throws Exception { + PageData versions = doGetAsyncTyped("/api/entities/vc/version?branch=" + branch + "&pageSize=100&page=0&sortProperty=timestamp&sortOrder=DESC", new TypeReference>() {}); + return versions.getData(); + } + + private void configureRepository(TenantId tenantId) throws Exception { + RepositorySettings repositorySettings = new RepositorySettings(); + repositorySettings.setLocalOnly(true); + repositorySettings.setDefaultBranch(branch); + repositorySettings.setAuthMethod(RepositoryAuthMethod.USERNAME_PASSWORD); + repositorySettings.setRepositoryUri(repoKey); + versionControlService.saveVersionControlSettings(tenantId, repositorySettings).get(); + } + + private void loginTenant1() throws Exception { + login(tenantAdmin1.getEmail(), tenantAdmin1.getEmail()); + } + + private void loginTenant2() throws Exception { + login(tenantAdmin2.getEmail(), tenantAdmin2.getEmail()); + } + + private Device createDevice(CustomerId customerId, DeviceProfileId deviceProfileId, String name, String accessToken, Consumer... modifiers) { + Device device = new Device(); + device.setCustomerId(customerId); + device.setName(name); + device.setLabel("lbl"); + device.setDeviceProfileId(deviceProfileId); + DeviceData deviceData = new DeviceData(); + deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); + device.setDeviceData(deviceData); + for (Consumer modifier : modifiers) { + modifier.accept(device); + } + return doPost("/api/device?accessToken=" + accessToken, device, Device.class); + } + + private DeviceProfile createDeviceProfile(RuleChainId defaultRuleChainId, DashboardId defaultDashboardId, String name) { + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setName(name); + deviceProfile.setDescription("dscrptn"); + deviceProfile.setType(DeviceProfileType.DEFAULT); + deviceProfile.setTransportType(DeviceTransportType.DEFAULT); + deviceProfile.setDefaultRuleChainId(defaultRuleChainId); + deviceProfile.setDefaultDashboardId(defaultDashboardId); + DeviceProfileData profileData = new DeviceProfileData(); + profileData.setConfiguration(new DefaultDeviceProfileConfiguration()); + profileData.setTransportConfiguration(new DefaultDeviceProfileTransportConfiguration()); + deviceProfile.setProfileData(profileData); + return doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + } + + protected EntityView createEntityView(CustomerId customerId, EntityId entityId, String name) { + EntityView entityView = new EntityView(); + entityView.setTenantId(tenantId); + entityView.setEntityId(entityId); + entityView.setCustomerId(customerId); + entityView.setName(name); + entityView.setType("A"); + return doPost("/api/entityView", entityView, EntityView.class); + } + + private AssetProfile createAssetProfile(RuleChainId defaultRuleChainId, DashboardId defaultDashboardId, String name) { + AssetProfile assetProfile = new AssetProfile(); + assetProfile.setName(name); + assetProfile.setDescription("dscrptn"); + assetProfile.setDefaultRuleChainId(defaultRuleChainId); + assetProfile.setDefaultDashboardId(defaultDashboardId); + return saveAssetProfile(assetProfile); + } + + private AssetProfile saveAssetProfile(AssetProfile assetProfile) { + return doPost("/api/assetProfile", assetProfile, AssetProfile.class); + } + + private Asset createAsset(CustomerId customerId, AssetProfileId assetProfileId, String name) { + Asset asset = new Asset(); + asset.setCustomerId(customerId); + asset.setAssetProfileId(assetProfileId); + asset.setName(name); + asset.setLabel("lbl"); + asset.setAdditionalInfo(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); + return doPost("/api/asset", asset, Asset.class); + } + + protected Customer createCustomer(String name) { + Customer customer = new Customer(); + customer.setTitle(name); + customer.setCountry("ua"); + customer.setAddress("abb"); + customer.setEmail("ccc@aa.org"); + customer.setAdditionalInfo(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); + return doPost("/api/customer", customer, Customer.class); + } + + protected OtaPackage createOtaPackage(TenantId tenantId, DeviceProfileId deviceProfileId, OtaPackageType type) { + OtaPackage otaPackage = new OtaPackage(); + otaPackage.setTenantId(tenantId); + otaPackage.setDeviceProfileId(deviceProfileId); + otaPackage.setType(type); + otaPackage.setTitle("My " + type); + otaPackage.setVersion("v1.0"); + otaPackage.setFileName("filename.txt"); + otaPackage.setContentType("text/plain"); + otaPackage.setChecksumAlgorithm(ChecksumAlgorithm.SHA256); + otaPackage.setChecksum("4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a"); + otaPackage.setDataSize(1L); + otaPackage.setData(ByteBuffer.wrap(new byte[]{(int) 1})); + return otaPackageService.saveOtaPackage(otaPackage); + } + + protected Dashboard createDashboard(CustomerId customerId, String name) { + Dashboard dashboard = new Dashboard(); + dashboard.setTitle(name); + dashboard.setConfiguration(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); + dashboard.setImage("abvregewrg"); + dashboard.setMobileHide(true); + dashboard = doPost("/api/dashboard", dashboard, Dashboard.class); + if (customerId != null) { + return assignDashboardToCustomer(dashboard.getId(), customerId); + } + return dashboard; + } + + protected Dashboard createDashboard(CustomerId customerId, String name, AssetId assetForEntityAlias) { + Dashboard dashboard = createDashboard(customerId, name); + String entityAliases = "{\n" + + "\t\"23c4185d-1497-9457-30b2-6d91e69a5b2c\": {\n" + + "\t\t\"alias\": \"assets\",\n" + + "\t\t\"filter\": {\n" + + "\t\t\t\"entityList\": [\n" + + "\t\t\t\t\"" + assetForEntityAlias.getId().toString() + "\"\n" + + "\t\t\t],\n" + + "\t\t\t\"entityType\": \"ASSET\",\n" + + "\t\t\t\"resolveMultiple\": true,\n" + + "\t\t\t\"type\": \"entityList\"\n" + + "\t\t},\n" + + "\t\t\"id\": \"23c4185d-1497-9457-30b2-6d91e69a5b2c\"\n" + + "\t}\n" + + "}"; + ObjectNode dashboardConfiguration = JacksonUtil.newObjectNode(); + dashboardConfiguration.set("entityAliases", JacksonUtil.toJsonNode(entityAliases)); + dashboardConfiguration.set("description", new TextNode("hallo")); + dashboard.setConfiguration(dashboardConfiguration); + return doPost("/api/dashboard", dashboard, Dashboard.class); + } + + protected RuleChain createRuleChain(String name, EntityId originatorId) throws Exception { + RuleChain ruleChain = new RuleChain(); + ruleChain.setName(name); + ruleChain.setType(RuleChainType.CORE); + ruleChain.setDebugMode(true); + ruleChain.setConfiguration(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); + ruleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); + + RuleChainMetaData metaData = new RuleChainMetaData(); + metaData.setRuleChainId(ruleChain.getId()); + + RuleNode ruleNode1 = new RuleNode(); + ruleNode1.setName("Generator 1"); + ruleNode1.setType(TbMsgGeneratorNode.class.getName()); + ruleNode1.setDebugMode(true); + TbMsgGeneratorNodeConfiguration configuration1 = new TbMsgGeneratorNodeConfiguration(); + configuration1.setOriginatorType(originatorId.getEntityType()); + configuration1.setOriginatorId(originatorId.getId().toString()); + ruleNode1.setConfiguration(JacksonUtil.valueToTree(configuration1)); + + RuleNode ruleNode2 = new RuleNode(); + ruleNode2.setName("Simple Rule Node 2"); + ruleNode2.setType(org.thingsboard.rule.engine.metadata.TbGetAttributesNode.class.getName()); + ruleNode2.setConfigurationVersion(TbGetAttributesNode.class.getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class).version()); + ruleNode2.setDebugMode(true); + TbGetAttributesNodeConfiguration configuration2 = new TbGetAttributesNodeConfiguration(); + configuration2.setServerAttributeNames(Collections.singletonList("serverAttributeKey2")); + ruleNode2.setConfiguration(JacksonUtil.valueToTree(configuration2)); + + metaData.setNodes(Arrays.asList(ruleNode1, ruleNode2)); + metaData.setFirstNodeIndex(0); + metaData.addConnectionInfo(0, 1, TbNodeConnectionType.SUCCESS); + doPost("/api/ruleChain/metadata", metaData, RuleChainMetaData.class); + + return doGet("/api/ruleChain/" + ruleChain.getUuidId(), RuleChain.class); + } + + protected RuleChain createRuleChain(String name) throws Exception { + RuleChain ruleChain = new RuleChain(); + ruleChain.setName(name); + ruleChain.setType(RuleChainType.CORE); + ruleChain.setDebugMode(true); + ruleChain.setConfiguration(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); + ruleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); + + RuleChainMetaData metaData = new RuleChainMetaData(); + metaData.setRuleChainId(ruleChain.getId()); + + RuleNode ruleNode1 = new RuleNode(); + ruleNode1.setName("Simple Rule Node 1"); + ruleNode1.setType(org.thingsboard.rule.engine.metadata.TbGetAttributesNode.class.getName()); + ruleNode1.setConfigurationVersion(TbGetAttributesNode.class.getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class).version()); + ruleNode1.setDebugMode(true); + TbGetAttributesNodeConfiguration configuration1 = new TbGetAttributesNodeConfiguration(); + configuration1.setServerAttributeNames(Collections.singletonList("serverAttributeKey1")); + ruleNode1.setConfiguration(JacksonUtil.valueToTree(configuration1)); + + RuleNode ruleNode2 = new RuleNode(); + ruleNode2.setName("Simple Rule Node 2"); + ruleNode2.setType(org.thingsboard.rule.engine.metadata.TbGetAttributesNode.class.getName()); + ruleNode2.setConfigurationVersion(TbGetAttributesNode.class.getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class).version()); + ruleNode2.setDebugMode(true); + TbGetAttributesNodeConfiguration configuration2 = new TbGetAttributesNodeConfiguration(); + configuration2.setServerAttributeNames(Collections.singletonList("serverAttributeKey2")); + ruleNode2.setConfiguration(JacksonUtil.valueToTree(configuration2)); + + metaData.setNodes(Arrays.asList(ruleNode1, ruleNode2)); + metaData.setFirstNodeIndex(0); + metaData.addConnectionInfo(0, 1, TbNodeConnectionType.SUCCESS); + doPost("/api/ruleChain/metadata", metaData, RuleChainMetaData.class); + + return doGet("/api/ruleChain/" + ruleChain.getUuidId(), RuleChain.class); + } + + protected EntityRelation createRelation(EntityId from, EntityId to) throws Exception { + EntityRelation relation = new EntityRelation(); + relation.setFrom(from); + relation.setTo(to); + relation.setType(EntityRelation.MANAGES_TYPE); + relation.setAdditionalInfo(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); + relation.setTypeGroup(RelationTypeGroup.COMMON); + return doPost("/api/v2/relation", relation, EntityRelation.class); + } + + protected void checkImportedRuleChainData(RuleChain initialRuleChain, RuleChainMetaData initialMetaData, RuleChain importedRuleChain, RuleChainMetaData importedMetaData) { + assertThat(importedRuleChain.getType()).isEqualTo(initialRuleChain.getType()); + assertThat(importedRuleChain.getName()).isEqualTo(initialRuleChain.getName()); + assertThat(importedRuleChain.isDebugMode()).isEqualTo(initialRuleChain.isDebugMode()); + assertThat(importedRuleChain.getConfiguration()).isEqualTo(initialRuleChain.getConfiguration()); + + assertThat(importedMetaData.getConnections()).isEqualTo(initialMetaData.getConnections()); + assertThat(importedMetaData.getFirstNodeIndex()).isEqualTo(initialMetaData.getFirstNodeIndex()); + for (int i = 0; i < initialMetaData.getNodes().size(); i++) { + RuleNode initialNode = initialMetaData.getNodes().get(i); + RuleNode importedNode = importedMetaData.getNodes().get(i); + assertThat(importedNode.getRuleChainId()).isEqualTo(importedRuleChain.getId()); + assertThat(importedNode.getName()).isEqualTo(initialNode.getName()); + assertThat(importedNode.getType()).isEqualTo(initialNode.getType()); + assertThat(importedNode.getConfiguration()).isEqualTo(initialNode.getConfiguration()); + assertThat(importedNode.getAdditionalInfo()).isEqualTo(initialNode.getAdditionalInfo()); + } + } + + private Dashboard assignDashboardToCustomer(DashboardId dashboardId, CustomerId customerId) { + return doPost("/api/customer/" + customerId + "/dashboard/" + dashboardId, Dashboard.class); + } + + private Asset findAsset(String name) throws Exception { + return doGetTypedWithPageLink("/api/tenant/assets?", new TypeReference>() {}, new PageLink(100, 0, name)).getData().get(0); + } + + private AssetProfile findAssetProfile(String name) throws Exception { + return doGetTypedWithPageLink("/api/assetProfiles?", new TypeReference>() {}, new PageLink(100, 0, name)).getData().get(0); + } + + private DeviceProfile findDeviceProfile(String name) throws Exception { + return doGetTypedWithPageLink("/api/deviceProfiles?", new TypeReference>() {}, new PageLink(100, 0, name)).getData().get(0); + } + + private Device findDevice(String name) throws Exception { + return doGetTypedWithPageLink("/api/tenant/devices?", new TypeReference>() {}, new PageLink(100, 0, name)).getData().get(0); + } + + private DeviceCredentials findDeviceCredentials(DeviceId deviceId) throws Exception { + return doGet("/api/device/" + deviceId + "/credentials", DeviceCredentials.class); + } + + private Customer findCustomer(String name) throws Exception { + return doGetTypedWithPageLink("/api/customers?", new TypeReference>() {}, new PageLink(100, 0, name)).getData().get(0); + } + + private Dashboard findDashboard(String name) throws Exception { + DashboardInfo dashboardInfo = doGetTypedWithPageLink("/api/tenant/dashboards?", new TypeReference>() {}, new PageLink(100, 0, name)).getData().get(0); + return doGet("/api/dashboard/" + dashboardInfo.getUuidId(), Dashboard.class); + } + + private RuleChain findRuleChain(String name) throws Exception { + return doGetTypedWithPageLink("/api/ruleChains?", new TypeReference>() {}, new PageLink(100, 0, name)).getData().get(0); + + } + + private RuleChainMetaData findRuleChainMetaData(RuleChainId ruleChainId) throws Exception { + return doGet("/api/ruleChain/" + ruleChainId + "/metadata", RuleChainMetaData.class); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java index 532b212af9..78e64b4f0a 100644 --- a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java +++ b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java @@ -20,6 +20,7 @@ import org.junit.Test; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.controller.AbstractControllerTest; @@ -29,6 +30,7 @@ import java.util.Map; import java.util.Random; import java.util.concurrent.atomic.AtomicInteger; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -40,6 +42,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. */ @TestPropertySource(properties = { "transport.http.enabled=true", + "transport.http.max_payload_size=10000" }) public abstract class BaseHttpDeviceApiTest extends AbstractControllerTest { @@ -74,6 +77,44 @@ public abstract class BaseHttpDeviceApiTest extends AbstractControllerTest { doGetAsync("/api/v1/" + deviceCredentials.getCredentialsId() + "/attributes?clientKeys=keyA,keyB,keyC").andExpect(status().isOk()); } + @Test + public void testReplyToCommandWithLargeResponse() throws Exception { + String errorResponse = doPost("/api/v1/" + deviceCredentials.getCredentialsId() + "/rpc/5", + JacksonUtil.toString(createRpcResponsePayload(10001)), + String.class, + status().isPayloadTooLarge()); + assertThat(errorResponse).contains("Payload size exceeds the limit"); + + doPost("/api/v1/" + deviceCredentials.getCredentialsId() + "/rpc/5", + JacksonUtil.toString(createRpcResponsePayload(10000)), + String.class, + status().isOk()); + } + + @Test + public void testPostRpcRequestWithLargeResponse() throws Exception { + String errorResponse = doPost("/api/v1/" + deviceCredentials.getCredentialsId() + "/rpc", + JacksonUtil.toString(createRpcRequestPayload(10001)), + String.class, + status().isPayloadTooLarge()); + assertThat(errorResponse).contains("Payload size exceeds the limit"); + + doPost("/api/v1/" + deviceCredentials.getCredentialsId() + "/rpc", + JacksonUtil.toString(createRpcRequestPayload(10000)), + String.class, + status().isOk()); + } + + private String createRpcResponsePayload(int size) { + String value = "a".repeat(size - 19); + return "{\"result\":\"" + value + "\"}"; + } + + private String createRpcRequestPayload(int size) { + String value = "a".repeat(size - 50); + return "{\"method\":\"get\",\"params\":{\"value\":\"" + value + "\"}}"; + } + protected ResultActions doGetAsync(String urlTemplate, Object... urlVariables) throws Exception { MockHttpServletRequestBuilder getRequest; getRequest = get(urlTemplate, urlVariables); diff --git a/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java b/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java index 74c2dfda37..5fde907bf1 100644 --- a/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java +++ b/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java @@ -17,20 +17,45 @@ package org.thingsboard.server.system; import lombok.extern.slf4j.Slf4j; import org.junit.Test; -import org.junit.jupiter.api.Assertions; +import org.springframework.http.MediaType; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.util.ClassUtils; import org.springframework.web.client.RestTemplate; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + @Slf4j public class RestTemplateConvertersTest { @Test - public void testJacksonXmlConverter() { + public void testMappingJackson2HttpMessageConverterIsUsedInsteadOfMappingJackson2XmlHttpMessageConverter() { ClassLoader classLoader = RestTemplate.class.getClassLoader(); boolean jackson2XmlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", classLoader); - Assertions.assertFalse(jackson2XmlPresent, "XmlMapper must not be present in classpath, please, exclude \"jackson-dataformat-xml\" dependency!"); - //If this xml mapper will be present in classpath then we will get "Unsupported Media Type" in RestTemplate + assertThat(jackson2XmlPresent).isTrue(); + + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer mockServer = MockRestServiceServer.createServer(restTemplate); + mockServer.expect(requestTo("/test")) + .andExpect(request -> { + MockClientHttpRequest mockRequest = (MockClientHttpRequest) request; + byte[] body = mockRequest.getBodyAsBytes(); + String requestBody = new String(body, StandardCharsets.UTF_8); + assertThat(requestBody).contains("{\"name\":\"test\",\"value\":1}"); + }) + .andRespond(withSuccess("{\"name\":\"test\",\"value\":1}", MediaType.APPLICATION_JSON)); + + TestObject requestObject = new TestObject("test", 1); + TestObject actualObject = restTemplate.postForObject("/test", requestObject, TestObject.class); + assertThat(actualObject).isEqualTo(requestObject); + mockServer.verify(); } + record TestObject(String name, int value) {} + } diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesIntegrationTest.java index a1bf7932e9..dbeb61c267 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesIntegrationTest.java @@ -16,7 +16,6 @@ package org.thingsboard.server.transport.coap.attributes.updates; import lombok.extern.slf4j.Slf4j; -import org.eclipse.californium.core.server.resources.Resource; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -25,11 +24,8 @@ import org.thingsboard.server.coapserver.DefaultCoapServerService; import org.thingsboard.server.common.transport.service.DefaultTransportService; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.transport.coap.CoapTestConfigProperties; -import org.thingsboard.server.transport.coap.CoapTransportResource; import org.thingsboard.server.transport.coap.attributes.AbstractCoapAttributesIntegrationTest; -import static org.mockito.Mockito.spy; - @Slf4j @DaoSqlTest public class CoapAttributesUpdatesIntegrationTest extends AbstractCoapAttributesIntegrationTest { diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/OtaLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/OtaLwM2MIntegrationTest.java index c7027ed42a..4f24c58916 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/OtaLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/OtaLwM2MIntegrationTest.java @@ -59,7 +59,7 @@ public class OtaLwM2MIntegrationTest extends AbstractOtaLwM2MIntegrationTest { createDeviceProfile(transportConfiguration); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(this.CLIENT_ENDPOINT_WITHOUT_FW_INFO)); final Device device = createDevice(deviceCredentials, this.CLIENT_ENDPOINT_WITHOUT_FW_INFO); - createNewClient(SECURITY_NO_SEC, null, false, this.CLIENT_ENDPOINT_WITHOUT_FW_INFO); + createNewClient(SECURITY_NO_SEC, null, false, this.CLIENT_ENDPOINT_WITHOUT_FW_INFO); awaitObserveReadAll(0, device.getId().getId().toString()); device.setFirmwareId(createFirmware().getId()); @@ -145,12 +145,14 @@ public class OtaLwM2MIntegrationTest extends AbstractOtaLwM2MIntegrationTest { return tsKvEntries; } - private boolean predicateForStatuses (List ts) { - List statuses = ts.stream().sorted(Comparator - .comparingLong(TsKvEntry::getTs)).map(KvEntry::getValueAsString) + private boolean predicateForStatuses(List ts) { + List statuses = ts.stream() + .sorted(Comparator.comparingLong(TsKvEntry::getTs)) + .map(KvEntry::getValueAsString) .map(OtaPackageUpdateStatus::valueOf) .collect(Collectors.toList()); log.warn("{}", statuses); return statuses.containsAll(expectedStatuses); } + } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java index 2684f210ca..e5c9cbf469 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.transport.lwm2m.security.sql; +import jakarta.servlet.http.HttpServletResponse; import org.eclipse.leshan.client.object.Security; import org.eclipse.leshan.core.util.Hex; import org.junit.Test; @@ -24,7 +25,6 @@ import org.thingsboard.server.common.data.device.credentials.lwm2m.PSKClientCred import org.thingsboard.server.common.data.device.profile.Lwm2mDeviceProfileTransportConfiguration; import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; -import jakarta.servlet.http.HttpServletResponse; import java.nio.charset.StandardCharsets; import static org.eclipse.leshan.client.object.Security.psk; diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/RpkLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/RpkLwM2MIntegrationTest.java index b6ebfc442e..c33d4b5059 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/RpkLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/RpkLwM2MIntegrationTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.transport.lwm2m.security.sql; +import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.codec.binary.Base64; import org.eclipse.leshan.client.object.Security; import org.eclipse.leshan.core.util.Hex; @@ -25,7 +26,6 @@ import org.thingsboard.server.common.data.device.credentials.lwm2m.RPKClientCred import org.thingsboard.server.common.data.device.profile.Lwm2mDeviceProfileTransportConfiguration; import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; -import jakarta.servlet.http.HttpServletResponse; import java.security.PrivateKey; import java.security.cert.X509Certificate; diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java index fc9df4e69b..452deef586 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java @@ -45,8 +45,8 @@ import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityDataUpdate; import org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest; import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestCallback; -import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestSubscribeOnTopicCallback; import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestClient; +import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestSubscribeOnTopicCallback; import java.util.ArrayList; import java.util.List; diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionJsonDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionJsonDeviceTest.java index 27ed65f54a..e82612963f 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionJsonDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionJsonDeviceTest.java @@ -35,8 +35,8 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest; import org.thingsboard.server.transport.mqtt.MqttTestConfigProperties; import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestCallback; -import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestSubscribeOnTopicCallback; import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestClient; +import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestSubscribeOnTopicCallback; import java.util.concurrent.TimeUnit; diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionProtoDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionProtoDeviceTest.java index 0926459513..2411d39004 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionProtoDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionProtoDeviceTest.java @@ -43,8 +43,8 @@ import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509Ce import org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest; import org.thingsboard.server.transport.mqtt.MqttTestConfigProperties; import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestCallback; -import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestSubscribeOnTopicCallback; import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestClient; +import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestSubscribeOnTopicCallback; import java.util.concurrent.TimeUnit; diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java index 603b42f6b6..561594f0bf 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java @@ -63,7 +63,9 @@ public abstract class AbstractMqttV5ClientSparkplugConnectionTest extends Abstra return finalFuture.get().get().isPresent(); }); TsKvEntry actualTsKvEntry = finalFuture.get().get().get(); - Assert.assertEquals(expectedTsKvEntry, actualTsKvEntry); + Assert.assertEquals(expectedTsKvEntry.getKey(), actualTsKvEntry.getKey()); + Assert.assertEquals(expectedTsKvEntry.getValue(), actualTsKvEntry.getValue()); + Assert.assertEquals(expectedTsKvEntry.getTs(), actualTsKvEntry.getTs()); } protected void processClientWithCorrectNodeAccessTokenWithoutNDEATH_Test() throws Exception { @@ -95,20 +97,27 @@ public abstract class AbstractMqttV5ClientSparkplugConnectionTest extends Abstra List devices = connectClientWithCorrectAccessTokenWithNDEATHCreatedDevices(cntDevices, ts); TsKvEntry tsKvEntry = new BasicTsKvEntry(ts, new StringDataEntry(messageName(STATE), ONLINE.name())); - AtomicReference>> finalFuture = new AtomicReference<>(); await(alias + messageName(STATE) + ", device: " + savedGateway.getName()) .atMost(40, TimeUnit.SECONDS) .until(() -> { - finalFuture.set(tsService.findAllLatest(tenantId, savedGateway.getId())); - return finalFuture.get().get().contains(tsKvEntry); + var foundEntry = tsService.findAllLatest(tenantId, savedGateway.getId()).get().stream() + .filter(tsKv -> tsKv.getKey().equals(tsKvEntry.getKey())) + .filter(tsKv -> tsKv.getValue().equals(tsKvEntry.getValue())) + .filter(tsKv -> tsKv.getTs() == tsKvEntry.getTs()) + .findFirst(); + return foundEntry.isPresent(); }); for (Device device : devices) { await(alias + messageName(STATE) + ", device: " + device.getName()) .atMost(40, TimeUnit.SECONDS) .until(() -> { - finalFuture.set(tsService.findAllLatest(tenantId, device.getId())); - return finalFuture.get().get().contains(tsKvEntry); + var foundEntry = tsService.findAllLatest(tenantId, device.getId()).get().stream() + .filter(tsKv -> tsKv.getKey().equals(tsKvEntry.getKey())) + .filter(tsKv -> tsKv.getValue().equals(tsKvEntry.getValue())) + .filter(tsKv -> tsKv.getTs() == tsKvEntry.getTs()) + .findFirst(); + return foundEntry.isPresent(); }); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java index af34e8bf0e..c637ce5bb1 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java @@ -78,7 +78,7 @@ public abstract class AbstractMqttV5ClientSparkplugTelemetryTest extends Abstrac finalFuture.set(tsService.findAllLatest(tenantId, savedGateway.getId())); return finalFuture.get().get().size() == (listTsKvEntry.size() + 1); }); - Assert.assertTrue("Actual tsKvEntrys is not containsAll Expected tsKvEntrys", finalFuture.get().get().containsAll(listTsKvEntry)); + Assert.assertTrue("Actual tsKvEntries is not containsAll Expected tsKvEntries", containsIgnoreVersion(finalFuture.get().get(), listTsKvEntry)); } protected void processClientWithCorrectAccessTokenPushNodeMetricBuildArraysPrimitiveSimple() throws Exception { @@ -107,7 +107,20 @@ public abstract class AbstractMqttV5ClientSparkplugTelemetryTest extends Abstrac finalFuture.set(tsService.findAllLatest(tenantId, savedGateway.getId())); return finalFuture.get().get().size() == (listTsKvEntry.size() + 1); }); - Assert.assertTrue("Actual tsKvEntrys is not containsAll Expected tsKvEntrys", finalFuture.get().get().containsAll(listTsKvEntry)); + Assert.assertTrue("Actual tsKvEntries is not containsAll Expected tsKvEntries", containsIgnoreVersion(finalFuture.get().get(), listTsKvEntry)); } + private static boolean containsIgnoreVersion(List expected, List actual) { + for (TsKvEntry actualEntry : actual) { + var found = expected.stream() + .filter(tsKv -> tsKv.getKey().equals(actualEntry.getKey())) + .filter(tsKv -> tsKv.getValue().equals(actualEntry.getValue())) + .filter(tsKv -> tsKv.getTs() == actualEntry.getTs()) + .findFirst(); + if (found.isEmpty()) { + return false; + } + } + return true; + } } diff --git a/build.sh b/build.sh new file mode 100755 index 0000000000..2c6a7d23fa --- /dev/null +++ b/build.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# +# 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. +# + +set -e # exit on any error + +#PROJECTS="msa/tb-node,msa/web-ui,rule-engine-pe/rule-node-twilio-sms" +PROJECTS="" + +if [ "$1" ]; then + PROJECTS="--projects $1" +fi + +echo "Building and pushing [amd64,arm64] projects '$PROJECTS' ..." +echo "HELP: usage ./build.sh [projects]" +echo "HELP: example ./build.sh msa/web-ui,msa/web-report" +java -version +#echo "Cleaning ui-ngx/node_modules" && rm -rf ui-ngx/node_modules + +MAVEN_OPTS="-Xmx1024m" NODE_OPTIONS="--max_old_space_size=4096" DOCKER_CLI_EXPERIMENTAL=enabled DOCKER_BUILDKIT=0 \ +mvn -T2 license:format clean install -DskipTests \ + $PROJECTS --also-make +# \ +# -Dpush-docker-amd-arm-images +# -Ddockerfile.skip=false -Dpush-docker-image=true +# --offline +# --projects '!msa/web-report' --also-make + +# push all +# mvn -T 1C license:format clean install -DskipTests -Ddockerfile.skip=false -Dpush-docker-image=true + + +## Build and push AMD and ARM docker images using docker buildx +## Reference to article how to setup docker miltiplatform build environment: https://medium.com/@artur.klauser/building-multi-architecture-docker-images-with-buildx-27d80f7e2408 +## install docker-ce from docker repo https://docs.docker.com/engine/install/ubuntu/ +# sudo apt install -y qemu-user-static binfmt-support +# export DOCKER_CLI_EXPERIMENTAL=enabled +# docker version +# docker run --rm --privileged multiarch/qemu-user-static --reset -p yes +# docker buildx create --name mybuilder +# docker buildx use mybuilder +# docker buildx inspect --bootstrap +# docker buildx ls +# mvn clean install -P push-docker-amd-arm-images \ No newline at end of file diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/CacheSpecsMap.java b/common/cache/src/main/java/org/thingsboard/server/cache/CacheSpecsMap.java index 583a871fe1..abca4624e6 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/CacheSpecsMap.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/CacheSpecsMap.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.cache; +import jakarta.annotation.PostConstruct; import lombok.Data; import lombok.Getter; import org.springframework.beans.factory.annotation.Value; @@ -22,7 +23,6 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; import org.thingsboard.server.common.data.CacheConstants; -import jakarta.annotation.PostConstruct; import java.util.Map; @Configuration diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/CaffeineTbCacheTransaction.java b/common/cache/src/main/java/org/thingsboard/server/cache/CaffeineTbCacheTransaction.java index 54465b0b50..47778c96b6 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/CaffeineTbCacheTransaction.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/CaffeineTbCacheTransaction.java @@ -38,10 +38,10 @@ public class CaffeineTbCacheTransaction pendingPuts = new LinkedHashMap<>(); + private final Map pendingPuts = new LinkedHashMap<>(); @Override - public void putIfAbsent(K key, V value) { + public void put(K key, V value) { pendingPuts.put(key, value); } diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/CaffeineTbTransactionalCache.java b/common/cache/src/main/java/org/thingsboard/server/cache/CaffeineTbTransactionalCache.java index 9c01b47b88..4ce6571f1c 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/CaffeineTbTransactionalCache.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/CaffeineTbTransactionalCache.java @@ -17,6 +17,7 @@ package org.thingsboard.server.cache; import lombok.Getter; import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import java.io.Serializable; @@ -26,6 +27,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.locks.Lock; @@ -34,17 +36,27 @@ import java.util.concurrent.locks.ReentrantLock; @RequiredArgsConstructor public abstract class CaffeineTbTransactionalCache implements TbTransactionalCache { - private final CacheManager cacheManager; @Getter - private final String cacheName; - - private final Lock lock = new ReentrantLock(); + protected final String cacheName; + protected final Cache cache; + protected final Lock lock = new ReentrantLock(); private final Map> objectTransactions = new HashMap<>(); private final Map> transactions = new HashMap<>(); + public CaffeineTbTransactionalCache(CacheManager cacheManager, String cacheName) { + this.cacheName = cacheName; + this.cache = Optional.ofNullable(cacheManager.getCache(cacheName)) + .orElseThrow(() -> new IllegalArgumentException("Cache '" + cacheName + "' is not configured")); + } + @Override public TbCacheValueWrapper get(K key) { - return SimpleTbCacheValueWrapper.wrap(cacheManager.getCache(cacheName).get(key)); + return SimpleTbCacheValueWrapper.wrap(cache.get(key)); + } + + @Override + public TbCacheValueWrapper get(K key, boolean transactionMode) { + return get(key); } @Override @@ -52,7 +64,7 @@ public abstract class CaffeineTbTransactionalCache newTransaction(List keys) { @@ -132,7 +144,7 @@ public abstract class CaffeineTbTransactionalCache pendingPuts) { + public boolean commit(UUID trId, Map pendingPuts) { lock.lock(); try { var tr = transactions.get(trId); @@ -181,7 +193,7 @@ public abstract class CaffeineTbTransactionalCache transactionsIds = objectTransactions.get(key); if (transactionsIds != null) { for (UUID otherTrId : transactionsIds) { diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbCacheTransaction.java b/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbCacheTransaction.java index 0cb2d661db..3dcb6e878f 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbCacheTransaction.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbCacheTransaction.java @@ -18,7 +18,6 @@ package org.thingsboard.server.cache; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.connection.RedisConnection; -import org.springframework.data.redis.connection.RedisStringCommands; import java.io.Serializable; import java.util.Objects; @@ -31,8 +30,8 @@ public class RedisTbCacheTransaction implements TbTransactionalCache { - private static final byte[] BINARY_NULL_VALUE = RedisSerializer.java().serialize(NullValue.INSTANCE); + static final byte[] BINARY_NULL_VALUE = RedisSerializer.java().serialize(NullValue.INSTANCE); static final JedisPool MOCK_POOL = new JedisPool(); //non-null pool required for JedisConnection to trigger closing jedis connection @Autowired @@ -52,11 +53,13 @@ public abstract class RedisTbTransactionalCache keySerializer = StringRedisSerializer.UTF_8; private final TbRedisSerializer valueSerializer; - private final Expiration evictExpiration; - private final Expiration cacheTtl; + protected final Expiration evictExpiration; + protected final Expiration cacheTtl; + protected final boolean cacheEnabled; public RedisTbTransactionalCache(String cacheName, CacheSpecsMap cacheSpecsMap, @@ -73,14 +76,28 @@ public abstract class RedisTbTransactionalCache Expiration.from(t, TimeUnit.MINUTES)) .orElseGet(Expiration::persistent); + this.cacheEnabled = Optional.ofNullable(cacheSpecsMap) + .map(CacheSpecsMap::getSpecs) + .map(x -> x.get(cacheName)) + .map(CacheSpecs::getMaxSize) + .map(size -> size > 0) + .orElse(false); } @Override public TbCacheValueWrapper get(K key) { + return get(key, false); + } + + @Override + public TbCacheValueWrapper get(K key, boolean transactionMode) { + if (!cacheEnabled) { + return null; + } try (var connection = connectionFactory.getConnection()) { byte[] rawKey = getRawKey(key); - byte[] rawValue = connection.get(rawKey); - if (rawValue == null) { + byte[] rawValue = doGet(connection, rawKey, transactionMode); + if (rawValue == null || rawValue.length == 0) { return null; } else if (Arrays.equals(rawValue, BINARY_NULL_VALUE)) { return SimpleTbCacheValueWrapper.empty(); @@ -96,15 +113,29 @@ public abstract class RedisTbTransactionalCache keys) { + if (!cacheEnabled) { + return; + } //Redis expects at least 1 key to delete. Otherwise - ERR wrong number of arguments for 'del' command if (keys.isEmpty()) { return; } try (var connection = connectionFactory.getConnection()) { - connection.del(keys.stream().map(this::getRawKey).toArray(byte[][]::new)); + connection.keyCommands().del(keys.stream().map(this::getRawKey).toArray(byte[][]::new)); } } @Override public void evictOrPut(K key, V value) { + if (!cacheEnabled) { + return; + } try (var connection = connectionFactory.getConnection()) { var rawKey = getRawKey(key); - var records = connection.del(rawKey); + var records = connection.keyCommands().del(rawKey); if (records == null || records == 0) { //We need to put the value in case of Redis, because evict will NOT cancel concurrent transaction used to "get" the missing value from cache. - connection.set(rawKey, getRawValue(value), evictExpiration, RedisStringCommands.SetOption.UPSERT); + connection.stringCommands().set(rawKey, getRawValue(value), evictExpiration, RedisStringCommands.SetOption.UPSERT); } } } @@ -153,7 +193,15 @@ public abstract class RedisTbTransactionalCache(this, connection); } - private RedisConnection getConnection(byte[] rawKey) { + @Override + public R getAndPutInTransaction(K key, Supplier dbCall, Function cacheValueToResult, Function dbValueToCacheValue, boolean cacheNullValue) { + if (!cacheEnabled) { + return dbCall.get(); + } + return TbTransactionalCache.super.getAndPutInTransaction(key, dbCall, cacheValueToResult, dbValueToCacheValue, cacheNullValue); + } + + protected RedisConnection getConnection(byte[] rawKey) { if (!connectionFactory.isRedisClusterAware()) { return connectionFactory.getConnection(); } @@ -168,7 +216,7 @@ public abstract class RedisTbTransactionalCache { - void putIfAbsent(K key, V value); + void put(K key, V value); boolean commit(); diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/TbJavaRedisSerializer.java b/common/cache/src/main/java/org/thingsboard/server/cache/TbJavaRedisSerializer.java new file mode 100644 index 0000000000..92c9f37900 --- /dev/null +++ b/common/cache/src/main/java/org/thingsboard/server/cache/TbJavaRedisSerializer.java @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cache; + +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.SerializationException; + +public class TbJavaRedisSerializer implements TbRedisSerializer { + + final RedisSerializer serializer = RedisSerializer.java(); + + @Override + public byte[] serialize(V value) throws SerializationException { + return serializer.serialize(value); + } + + @Override + public V deserialize(K key, byte[] bytes) throws SerializationException { + return (V) serializer.deserialize(bytes); + } + +} diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/TbTransactionalCache.java b/common/cache/src/main/java/org/thingsboard/server/cache/TbTransactionalCache.java index be0b38b65e..765c82a8db 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/TbTransactionalCache.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/TbTransactionalCache.java @@ -27,6 +27,8 @@ public interface TbTransactionalCache get(K key); + TbCacheValueWrapper get(K key, boolean transactionMode); + void put(K key, V value); void putIfAbsent(K key, V value); @@ -51,7 +53,7 @@ public interface TbTransactionalCache cacheValueWrapper = get(key); + TbCacheValueWrapper cacheValueWrapper = get(key, true); if (cacheValueWrapper != null) { return cacheValueWrapper.get(); } @@ -60,15 +62,20 @@ public interface TbTransactionalCache dbCall, boolean cacheNullValue) { - TbCacheValueWrapper cacheValueWrapper = get(key); + return getAndPutInTransaction(key, dbCall, Function.identity(), Function.identity(), cacheNullValue); + } + + default R getAndPutInTransaction(K key, Supplier dbCall, Function cacheValueToResult, Function dbValueToCacheValue, boolean cacheNullValue) { + TbCacheValueWrapper cacheValueWrapper = get(key, true); if (cacheValueWrapper != null) { - return cacheValueWrapper.get(); + V cacheValue = cacheValueWrapper.get(); + return cacheValue != null ? cacheValueToResult.apply(cacheValue) : null; } var cacheTransaction = newTransactionForKey(key); try { - V dbValue = dbCall.get(); + R dbValue = dbCall.get(); if (dbValue != null || cacheNullValue) { - cacheTransaction.putIfAbsent(key, dbValue); + cacheTransaction.put(key, dbValueToCacheValue.apply(dbValue)); cacheTransaction.commit(); return dbValue; } else { @@ -85,7 +92,7 @@ public interface TbTransactionalCache cacheValueWrapper = get(key); + TbCacheValueWrapper cacheValueWrapper = get(key, true); if (cacheValueWrapper != null) { var cacheValue = cacheValueWrapper.get(); return cacheValue == null ? null : cacheValueToResult.apply(cacheValue); @@ -94,27 +101,4 @@ public interface TbTransactionalCache R getAndPutInTransaction(K key, Supplier dbCall, Function cacheValueToResult, Function dbValueToCacheValue, boolean cacheNullValue) { - TbCacheValueWrapper cacheValueWrapper = get(key); - if (cacheValueWrapper != null) { - var cacheValue = cacheValueWrapper.get(); - return cacheValue == null ? null : cacheValueToResult.apply(cacheValue); - } - var cacheTransaction = newTransactionForKey(key); - try { - R dbValue = dbCall.get(); - if (dbValue != null || cacheNullValue) { - cacheTransaction.putIfAbsent(key, dbValueToCacheValue.apply(dbValue)); - cacheTransaction.commit(); - return dbValue; - } else { - cacheTransaction.rollback(); - return null; - } - } catch (Throwable e) { - cacheTransaction.rollback(); - throw e; - } - } - } diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/VersionedCaffeineTbCache.java b/common/cache/src/main/java/org/thingsboard/server/cache/VersionedCaffeineTbCache.java new file mode 100644 index 0000000000..f9c22ecc32 --- /dev/null +++ b/common/cache/src/main/java/org/thingsboard/server/cache/VersionedCaffeineTbCache.java @@ -0,0 +1,95 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cache; + +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.thingsboard.server.common.data.HasVersion; +import org.thingsboard.server.common.data.util.TbPair; + +import java.io.Serializable; + +public abstract class VersionedCaffeineTbCache extends CaffeineTbTransactionalCache implements VersionedTbCache { + + public VersionedCaffeineTbCache(CacheManager cacheManager, String cacheName) { + super(cacheManager, cacheName); + } + + @Override + public TbCacheValueWrapper get(K key) { + TbPair versionValuePair = doGet(key); + if (versionValuePair != null) { + return SimpleTbCacheValueWrapper.wrap(versionValuePair.getSecond()); + } + return null; + } + + @Override + public void put(K key, V value) { + Long version = getVersion(value); + if (version == null) { + return; + } + doPut(key, value, version); + } + + private void doPut(K key, V value, Long version) { + lock.lock(); + try { + TbPair versionValuePair = doGet(key); + if (versionValuePair == null || version > versionValuePair.getFirst()) { + failAllTransactionsByKey(key); + cache.put(key, wrapValue(value, version)); + } + } finally { + lock.unlock(); + } + } + + private TbPair doGet(K key) { + Cache.ValueWrapper source = cache.get(key); + return source == null ? null : (TbPair) source.get(); + } + + @Override + public void evict(K key) { + lock.lock(); + try { + failAllTransactionsByKey(key); + cache.evict(key); + } finally { + lock.unlock(); + } + } + + @Override + public void evict(K key, Long version) { + if (version == null) { + return; + } + doPut(key, null, version); + } + + @Override + void doPutIfAbsent(K key, V value) { + cache.putIfAbsent(key, wrapValue(value, getVersion(value))); + } + + private TbPair wrapValue(V value, Long version) { + return TbPair.of(version, value); + } + +} diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/VersionedRedisTbCache.java b/common/cache/src/main/java/org/thingsboard/server/cache/VersionedRedisTbCache.java new file mode 100644 index 0000000000..6ef3918a68 --- /dev/null +++ b/common/cache/src/main/java/org/thingsboard/server/cache/VersionedRedisTbCache.java @@ -0,0 +1,162 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cache; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.NotImplementedException; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.ReturnType; +import org.springframework.data.redis.core.types.Expiration; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.thingsboard.server.common.data.HasVersion; + +import java.io.Serializable; +import java.util.Arrays; + +@Slf4j +public abstract class VersionedRedisTbCache extends RedisTbTransactionalCache implements VersionedTbCache { + + private static final int VERSION_SIZE = 8; + private static final int VALUE_END_OFFSET = -1; + + static final byte[] SET_VERSIONED_VALUE_LUA_SCRIPT = StringRedisSerializer.UTF_8.serialize(""" + local key = KEYS[1] + local newValue = ARGV[1] + local newVersion = tonumber(ARGV[2]) + local expiration = tonumber(ARGV[3]) + + local function setNewValue() + local newValueWithVersion = struct.pack(">I8", newVersion) .. newValue + redis.call('SET', key, newValueWithVersion, 'EX', expiration) + end + + -- Get the current version (first 8 bytes) of the current value + local currentVersionBytes = redis.call('GETRANGE', key, 0, 7) + + if currentVersionBytes and #currentVersionBytes == 8 then + local currentVersion = struct.unpack(">I8", currentVersionBytes) + if newVersion > currentVersion then + setNewValue() + end + else + -- If the current value is absent or the current version is not found, set the new value + setNewValue() + end + """); + static final byte[] SET_VERSIONED_VALUE_SHA = StringRedisSerializer.UTF_8.serialize("0453cb1814135b706b4198b09a09f43c9f67bbfe"); + + public VersionedRedisTbCache(String cacheName, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory, TBRedisCacheConfiguration configuration, TbRedisSerializer valueSerializer) { + super(cacheName, cacheSpecsMap, connectionFactory, configuration, valueSerializer); + } + + @PostConstruct + public void init() { + try (var connection = getConnection(SET_VERSIONED_VALUE_SHA)) { + log.debug("Loading LUA with expected SHA[{}], connection [{}]", new String(SET_VERSIONED_VALUE_SHA), connection.getNativeConnection()); + String sha = connection.scriptingCommands().scriptLoad(SET_VERSIONED_VALUE_LUA_SCRIPT); + if (!Arrays.equals(SET_VERSIONED_VALUE_SHA, StringRedisSerializer.UTF_8.serialize(sha))) { + log.error("SHA for SET_VERSIONED_VALUE_LUA_SCRIPT wrong! Expected [{}], but actual [{}], connection [{}]", new String(SET_VERSIONED_VALUE_SHA), sha, connection.getNativeConnection()); + } + } catch (Throwable t) { + log.error("Error on Redis versioned cache init", t); + } + } + + @Override + protected byte[] doGet(RedisConnection connection, byte[] rawKey, boolean transactionMode) { + if (transactionMode) { + return super.doGet(connection, rawKey, true); + } + return connection.stringCommands().getRange(rawKey, VERSION_SIZE, VALUE_END_OFFSET); + } + + @Override + public void put(K key, V value) { + Long version = getVersion(value); + if (version == null) { + return; + } + doPut(key, value, version, cacheTtl); + } + + @Override + public void put(K key, V value, RedisConnection connection, boolean transactionMode) { + if (transactionMode) { + super.put(key, value, connection, true); // because scripting commands are not supported in transaction mode + return; + } + Long version = getVersion(value); + if (version == null) { + return; + } + byte[] rawKey = getRawKey(key); + doPut(rawKey, value, version, cacheTtl, connection); + } + + private void doPut(K key, V value, Long version, Expiration expiration) { + if (!cacheEnabled) { + return; + } + log.trace("put [{}][{}][{}]", key, value, version); + final byte[] rawKey = getRawKey(key); + try (var connection = getConnection(rawKey)) { + doPut(rawKey, value, version, expiration, connection); + } + } + + private void doPut(byte[] rawKey, V value, Long version, Expiration expiration, RedisConnection connection) { + byte[] rawValue = getRawValue(value); + byte[] rawVersion = StringRedisSerializer.UTF_8.serialize(String.valueOf(version)); + byte[] rawExpiration = StringRedisSerializer.UTF_8.serialize(String.valueOf(expiration.getExpirationTimeInSeconds())); + try { + connection.scriptingCommands().evalSha(SET_VERSIONED_VALUE_SHA, ReturnType.VALUE, 1, rawKey, rawValue, rawVersion, rawExpiration); + } catch (InvalidDataAccessApiUsageException e) { + log.debug("loading LUA [{}]", connection.getNativeConnection()); + String sha = connection.scriptingCommands().scriptLoad(SET_VERSIONED_VALUE_LUA_SCRIPT); + if (!Arrays.equals(SET_VERSIONED_VALUE_SHA, StringRedisSerializer.UTF_8.serialize(sha))) { + log.error("SHA for SET_VERSIONED_VALUE_LUA_SCRIPT wrong! Expected [{}], but actual [{}]", new String(SET_VERSIONED_VALUE_SHA), sha); + } + try { + connection.scriptingCommands().evalSha(SET_VERSIONED_VALUE_SHA, ReturnType.VALUE, 1, rawKey, rawValue, rawVersion, rawExpiration); + } catch (InvalidDataAccessApiUsageException ignored) { + log.debug("Slowly executing eval instead of fast evalsha"); + connection.scriptingCommands().eval(SET_VERSIONED_VALUE_LUA_SCRIPT, ReturnType.VALUE, 1, rawKey, rawValue, rawVersion, rawExpiration); + } + } + } + + @Override + public void evict(K key, Long version) { + log.trace("evict [{}][{}]", key, version); + if (version != null) { + doPut(key, null, version, evictExpiration); + } + } + + @Override + public void putIfAbsent(K key, V value) { + throw new NotImplementedException("putIfAbsent is not supported by versioned cache"); + } + + @Override + public void evictOrPut(K key, V value) { + throw new NotImplementedException("evictOrPut is not supported by versioned cache"); + } + +} diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/VersionedTbCache.java b/common/cache/src/main/java/org/thingsboard/server/cache/VersionedTbCache.java new file mode 100644 index 0000000000..faf63495b2 --- /dev/null +++ b/common/cache/src/main/java/org/thingsboard/server/cache/VersionedTbCache.java @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cache; + +import org.thingsboard.server.common.data.HasVersion; + +import java.io.Serializable; +import java.util.Collection; +import java.util.Optional; +import java.util.function.Supplier; + +public interface VersionedTbCache extends TbTransactionalCache { + + TbCacheValueWrapper get(K key); + + default V get(K key, Supplier supplier) { + return get(key, supplier, true); + } + + default V get(K key, Supplier supplier, boolean putToCache) { + return Optional.ofNullable(get(key)) + .map(TbCacheValueWrapper::get) + .orElseGet(() -> { + V value = supplier.get(); + if (putToCache) { + put(key, value); + } + return value; + }); + } + + void put(K key, V value); + + void evict(K key); + + void evict(Collection keys); + + void evict(K key, Long version); + + default Long getVersion(V value) { + if (value == null) { + return 0L; + } else if (value.getVersion() != null) { + return value.getVersion(); + } else { + return null; + } + } + +} diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceCacheEvictEvent.java b/common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceCacheEvictEvent.java index 63fa62f013..3fb78c53a9 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceCacheEvictEvent.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceCacheEvictEvent.java @@ -16,6 +16,7 @@ package org.thingsboard.server.cache.device; import lombok.Data; +import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; @@ -26,5 +27,6 @@ public class DeviceCacheEvictEvent { private final DeviceId deviceId; private final String newName; private final String oldName; + private Device savedDevice; } diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceCaffeineCache.java b/common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceCaffeineCache.java index d6e2e3e6cc..aa44010f53 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceCaffeineCache.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceCaffeineCache.java @@ -18,13 +18,13 @@ package org.thingsboard.server.cache.device; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.cache.CacheManager; import org.springframework.stereotype.Service; -import org.thingsboard.server.cache.CaffeineTbTransactionalCache; +import org.thingsboard.server.cache.VersionedCaffeineTbCache; import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.Device; @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) @Service("DeviceCache") -public class DeviceCaffeineCache extends CaffeineTbTransactionalCache { +public class DeviceCaffeineCache extends VersionedCaffeineTbCache { public DeviceCaffeineCache(CacheManager cacheManager) { super(cacheManager, CacheConstants.DEVICE_CACHE); diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceRedisCache.java b/common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceRedisCache.java index 03eea82f09..6e338a175a 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceRedisCache.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceRedisCache.java @@ -21,9 +21,9 @@ import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.serializer.SerializationException; import org.springframework.stereotype.Service; import org.thingsboard.server.cache.CacheSpecsMap; -import org.thingsboard.server.cache.RedisTbTransactionalCache; import org.thingsboard.server.cache.TBRedisCacheConfiguration; import org.thingsboard.server.cache.TbRedisSerializer; +import org.thingsboard.server.cache.VersionedRedisTbCache; import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.util.ProtoUtils; @@ -31,7 +31,7 @@ import org.thingsboard.server.gen.transport.TransportProtos; @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") @Service("DeviceCache") -public class DeviceRedisCache extends RedisTbTransactionalCache { +public class DeviceRedisCache extends VersionedRedisTbCache { public DeviceRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { super(CacheConstants.DEVICE_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbRedisSerializer<>() { diff --git a/common/cache/src/test/java/org/thingsboard/server/cache/CacheSpecsMapTest.java b/common/cache/src/test/java/org/thingsboard/server/cache/CacheSpecsMapTest.java index aec5e01f39..eb83cc2821 100644 --- a/common/cache/src/test/java/org/thingsboard/server/cache/CacheSpecsMapTest.java +++ b/common/cache/src/test/java/org/thingsboard/server/cache/CacheSpecsMapTest.java @@ -62,4 +62,5 @@ public class CacheSpecsMapTest { public void givenCacheConfig_whenCacheManagerReady_thenVerifyNonExistedCaches() { assertThat(cacheManager.getCache("rainbows_and_unicorns")).isNull(); } -} \ No newline at end of file + +} diff --git a/common/cache/src/test/java/org/thingsboard/server/cache/TsLatestRedisCacheTest.java b/common/cache/src/test/java/org/thingsboard/server/cache/TsLatestRedisCacheTest.java new file mode 100644 index 0000000000..d0f3042c61 --- /dev/null +++ b/common/cache/src/test/java/org/thingsboard/server/cache/TsLatestRedisCacheTest.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cache; + +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; + +import java.security.MessageDigest; + +import static org.assertj.core.api.Assertions.assertThat; + +class VersionedRedisTbCacheTest { + + @Test + void testUpsertTsLatestLUAScriptHash() { + assertThat(getSHA1(VersionedRedisTbCache.SET_VERSIONED_VALUE_LUA_SCRIPT)).isEqualTo(new String(VersionedRedisTbCache.SET_VERSIONED_VALUE_SHA)); + } + + @SneakyThrows + String getSHA1(byte[] script) { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + byte[] hash = md.digest(script); + + StringBuilder sb = new StringBuilder(); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + + return sb.toString(); + } + +} \ No newline at end of file diff --git a/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java b/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java index 301ef69dcc..ec37a5a864 100644 --- a/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java +++ b/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java @@ -18,7 +18,6 @@ package org.thingsboard.server.cluster; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; -import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; @@ -39,6 +38,7 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToVersionControlServiceMsg; +import org.thingsboard.server.gen.transport.TransportProtos.RestApiCallResponseMsgProto; import org.thingsboard.server.queue.TbQueueCallback; import org.thingsboard.server.queue.TbQueueClusterService; @@ -58,10 +58,14 @@ public interface TbClusterService extends TbQueueClusterService { void pushNotificationToCore(String targetServiceId, FromDeviceRpcResponse response, TbQueueCallback callback); + void pushNotificationToCore(String targetServiceId, RestApiCallResponseMsgProto msg, TbQueueCallback callback); + void pushMsgToRuleEngine(TopicPartitionInfo tpi, UUID msgId, ToRuleEngineMsg msg, TbQueueCallback callback); void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, TbMsg msg, TbQueueCallback callback); + void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, TbMsg msg, boolean useQueueFromTbMsg, TbQueueCallback callback); + void pushNotificationToRuleEngine(String targetServiceId, FromDeviceRpcResponse response, TbQueueCallback callback); void pushNotificationToTransport(String targetServiceId, ToTransportMsg response, TbQueueCallback callback); diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 508103a1db..3ef97a0dee 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.coapserver; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.eclipse.californium.core.CoapServer; import org.eclipse.californium.core.config.CoapConfig; @@ -27,8 +29,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.thingsboard.common.util.ThingsBoardThreadFactory; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.UnknownHostException; diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java index d869471fa3..a205ee9b84 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java @@ -17,7 +17,6 @@ package org.thingsboard.server.dao.attributes; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.AttributeScope; -import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; @@ -32,30 +31,18 @@ import java.util.Optional; */ public interface AttributesService { - @Deprecated(since = "3.7.0") - ListenableFuture> find(TenantId tenantId, EntityId entityId, String scope, String attributeKey); - ListenableFuture> find(TenantId tenantId, EntityId entityId, AttributeScope scope, String attributeKey); - @Deprecated(since = "3.7.0") - ListenableFuture> find(TenantId tenantId, EntityId entityId, String scope, Collection attributeKeys); - ListenableFuture> find(TenantId tenantId, EntityId entityId, AttributeScope scope, Collection attributeKeys); - @Deprecated(since = "3.7.0") - ListenableFuture> findAll(TenantId tenantId, EntityId entityId, String scope); - ListenableFuture> findAll(TenantId tenantId, EntityId entityId, AttributeScope scope); @Deprecated(since = "3.7.0") - ListenableFuture> save(TenantId tenantId, EntityId entityId, String scope, List attributes); - - ListenableFuture> save(TenantId tenantId, EntityId entityId, AttributeScope scope, List attributes); + ListenableFuture> save(TenantId tenantId, EntityId entityId, String scope, List attributes); - @Deprecated(since = "3.7.0") - ListenableFuture save(TenantId tenantId, EntityId entityId, String scope, AttributeKvEntry attribute); + ListenableFuture> save(TenantId tenantId, EntityId entityId, AttributeScope scope, List attributes); - ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute); + ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute); @Deprecated(since = "3.7.0") ListenableFuture> removeAll(TenantId tenantId, EntityId entityId, String scope, List attributeKeys); @@ -64,9 +51,6 @@ public interface AttributesService { List findAllKeysByDeviceProfileId(TenantId tenantId, DeviceProfileId deviceProfileId); - @Deprecated(since = "3.7.0") - List findAllKeysByEntityIds(TenantId tenantId, EntityType entityType, List entityIds); - List findAllKeysByEntityIds(TenantId tenantId, List entityIds); List findAllKeysByEntityIds(TenantId tenantId, List entityIds, String scope); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/AbstractCassandraCluster.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/AbstractCassandraCluster.java index d6e652c0fc..c7a7fa7798 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/AbstractCassandraCluster.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/AbstractCassandraCluster.java @@ -19,6 +19,7 @@ package org.thingsboard.server.dao.cassandra; import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.jmx.JmxReporter; import com.datastax.oss.driver.api.core.ConsistencyLevel; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -29,7 +30,6 @@ import org.thingsboard.server.dao.cassandra.guava.GuavaSession; import org.thingsboard.server.dao.cassandra.guava.GuavaSessionBuilder; import org.thingsboard.server.dao.cassandra.guava.GuavaSessionUtils; -import jakarta.annotation.PreDestroy; import java.nio.file.Paths; @Slf4j diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/CassandraCluster.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/CassandraCluster.java index c1ebb98f08..3655efa1e6 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/CassandraCluster.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/CassandraCluster.java @@ -15,12 +15,11 @@ */ package org.thingsboard.server.dao.cassandra; +import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.thingsboard.server.dao.util.NoSqlAnyDao; -import jakarta.annotation.PostConstruct; - @Component("CassandraCluster") @NoSqlAnyDao public class CassandraCluster extends AbstractCassandraCluster { diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/CassandraDriverOptions.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/CassandraDriverOptions.java index 23ddf6c96f..848d33eedb 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/CassandraDriverOptions.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/CassandraDriverOptions.java @@ -22,6 +22,7 @@ import com.datastax.oss.driver.api.core.config.DriverConfigLoader; import com.datastax.oss.driver.api.core.config.ProgrammaticDriverConfigLoaderBuilder; import com.datastax.oss.driver.api.core.metrics.DefaultNodeMetric; import com.datastax.oss.driver.api.core.metrics.DefaultSessionMetric; +import jakarta.annotation.PostConstruct; import lombok.Data; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; @@ -29,7 +30,6 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.dao.util.NoSqlAnyDao; -import jakarta.annotation.PostConstruct; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/CassandraInstallCluster.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/CassandraInstallCluster.java index 776c9db852..acbcadcb81 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/CassandraInstallCluster.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/CassandraInstallCluster.java @@ -15,12 +15,11 @@ */ package org.thingsboard.server.dao.cassandra; +import jakarta.annotation.PostConstruct; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; import org.thingsboard.server.dao.util.NoSqlAnyDao; -import jakarta.annotation.PostConstruct; - @Component("CassandraInstallCluster") @NoSqlAnyDao @Profile("install") diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java index 8a1f1cb5a8..005c740571 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java @@ -77,7 +77,7 @@ public interface DeviceService extends EntityDaoService { PageData findDevicesByTenantIdAndTypeAndEmptyOtaPackage(TenantId tenantId, DeviceProfileId deviceProfileId, OtaPackageType type, PageLink pageLink); - Long countDevicesByTenantIdAndDeviceProfileIdAndEmptyOtaPackage(TenantId tenantId, DeviceProfileId deviceProfileId, OtaPackageType otaPackageType); + long countDevicesByTenantIdAndDeviceProfileIdAndEmptyOtaPackage(TenantId tenantId, DeviceProfileId deviceProfileId, OtaPackageType otaPackageType); ListenableFuture> findDevicesByTenantIdAndIdsAsync(TenantId tenantId, List deviceIds); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/notification/NotificationService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/notification/NotificationService.java index 91f6f7a379..8f3a5934fc 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/notification/NotificationService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/notification/NotificationService.java @@ -20,9 +20,12 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import java.util.Set; + public interface NotificationService { Notification saveNotification(TenantId tenantId, Notification notification); @@ -35,7 +38,7 @@ public interface NotificationService { PageData findNotificationsByRecipientIdAndReadStatus(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, boolean unreadOnly, PageLink pageLink); - PageData findLatestUnreadNotificationsByRecipientId(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, int limit); + PageData findLatestUnreadNotificationsByRecipientIdAndNotificationTypes(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, Set types, int limit); int countUnreadNotificationsByRecipientId(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java index 05dc01a039..fbc79719e0 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java @@ -37,7 +37,7 @@ public interface RelationService { EntityRelation getRelation(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup); - boolean saveRelation(TenantId tenantId, EntityRelation relation); + EntityRelation saveRelation(TenantId tenantId, EntityRelation relation); void saveRelations(TenantId tenantId, List relations); @@ -47,7 +47,7 @@ public interface RelationService { ListenableFuture deleteRelationAsync(TenantId tenantId, EntityRelation relation); - boolean deleteRelation(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup); + EntityRelation deleteRelation(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup); ListenableFuture deleteRelationAsync(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java index 9a65fcfdae..96b97933aa 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.resource; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.HasImage; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.TbImageDeleteResult; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; @@ -36,9 +37,9 @@ public interface ImageService { TbResourceInfo getPublicImageInfoByKey(String publicResourceKey); - PageData getImagesByTenantId(TenantId tenantId, PageLink pageLink); + PageData getImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink); - PageData getAllImagesByTenantId(TenantId tenantId, PageLink pageLink); + PageData getAllImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink); byte[] getImageData(TenantId tenantId, TbResourceId imageId); @@ -46,6 +47,8 @@ public interface ImageService { TbImageDeleteResult deleteImage(TbResourceInfo imageInfo, boolean force); + String calculateImageEtag(byte[] imageData); + TbResourceInfo findSystemOrTenantImageByEtag(TenantId tenantId, String etag); boolean replaceBase64WithImageUrl(HasImage entity, String type); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java index ffea217da7..eaba144042 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java @@ -50,7 +50,7 @@ public interface TimeseriesService { ListenableFuture saveWithoutLatest(TenantId tenantId, EntityId entityId, List tsKvEntry, long ttl); - ListenableFuture> saveLatest(TenantId tenantId, EntityId entityId, List tsKvEntry); + ListenableFuture> saveLatest(TenantId tenantId, EntityId entityId, List tsKvEntry); ListenableFuture> remove(TenantId tenantId, EntityId entityId, List queries); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java index c3ecf80268..42f635cd43 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java @@ -17,12 +17,12 @@ package org.thingsboard.server.dao.user; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.mobile.MobileSessionInfo; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.id.UserCredentialsId; import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.mobile.MobileSessionInfo; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.UserCredentials; diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/util/SqlTsLatestAnyDaoCachedRedis.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/util/SqlTsLatestAnyDaoCachedRedis.java new file mode 100644 index 0000000000..634302a1f3 --- /dev/null +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/util/SqlTsLatestAnyDaoCachedRedis.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.util; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@ConditionalOnExpression("('${database.ts_latest.type}'=='sql' || '${database.ts_latest.type}'=='timescale') && '${cache.ts_latest.enabled:false}'=='true' && '${cache.type:caffeine}'=='redis' ") +public @interface SqlTsLatestAnyDaoCachedRedis { +} diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeService.java index 98360e4039..39a5210849 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeService.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.DeprecatedFilter; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetTypeFilter; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.dao.entity.EntityDaoService; @@ -40,11 +41,11 @@ public interface WidgetTypeService extends EntityDaoService { void deleteWidgetType(TenantId tenantId, WidgetTypeId widgetTypeId); - PageData findSystemWidgetTypesByPageLink(TenantId tenantId, boolean fullSearch, DeprecatedFilter deprecatedFilter, List widgetTypes, PageLink pageLink); + PageData findSystemWidgetTypesByPageLink(WidgetTypeFilter widgetTypeFilter, PageLink pageLink); - PageData findAllTenantWidgetTypesByTenantIdAndPageLink(TenantId tenantId, boolean fullSearch, DeprecatedFilter deprecatedFilter, List widgetTypes, PageLink pageLink); + PageData findAllTenantWidgetTypesByTenantIdAndPageLink(WidgetTypeFilter widgetTypeFilter, PageLink pageLink); - PageData findTenantWidgetTypesByTenantIdAndPageLink(TenantId tenantId, boolean fullSearch, DeprecatedFilter deprecatedFilter, List widgetTypes, PageLink pageLink); + PageData findTenantWidgetTypesByTenantIdAndPageLink(WidgetTypeFilter widgetTypeFilter, PageLink pageLink); List findWidgetTypesByWidgetsBundleId(TenantId tenantId, WidgetsBundleId widgetsBundleId); @@ -56,6 +57,8 @@ public interface WidgetTypeService extends EntityDaoService { WidgetType findWidgetTypeByTenantIdAndFqn(TenantId tenantId, String fqn); + WidgetTypeDetails findWidgetTypeDetailsByTenantIdAndFqn(TenantId tenantId, String fqn); + void updateWidgetsBundleWidgetTypes(TenantId tenantId, WidgetsBundleId widgetsBundleId, List widgetTypeIds); void updateWidgetsBundleWidgetFqns(TenantId tenantId, WidgetsBundleId widgetsBundleId, List widgetFqns); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleService.java index 396ba4e3fa..9dbc0149a9 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleService.java @@ -20,6 +20,7 @@ import org.thingsboard.server.common.data.id.WidgetsBundleId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.common.data.widget.WidgetsBundleFilter; import org.thingsboard.server.dao.entity.EntityDaoService; import java.util.List; @@ -34,15 +35,15 @@ public interface WidgetsBundleService extends EntityDaoService { WidgetsBundle findWidgetsBundleByTenantIdAndAlias(TenantId tenantId, String alias); - PageData findSystemWidgetsBundlesByPageLink(TenantId tenantId, boolean fullSearch, PageLink pageLink); + PageData findSystemWidgetsBundlesByPageLink(WidgetsBundleFilter widgetsBundleFilter, PageLink pageLink); List findSystemWidgetsBundles(TenantId tenantId); PageData findTenantWidgetsBundlesByTenantId(TenantId tenantId, PageLink pageLink); - PageData findAllTenantWidgetsBundlesByTenantIdAndPageLink(TenantId tenantId, boolean fullSearch, PageLink pageLink); + PageData findAllTenantWidgetsBundlesByTenantIdAndPageLink(WidgetsBundleFilter widgetsBundleFilter, PageLink pageLink); - PageData findTenantWidgetsBundlesByTenantIdAndPageLink(TenantId tenantId, boolean fullSearch, PageLink pageLink); + PageData findTenantWidgetsBundlesByTenantIdAndPageLink(WidgetsBundleFilter widgetsBundleFilter, PageLink pageLink); List findAllTenantWidgetsBundlesByTenantId(TenantId tenantId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index c95259aec5..2de57f36ac 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -36,6 +36,7 @@ public class CacheConstants { public static final String ASSET_PROFILE_CACHE = "assetProfiles"; public static final String ATTRIBUTES_CACHE = "attributes"; + public static final String TS_LATEST_CACHE = "tsLatest"; public static final String USERS_SESSION_INVALIDATION_CACHE = "userSessionsInvalidation"; public static final String OTA_PACKAGE_CACHE = "otaPackages"; public static final String OTA_PACKAGE_DATA_CACHE = "otaPackagesData"; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java b/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java index 398cd17aa1..07b91294f7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java @@ -20,7 +20,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty.Access; import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; @@ -30,7 +29,7 @@ import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; @EqualsAndHashCode(callSuper = true) -public class Customer extends ContactBased implements HasTenantId, ExportableEntity, HasTitle { +public class Customer extends ContactBased implements HasTenantId, ExportableEntity, HasTitle, HasVersion { private static final long serialVersionUID = -1599722990298929275L; @@ -43,6 +42,8 @@ public class Customer extends ContactBased implements HasTenantId, E @Getter @Setter private CustomerId externalId; + @Getter @Setter + private Long version; public Customer() { super(); @@ -57,6 +58,7 @@ public class Customer extends ContactBased implements HasTenantId, E this.tenantId = customer.getTenantId(); this.title = customer.getTitle(); this.externalId = customer.getExternalId(); + this.version = customer.getVersion(); } public TenantId getTenantId() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DashboardInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/DashboardInfo.java index 238ef8744c..a6cc81a51a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DashboardInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DashboardInfo.java @@ -17,19 +17,21 @@ package org.thingsboard.server.common.data; import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import lombok.Getter; +import lombok.Setter; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; -import jakarta.validation.Valid; import java.util.HashSet; import java.util.Objects; import java.util.Set; @Schema -public class DashboardInfo extends BaseData implements HasName, HasTenantId, HasTitle, HasImage { +public class DashboardInfo extends BaseData implements HasName, HasTenantId, HasTitle, HasImage, HasVersion { private static final long serialVersionUID = -9080404114760433799L; @@ -43,6 +45,9 @@ public class DashboardInfo extends BaseData implements HasName, Has private boolean mobileHide; private Integer mobileOrder; + @Getter @Setter + private Long version; + public DashboardInfo() { super(); } @@ -59,6 +64,7 @@ public class DashboardInfo extends BaseData implements HasName, Has this.assignedCustomers = dashboardInfo.getAssignedCustomers(); this.mobileHide = dashboardInfo.isMobileHide(); this.mobileOrder = dashboardInfo.getMobileOrder(); + this.version = dashboardInfo.getVersion(); } @Schema(description = "JSON object with the dashboard Id. " + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Device.java b/common/data/src/main/java/org/thingsboard/server/common/data/Device.java index e7c37d3ed1..f6478f4a0b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/Device.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/Device.java @@ -22,6 +22,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; +import lombok.ToString; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.device.data.DeviceData; import org.thingsboard.server.common.data.id.CustomerId; @@ -38,8 +39,9 @@ import java.util.Optional; @Schema @EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) @Slf4j -public class Device extends BaseDataWithAdditionalInfo implements HasLabel, HasTenantId, HasCustomerId, HasOtaPackage, ExportableEntity { +public class Device extends BaseDataWithAdditionalInfo implements HasLabel, HasTenantId, HasCustomerId, HasOtaPackage, HasVersion, ExportableEntity { private static final long serialVersionUID = 2807343040519543363L; @@ -65,6 +67,8 @@ public class Device extends BaseDataWithAdditionalInfo implements HasL @Getter @Setter private DeviceId externalId; + @Getter @Setter + private Long version; public Device() { super(); @@ -86,6 +90,7 @@ public class Device extends BaseDataWithAdditionalInfo implements HasL this.firmwareId = device.getFirmwareId(); this.softwareId = device.getSoftwareId(); this.externalId = device.getExternalId(); + this.version = device.getVersion(); } public Device updateDevice(Device device) { @@ -100,6 +105,7 @@ public class Device extends BaseDataWithAdditionalInfo implements HasL this.setSoftwareId(device.getSoftwareId()); Optional.ofNullable(device.getAdditionalInfo()).ifPresent(this::setAdditionalInfo); this.setExternalId(device.getExternalId()); + this.setVersion(device.getVersion()); return this; } @@ -225,33 +231,4 @@ public class Device extends BaseDataWithAdditionalInfo implements HasL return super.getAdditionalInfo(); } - @Override - public String toString() { - StringBuilder builder = new StringBuilder(); - builder.append("Device [tenantId="); - builder.append(tenantId); - builder.append(", customerId="); - builder.append(customerId); - builder.append(", name="); - builder.append(name); - builder.append(", type="); - builder.append(type); - builder.append(", label="); - builder.append(label); - builder.append(", deviceProfileId="); - builder.append(deviceProfileId); - builder.append(", deviceData="); - builder.append(firmwareId); - builder.append(", firmwareId="); - builder.append(deviceData); - builder.append(", additionalInfo="); - builder.append(getAdditionalInfo()); - builder.append(", createdTime="); - builder.append(createdTime); - builder.append(", id="); - builder.append(id); - builder.append("]"); - return builder.toString(); - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceInfo.java index c324f96ac2..6a583afbd1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceInfo.java @@ -18,11 +18,13 @@ package org.thingsboard.server.common.data; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.ToString; import org.thingsboard.server.common.data.id.DeviceId; @Schema @Data @EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) public class DeviceInfo extends Device { private static final long serialVersionUID = -3004579925090663691L; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java index 6791a460ed..df297ba85e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java @@ -42,7 +42,7 @@ import java.io.IOException; @ToString(exclude = {"image", "profileDataBytes"}) @EqualsAndHashCode(callSuper = true) @Slf4j -public class DeviceProfile extends BaseData implements HasName, HasTenantId, HasOtaPackage, HasRuleEngineProfile, ExportableEntity, HasImage, HasDefaultOption { +public class DeviceProfile extends BaseData implements HasName, HasTenantId, HasOtaPackage, HasRuleEngineProfile, ExportableEntity, HasImage, HasDefaultOption, HasVersion { private static final long serialVersionUID = 6998485460273302018L; @@ -97,6 +97,7 @@ public class DeviceProfile extends BaseData implements HasName, private RuleChainId defaultEdgeRuleChainId; private DeviceProfileId externalId; + private Long version; public DeviceProfile() { super(); @@ -122,6 +123,7 @@ public class DeviceProfile extends BaseData implements HasName, this.softwareId = deviceProfile.getSoftwareId(); this.defaultEdgeRuleChainId = deviceProfile.getDefaultEdgeRuleChainId(); this.externalId = deviceProfile.getExternalId(); + this.version = deviceProfile.getVersion(); } @Schema(description = "JSON object with the device profile Id. " + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java index 344045134e..8c7a89a220 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -36,7 +36,7 @@ import org.thingsboard.server.common.data.validation.NoXss; @AllArgsConstructor @EqualsAndHashCode(callSuper = true) public class EntityView extends BaseDataWithAdditionalInfo - implements HasName, HasTenantId, HasCustomerId, ExportableEntity { + implements HasName, HasTenantId, HasCustomerId, HasVersion, ExportableEntity { private static final long serialVersionUID = 5582010124562018986L; @@ -60,6 +60,7 @@ public class EntityView extends BaseDataWithAdditionalInfo private long endTimeMs; private EntityViewId externalId; + private Long version; public EntityView() { super(); @@ -80,6 +81,7 @@ public class EntityView extends BaseDataWithAdditionalInfo this.startTimeMs = entityView.getStartTimeMs(); this.endTimeMs = entityView.getEndTimeMs(); this.externalId = entityView.getExternalId(); + this.version = entityView.getVersion(); } @Schema(description = "JSON object with Customer Id. Use 'assignEntityViewToCustomer' to change the Customer Id.", accessMode = Schema.AccessMode.READ_ONLY) @@ -102,7 +104,7 @@ public class EntityView extends BaseDataWithAdditionalInfo @Schema(description = "JSON object with the Entity View Id. " + "Specify this field to update the Entity View. " + "Referencing non-existing Entity View Id will cause error. " + - "Omit this field to create new Entity View." ) + "Omit this field to create new Entity View.") @Override public EntityViewId getId() { return super.getId(); @@ -114,7 +116,7 @@ public class EntityView extends BaseDataWithAdditionalInfo return super.getCreatedTime(); } - @Schema(description = "Additional parameters of the device",implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "Additional parameters of the device", implementation = com.fasterxml.jackson.databind.JsonNode.class) @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/HasOtaPackage.java b/common/data/src/main/java/org/thingsboard/server/common/data/HasOtaPackage.java index 1b62f4c36a..93a170ef94 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/HasOtaPackage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/HasOtaPackage.java @@ -22,4 +22,8 @@ public interface HasOtaPackage { OtaPackageId getFirmwareId(); OtaPackageId getSoftwareId(); + + void setFirmwareId(OtaPackageId otaPackageId); + + void setSoftwareId(OtaPackageId otaPackageId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventCleanupRepository.java b/common/data/src/main/java/org/thingsboard/server/common/data/HasVersion.java similarity index 74% rename from dao/src/main/java/org/thingsboard/server/dao/sql/event/EventCleanupRepository.java rename to common/data/src/main/java/org/thingsboard/server/common/data/HasVersion.java index 3b9aa3d5c1..db916d2195 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventCleanupRepository.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/HasVersion.java @@ -13,11 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao.sql.event; +package org.thingsboard.server.common.data; -public interface EventCleanupRepository { +public interface HasVersion { - void cleanupEvents(long eventExpTime, boolean debug); + Long getVersion(); + + default void setVersion(Long version) { + } - void migrateEvents(long regularEventTs, long debugEventTs); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ImageExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/ImageExportData.java index b407563d24..3be7b6f92d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ImageExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ImageExportData.java @@ -33,6 +33,7 @@ public class ImageExportData { private String mediaType; private String fileName; private String title; + private String subType; private String resourceKey; private boolean isPublic; private String publicResourceKey; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java new file mode 100644 index 0000000000..10a033f75d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java @@ -0,0 +1,21 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data; + +public enum ResourceSubType { + IMAGE, + SCADA_SYMBOL +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SaveDeviceWithCredentialsRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/SaveDeviceWithCredentialsRequest.java index 24991bb71d..002deb1a8d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SaveDeviceWithCredentialsRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SaveDeviceWithCredentialsRequest.java @@ -16,10 +16,11 @@ package org.thingsboard.server.common.data; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.thingsboard.server.common.data.security.DeviceCredentials; -import jakarta.validation.constraints.NotNull;; +; @Schema @Data diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java index f1318c2257..8537fe68ed 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java @@ -46,6 +46,8 @@ public class TbResourceInfo extends BaseData implements HasName, H private String title; @Schema(description = "Resource type.", example = "LWM2M_MODEL", accessMode = Schema.AccessMode.READ_ONLY) private ResourceType resourceType; + @Schema(description = "Resource sub type.", example = "IOT_SVG", accessMode = Schema.AccessMode.READ_ONLY) + private ResourceSubType resourceSubType; @NoXss @Length(fieldName = "resourceKey") @Schema(description = "Resource key.", example = "19_1.0", accessMode = Schema.AccessMode.READ_ONLY) @@ -78,6 +80,7 @@ public class TbResourceInfo extends BaseData implements HasName, H this.tenantId = resourceInfo.tenantId; this.title = resourceInfo.title; this.resourceType = resourceInfo.resourceType; + this.resourceSubType = resourceInfo.resourceSubType; this.resourceKey = resourceInfo.resourceKey; this.searchText = resourceInfo.searchText; this.isPublic = resourceInfo.isPublic; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfoFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfoFilter.java index 87d9b69749..60583dbb96 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfoFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfoFilter.java @@ -27,5 +27,6 @@ public class TbResourceInfoFilter { private TenantId tenantId; private Set resourceTypes; + private Set resourceSubTypes; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java b/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java index b7fade3e09..0c0c5c760a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.validation.Length; @@ -27,7 +29,7 @@ import org.thingsboard.server.common.data.validation.NoXss; @Schema @EqualsAndHashCode(callSuper = true) -public class Tenant extends ContactBased implements HasTenantId, HasTitle { +public class Tenant extends ContactBased implements HasTenantId, HasTitle, HasVersion { private static final long serialVersionUID = 8057243243859922101L; @@ -43,6 +45,9 @@ public class Tenant extends ContactBased implements HasTenantId, HasTi @Schema(description = "JSON object with Tenant Profile Id") private TenantProfileId tenantProfileId; + @Getter @Setter + private Long version; + public Tenant() { super(); } @@ -56,6 +61,7 @@ public class Tenant extends ContactBased implements HasTenantId, HasTi this.title = tenant.getTitle(); this.region = tenant.getRegion(); this.tenantProfileId = tenant.getTenantProfileId(); + this.version = tenant.getVersion(); } public String getTitle() { @@ -98,7 +104,7 @@ public class Tenant extends ContactBased implements HasTenantId, HasTi @Schema(description = "JSON object with the tenant Id. " + "Specify this field to update the tenant. " + "Referencing non-existing tenant Id will cause error. " + - "Omit this field to create new tenant." ) + "Omit this field to create new tenant.") @Override public TenantId getId() { return super.getId(); @@ -158,7 +164,7 @@ public class Tenant extends ContactBased implements HasTenantId, HasTi return super.getEmail(); } - @Schema(description = "Additional parameters of the device",implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "Additional parameters of the device", implementation = com.fasterxml.jackson.databind.JsonNode.class) @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/User.java b/common/data/src/main/java/org/thingsboard/server/common/data/User.java index 678b7cdac6..7b616d2e54 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/User.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/User.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; @@ -33,7 +35,7 @@ import static org.apache.commons.lang3.StringUtils.isNotEmpty; @Schema @EqualsAndHashCode(callSuper = true) -public class User extends BaseDataWithAdditionalInfo implements HasName, HasTenantId, HasCustomerId, NotificationRecipient { +public class User extends BaseDataWithAdditionalInfo implements HasName, HasTenantId, HasCustomerId, NotificationRecipient, HasVersion { private static final long serialVersionUID = 8250339805336035966L; @@ -50,6 +52,9 @@ public class User extends BaseDataWithAdditionalInfo implements HasName, @NoXss private String phone; + @Getter @Setter + private Long version; + public User() { super(); } @@ -67,6 +72,7 @@ public class User extends BaseDataWithAdditionalInfo implements HasName, this.firstName = user.getFirstName(); this.lastName = user.getLastName(); this.phone = user.getPhone(); + this.version = user.getVersion(); } @@ -222,4 +228,5 @@ public class User extends BaseDataWithAdditionalInfo implements HasName, public boolean isCustomerUser() { return !isSystemAdmin() && !isTenantAdmin(); } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java index 0bd8e3a687..a7cfd7fc90 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java @@ -26,7 +26,6 @@ import org.thingsboard.server.common.data.BaseData; import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; -import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java index c39c603a9e..acdf4c5cc4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java @@ -17,8 +17,11 @@ package org.thingsboard.server.common.data.alarm; import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.Builder; import lombok.Data; +import lombok.ToString; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; @@ -27,9 +30,6 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; - @Data @Builder public class AlarmCreateOrUpdateActiveRequest implements AlarmModificationRequest { @@ -53,9 +53,12 @@ public class AlarmCreateOrUpdateActiveRequest implements AlarmModificationReques private long startTs; @Schema(description = "Timestamp of the alarm end time(last time update), in milliseconds", example = "1634111163522") private long endTs; + + @ToString.Exclude @NoXss @Schema(description = "JSON object with alarm details") private JsonNode details; + @Valid @Schema(description = "JSON object with propagation details") private AlarmPropagationInfo propagation; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmUpdateRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmUpdateRequest.java index 8eb418d6dc..c98b652102 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmUpdateRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmUpdateRequest.java @@ -17,16 +17,16 @@ package org.thingsboard.server.common.data.alarm; import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.Builder; import lombok.Data; +import lombok.ToString; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.validation.NoXss; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; - @Data @Builder public class AlarmUpdateRequest implements AlarmModificationRequest { @@ -47,9 +47,12 @@ public class AlarmUpdateRequest implements AlarmModificationRequest { private long startTs; @Schema(description = "Timestamp of the alarm end time(last time update), in milliseconds", example = "1634111163522") private long endTs; + + @ToString.Exclude @NoXss @Schema(description = "JSON object with alarm details") private JsonNode details; + @Valid @Schema(description = "JSON object with propagation details") private AlarmPropagationInfo propagation; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java b/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java index 16518bc618..4b79752135 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java @@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.ExportableEntity; import org.thingsboard.server.common.data.HasCustomerId; import org.thingsboard.server.common.data.HasLabel; import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CustomerId; @@ -36,7 +37,7 @@ import java.util.Optional; @Schema @EqualsAndHashCode(callSuper = true) -public class Asset extends BaseDataWithAdditionalInfo implements HasLabel, HasTenantId, HasCustomerId, ExportableEntity { +public class Asset extends BaseDataWithAdditionalInfo implements HasLabel, HasTenantId, HasCustomerId, HasVersion, ExportableEntity { private static final long serialVersionUID = 2807343040519543363L; @@ -56,6 +57,8 @@ public class Asset extends BaseDataWithAdditionalInfo implements HasLab @Getter @Setter private AssetId externalId; + @Getter @Setter + private Long version; public Asset() { super(); @@ -74,6 +77,7 @@ public class Asset extends BaseDataWithAdditionalInfo implements HasLab this.label = asset.getLabel(); this.assetProfileId = asset.getAssetProfileId(); this.externalId = asset.getExternalId(); + this.version = asset.getVersion(); } public void update(Asset asset) { @@ -85,6 +89,7 @@ public class Asset extends BaseDataWithAdditionalInfo implements HasLab this.assetProfileId = asset.getAssetProfileId(); Optional.ofNullable(asset.getAdditionalInfo()).ifPresent(this::setAdditionalInfo); this.externalId = asset.getExternalId(); + this.version = asset.getVersion(); } @Schema(description = "JSON object with the asset Id. " + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetProfile.java b/common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetProfile.java index 91563c0f2d..890c497d4b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetProfile.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetProfile.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.HasImage; import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.HasRuleEngineProfile; import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.RuleChainId; @@ -39,7 +40,7 @@ import org.thingsboard.server.common.data.validation.NoXss; @ToString(exclude = {"image"}) @EqualsAndHashCode(callSuper = true) @Slf4j -public class AssetProfile extends BaseData implements HasName, HasTenantId, HasRuleEngineProfile, ExportableEntity, HasImage, HasDefaultOption { +public class AssetProfile extends BaseData implements HasName, HasTenantId, HasRuleEngineProfile, ExportableEntity, HasImage, HasDefaultOption, HasVersion { private static final long serialVersionUID = 6998485460273302018L; @@ -74,6 +75,7 @@ public class AssetProfile extends BaseData implements HasName, H private RuleChainId defaultEdgeRuleChainId; private AssetProfileId externalId; + private Long version; public AssetProfile() { super(); @@ -95,6 +97,7 @@ public class AssetProfile extends BaseData implements HasName, H this.defaultQueueName = assetProfile.getDefaultQueueName(); this.defaultEdgeRuleChainId = assetProfile.getDefaultEdgeRuleChainId(); this.externalId = assetProfile.getExternalId(); + this.version = assetProfile.getVersion(); } @Schema(description = "JSON object with the asset profile Id. " + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java index d3c0889d6b..db56acb8b2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java @@ -40,6 +40,7 @@ public enum ActionType { RELATION_ADD_OR_UPDATE(false, TbMsgType.RELATION_ADD_OR_UPDATE), RELATION_DELETED(false, TbMsgType.RELATION_DELETED), RELATIONS_DELETED(false, TbMsgType.RELATIONS_DELETED), + REST_API_RULE_ENGINE_CALL(false, null), // log call to rule engine from REST API ALARM_ACK(false, TbMsgType.ALARM_ACK), ALARM_CLEAR(false, TbMsgType.ALARM_CLEAR), ALARM_DELETE(false, TbMsgType.ALARM_DELETE), diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java index 5983c4dd06..d74f21989c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java @@ -17,9 +17,9 @@ package org.thingsboard.server.common.data.device.profile; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import lombok.Data; -import jakarta.validation.Valid; import java.io.Serializable; import java.util.List; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java index 2f6e20f013..58e8935629 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java @@ -16,12 +16,12 @@ package org.thingsboard.server.common.data.device.profile; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import lombok.Data; import org.thingsboard.server.common.data.query.EntityKeyValueType; import org.thingsboard.server.common.data.query.KeyFilterPredicate; import org.thingsboard.server.common.data.validation.NoXss; -import jakarta.validation.Valid; import java.io.Serializable; @Schema diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java index a42b0523d2..9743ed1e03 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java @@ -16,11 +16,11 @@ package org.thingsboard.server.common.data.device.profile; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import lombok.Data; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.validation.NoXss; -import jakarta.validation.Valid; import java.io.Serializable; @Schema diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileAlarm.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileAlarm.java index ebf39ceec9..edb4857441 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileAlarm.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileAlarm.java @@ -16,12 +16,12 @@ package org.thingsboard.server.common.data.device.profile; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import lombok.Data; import org.thingsboard.server.common.data.alarm.AlarmSeverity; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; -import jakarta.validation.Valid; import java.io.Serializable; import java.util.List; import java.util.TreeMap; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java index 3c1b684c74..d35ba34781 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java @@ -16,9 +16,9 @@ package org.thingsboard.server.common.data.device.profile; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import lombok.Data; -import jakarta.validation.Valid; import java.io.Serializable; import java.util.List; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java b/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java index 67d32d6e89..ec8ade7f69 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java @@ -17,12 +17,14 @@ package org.thingsboard.server.common.data.edge; import io.swagger.v3.oas.annotations.media.Schema; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.Setter; import lombok.ToString; import org.thingsboard.server.common.data.BaseDataWithAdditionalInfo; import org.thingsboard.server.common.data.HasCustomerId; import org.thingsboard.server.common.data.HasLabel; import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.RuleChainId; @@ -34,7 +36,7 @@ import org.thingsboard.server.common.data.validation.NoXss; @EqualsAndHashCode(callSuper = true) @ToString @Setter -public class Edge extends BaseDataWithAdditionalInfo implements HasLabel, HasTenantId, HasCustomerId { +public class Edge extends BaseDataWithAdditionalInfo implements HasLabel, HasTenantId, HasCustomerId, HasVersion { private static final long serialVersionUID = 4934987555236873728L; @@ -57,6 +59,9 @@ public class Edge extends BaseDataWithAdditionalInfo implements HasLabel @Length(fieldName = "secret") private String secret; + @Getter + private Long version; + public Edge() { super(); } @@ -75,6 +80,7 @@ public class Edge extends BaseDataWithAdditionalInfo implements HasLabel this.name = edge.getName(); this.routingKey = edge.getRoutingKey(); this.secret = edge.getSecret(); + this.version = edge.getVersion(); } public void update(Edge edge) { @@ -86,6 +92,7 @@ public class Edge extends BaseDataWithAdditionalInfo implements HasLabel this.name = edge.getName(); this.routingKey = edge.getRoutingKey(); this.secret = edge.getSecret(); + this.version = edge.getVersion(); } @Schema(description = "JSON object with the Edge Id. " + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventActionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventActionType.java index 7bee0c6094..176de62f69 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventActionType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventActionType.java @@ -43,7 +43,7 @@ public enum EdgeEventActionType { DELETED_COMMENT(ActionType.DELETED_COMMENT), ASSIGNED_TO_EDGE(ActionType.ASSIGNED_TO_EDGE), UNASSIGNED_FROM_EDGE(ActionType.UNASSIGNED_FROM_EDGE), - CREDENTIALS_REQUEST(null), + CREDENTIALS_REQUEST(null), // deprecated ENTITY_MERGE_REQUEST(null); // deprecated private final ActionType actionType; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/SqlAttributesInsertRepository.java b/common/data/src/main/java/org/thingsboard/server/common/data/exception/EntityVersionMismatchException.java similarity index 65% rename from dao/src/main/java/org/thingsboard/server/dao/sql/attributes/SqlAttributesInsertRepository.java rename to common/data/src/main/java/org/thingsboard/server/common/data/exception/EntityVersionMismatchException.java index a99f5a24e7..310c6bccf7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/SqlAttributesInsertRepository.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/exception/EntityVersionMismatchException.java @@ -13,15 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao.sql.attributes; +package org.thingsboard.server.common.data.exception; -import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; -import org.thingsboard.server.dao.util.SqlDao; +public class EntityVersionMismatchException extends RuntimeException { -@Repository -@Transactional -@SqlDao -public class SqlAttributesInsertRepository extends AttributeKvInsertRepository { + public EntityVersionMismatchException(String message, Throwable cause) { + super(message, cause); + } -} \ No newline at end of file +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java b/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java index 0f789a5822..43423e9eef 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java @@ -29,6 +29,7 @@ public enum ThingsboardErrorCode { ITEM_NOT_FOUND(32), TOO_MANY_REQUESTS(33), TOO_MANY_UPDATES(34), + VERSION_CONFLICT(35), SUBSCRIPTION_VIOLATION(40), PASSWORD_VIOLATION(45); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/AttributeKvEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/AttributeKvEntry.java index 19057fb1aa..c63c953170 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/AttributeKvEntry.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/AttributeKvEntry.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.kv; +import org.thingsboard.server.common.data.HasVersion; + /** * @author Andrew Shvayka */ -public interface AttributeKvEntry extends KvEntry { +public interface AttributeKvEntry extends KvEntry, HasVersion { long getLastUpdateTs(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseAttributeKvEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseAttributeKvEntry.java index ced1486c14..99378dbc7c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseAttributeKvEntry.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseAttributeKvEntry.java @@ -16,11 +16,13 @@ package org.thingsboard.server.common.data.kv; import jakarta.validation.Valid; +import lombok.Data; import java.util.Optional; /** * @author Andrew Shvayka */ +@Data public class BaseAttributeKvEntry implements AttributeKvEntry { private static final long serialVersionUID = -6460767583563159407L; @@ -29,18 +31,22 @@ public class BaseAttributeKvEntry implements AttributeKvEntry { @Valid private final KvEntry kv; + private final Long version; + public BaseAttributeKvEntry(KvEntry kv, long lastUpdateTs) { this.kv = kv; this.lastUpdateTs = lastUpdateTs; + this.version = null; } - public BaseAttributeKvEntry(long lastUpdateTs, KvEntry kv) { - this(kv, lastUpdateTs); + public BaseAttributeKvEntry(KvEntry kv, long lastUpdateTs, Long version) { + this.kv = kv; + this.lastUpdateTs = lastUpdateTs; + this.version = version; } - @Override - public long getLastUpdateTs() { - return lastUpdateTs; + public BaseAttributeKvEntry(long lastUpdateTs, KvEntry kv) { + this(kv, lastUpdateTs); } @Override @@ -88,30 +94,4 @@ public class BaseAttributeKvEntry implements AttributeKvEntry { return kv.getValue(); } - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - BaseAttributeKvEntry that = (BaseAttributeKvEntry) o; - - if (lastUpdateTs != that.lastUpdateTs) return false; - return kv.equals(that.kv); - - } - - @Override - public int hashCode() { - int result = (int) (lastUpdateTs ^ (lastUpdateTs >>> 32)); - result = 31 * result + kv.hashCode(); - return result; - } - - @Override - public String toString() { - return "BaseAttributeKvEntry{" + - "lastUpdateTs=" + lastUpdateTs + - ", kv=" + kv + - '}'; - } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicTsKvEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicTsKvEntry.java index 396d1df5ba..82f96ea966 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicTsKvEntry.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicTsKvEntry.java @@ -16,18 +16,30 @@ package org.thingsboard.server.common.data.kv; import jakarta.validation.Valid; +import lombok.Data; + import java.util.Objects; import java.util.Optional; +@Data public class BasicTsKvEntry implements TsKvEntry { private static final int MAX_CHARS_PER_DATA_POINT = 512; protected final long ts; @Valid private final KvEntry kv; + private final Long version; + public BasicTsKvEntry(long ts, KvEntry kv) { this.ts = ts; this.kv = kv; + this.version = null; + } + + public BasicTsKvEntry(long ts, KvEntry kv, Long version) { + this.ts = ts; + this.kv = kv; + this.version = version; } @Override @@ -70,33 +82,6 @@ public class BasicTsKvEntry implements TsKvEntry { return kv.getValue(); } - @Override - public long getTs() { - return ts; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof BasicTsKvEntry)) return false; - BasicTsKvEntry that = (BasicTsKvEntry) o; - return getTs() == that.getTs() && - Objects.equals(kv, that.kv); - } - - @Override - public int hashCode() { - return Objects.hash(getTs(), kv); - } - - @Override - public String toString() { - return "BasicTsKvEntry{" + - "ts=" + ts + - ", kv=" + kv + - '}'; - } - @Override public String getValueAsString() { return kv.getValueAsString(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvEntry.java index c65f48e550..4b8887e893 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvEntry.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvEntry.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.kv; import com.fasterxml.jackson.annotation.JsonIgnore; +import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.query.TsValue; /** @@ -24,7 +25,7 @@ import org.thingsboard.server.common.data.query.TsValue; * @author ashvayka * */ -public interface TsKvEntry extends KvEntry { +public interface TsKvEntry extends KvEntry, HasVersion { long getTs(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvLatestRemovingResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvLatestRemovingResult.java index acdb745ad8..dae91bdd81 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvLatestRemovingResult.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvLatestRemovingResult.java @@ -22,15 +22,22 @@ public class TsKvLatestRemovingResult { private String key; private TsKvEntry data; private boolean removed; + private Long version; - public TsKvLatestRemovingResult(TsKvEntry data) { + public TsKvLatestRemovingResult(String key, boolean removed) { + this(key, removed, null); + } + + public TsKvLatestRemovingResult(TsKvEntry data, Long version) { this.key = data.getKey(); this.data = data; this.removed = true; + this.version = version; } - public TsKvLatestRemovingResult(String key, boolean removed) { + public TsKvLatestRemovingResult(String key, boolean removed, Long version) { this.key = key; this.removed = removed; + this.version = version; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java b/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java index 7aa472bea1..6532a4fe0d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java @@ -42,7 +42,8 @@ public enum LimitedApi { TRANSPORT_MESSAGES_PER_DEVICE("transport messages per device", false), TRANSPORT_MESSAGES_PER_GATEWAY("transport messages per gateway", false), TRANSPORT_MESSAGES_PER_GATEWAY_DEVICE("transport messages per gateway device", false), - EMAILS("emails sending", true); + EMAILS("emails sending", true), + WS_SUBSCRIPTIONS("WS subscriptions", false); private Function configExtractor; @Getter diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/msg/TbMsgType.java b/common/data/src/main/java/org/thingsboard/server/common/data/msg/TbMsgType.java index 763f245a09..9aa1994ab6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/msg/TbMsgType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/msg/TbMsgType.java @@ -67,6 +67,7 @@ public enum TbMsgType { PROVISION_SUCCESS, PROVISION_FAILURE, SEND_EMAIL, + REST_API_REQUEST("REST API request"), // tellSelfOnly types GENERATOR_NODE_SELF_MSG(null, true), diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequest.java index 0dd7b7fc47..4ad15dad7c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequest.java @@ -16,6 +16,8 @@ package org.thingsboard.server.common.data.notification; import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -33,8 +35,6 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.notification.info.NotificationInfo; import org.thingsboard.server.common.data.notification.template.NotificationTemplate; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotEmpty; import java.util.List; import java.util.UUID; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestConfig.java index 3a7dfc06f1..a6896d8996 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestConfig.java @@ -15,9 +15,8 @@ */ package org.thingsboard.server.common.data.notification; -import lombok.Data; - import jakarta.validation.constraints.Max; +import lombok.Data; @Data public class NotificationRequestConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationType.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationType.java index 5f91725b40..ec66a3d53a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationType.java @@ -32,4 +32,5 @@ public enum NotificationType { EDGE_CONNECTION, EDGE_COMMUNICATION_FAILURE, TASK_PROCESSING_FAILURE + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/DefaultNotificationRuleRecipientsConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/DefaultNotificationRuleRecipientsConfig.java index d7423f9f17..494295d19d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/DefaultNotificationRuleRecipientsConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/DefaultNotificationRuleRecipientsConfig.java @@ -15,10 +15,10 @@ */ package org.thingsboard.server.common.data.notification.rule; +import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; -import jakarta.validation.constraints.NotEmpty; import java.util.List; import java.util.Map; import java.util.UUID; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/EscalatedNotificationRuleRecipientsConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/EscalatedNotificationRuleRecipientsConfig.java index ef18d027a8..cc1b25632e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/EscalatedNotificationRuleRecipientsConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/EscalatedNotificationRuleRecipientsConfig.java @@ -15,10 +15,10 @@ */ package org.thingsboard.server.common.data.notification.rule; +import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; -import jakarta.validation.constraints.NotEmpty; import java.util.List; import java.util.Map; import java.util.UUID; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java index ccd6afdd5b..dc05dcc74d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java @@ -16,6 +16,10 @@ package org.thingsboard.server.common.data.notification.rule; import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -31,10 +35,6 @@ import org.thingsboard.server.common.data.notification.rule.trigger.config.Notif import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; -import jakarta.validation.Valid; -import jakarta.validation.constraints.AssertTrue; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import java.io.Serializable; import java.util.List; import java.util.stream.Collectors; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java index 092c2e1672..b7253ee6bd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java @@ -20,10 +20,10 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; -import jakarta.validation.constraints.NotNull; import java.io.Serializable; import java.util.List; import java.util.Map; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java index b99f19dae8..57db13b13e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -22,7 +23,6 @@ import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSeverity; -import jakarta.validation.constraints.NotEmpty; import java.util.Set; @Data diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java index aea8846d8a..6bea5a5b4a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -22,7 +23,6 @@ import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSeverity; -import jakarta.validation.constraints.NotEmpty; import java.io.Serializable; import java.util.Set; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/DeviceActivityNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/DeviceActivityNotificationRuleTriggerConfig.java index f092c1e4e1..2db07ef854 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/DeviceActivityNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/DeviceActivityNotificationRuleTriggerConfig.java @@ -15,12 +15,12 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import jakarta.validation.constraints.NotEmpty; import java.util.Set; import java.util.UUID; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntitiesLimitNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntitiesLimitNotificationRuleTriggerConfig.java index f23cd7906e..e4c6665575 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntitiesLimitNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntitiesLimitNotificationRuleTriggerConfig.java @@ -15,13 +15,13 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import jakarta.validation.constraints.Max; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.EntityType; -import jakarta.validation.constraints.Max; import java.util.Set; @Data diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationSettings.java index ea47537b93..9476c9bb36 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationSettings.java @@ -15,11 +15,11 @@ */ package org.thingsboard.server.common.data.notification.settings; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; import java.io.Serializable; import java.util.Map; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/SlackNotificationDeliveryMethodConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/SlackNotificationDeliveryMethodConfig.java index 8d65eb3523..2a40cf131c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/SlackNotificationDeliveryMethodConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/SlackNotificationDeliveryMethodConfig.java @@ -15,11 +15,10 @@ */ package org.thingsboard.server.common.data.notification.settings; +import jakarta.validation.constraints.NotEmpty; import lombok.Data; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; -import jakarta.validation.constraints.NotEmpty; - @Data public class SlackNotificationDeliveryMethodConfig implements NotificationDeliveryMethodConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/UserNotificationSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/UserNotificationSettings.java index 903a239459..fc16211bcd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/UserNotificationSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/UserNotificationSettings.java @@ -18,14 +18,14 @@ package org.thingsboard.server.common.data.notification.settings; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.common.data.notification.targets.NotificationTargetType; -import jakarta.validation.Valid; -import jakarta.validation.constraints.AssertTrue; -import jakarta.validation.constraints.NotNull; import java.util.Collections; import java.util.Map; import java.util.Set; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java index aa68f7aa40..e07abe7c00 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java @@ -15,11 +15,10 @@ */ package org.thingsboard.server.common.data.notification.targets; -import lombok.Data; -import lombok.EqualsAndHashCode; - import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; +import lombok.Data; +import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java index 6e45f04e65..afaf1bf2fa 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.notification.targets; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.BaseData; @@ -26,10 +29,6 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - @Data @EqualsAndHashCode(callSuper = true) public class NotificationTarget extends BaseData implements HasTenantId, HasName, ExportableEntity { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/CustomerUsersFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/CustomerUsersFilter.java index 40a863b7d8..3eb3e8680c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/CustomerUsersFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/CustomerUsersFilter.java @@ -15,9 +15,9 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import jakarta.validation.constraints.NotNull; import lombok.Data; -import jakarta.validation.constraints.NotNull; import java.util.UUID; @Data diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/PlatformUsersNotificationTargetConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/PlatformUsersNotificationTargetConfig.java index 0792cb2d07..eb4a3e9c61 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/PlatformUsersNotificationTargetConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/PlatformUsersNotificationTargetConfig.java @@ -15,14 +15,13 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.notification.targets.NotificationTargetConfig; import org.thingsboard.server.common.data.notification.targets.NotificationTargetType; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; - @Data @EqualsAndHashCode(callSuper = true) public class PlatformUsersNotificationTargetConfig extends NotificationTargetConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UserListFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UserListFilter.java index eea1921f18..bab5e6dfb4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UserListFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UserListFilter.java @@ -15,9 +15,9 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import jakarta.validation.constraints.NotEmpty; import lombok.Data; -import jakarta.validation.constraints.NotEmpty; import java.util.List; import java.util.UUID; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackConversation.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackConversation.java index 63b1f990e3..c0d29913c8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackConversation.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackConversation.java @@ -17,6 +17,8 @@ package org.thingsboard.server.common.data.notification.targets.slack; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -24,9 +26,6 @@ import lombok.NoArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.thingsboard.server.common.data.notification.targets.NotificationRecipient; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; - import static org.apache.commons.lang3.StringUtils.isEmpty; @Data diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackNotificationTargetConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackNotificationTargetConfig.java index fbf10c370f..adfb07b20f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackNotificationTargetConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackNotificationTargetConfig.java @@ -15,14 +15,13 @@ */ package org.thingsboard.server.common.data.notification.targets.slack; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.notification.targets.NotificationTargetConfig; import org.thingsboard.server.common.data.notification.targets.NotificationTargetType; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; - @Data @EqualsAndHashCode(callSuper = true) public class SlackNotificationTargetConfig extends NotificationTargetConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/DeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/DeliveryMethodNotificationTemplate.java index 9cb1e103d0..63109eca40 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/DeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/DeliveryMethodNotificationTemplate.java @@ -20,11 +20,11 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; -import jakarta.validation.constraints.NotEmpty; import java.util.List; @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java index acd78e4ffd..448af20915 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.template; +import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -23,7 +24,6 @@ import org.thingsboard.server.common.data.notification.NotificationDeliveryMetho import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; -import jakarta.validation.constraints.NotEmpty; import java.util.List; @Data diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplate.java index 54c11be1e4..f06c73ca63 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplate.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.notification.template; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.BaseData; @@ -27,10 +30,6 @@ import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; - @Data @EqualsAndHashCode(callSuper = true) public class NotificationTemplate extends BaseData implements HasTenantId, HasName, ExportableEntity { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplateConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplateConfig.java index 59e50c7885..0aa092ba6a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplateConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplateConfig.java @@ -15,11 +15,11 @@ */ package org.thingsboard.server.common.data.notification.template; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; import lombok.Data; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotEmpty; import java.util.HashMap; import java.util.Map; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java index 55ee91d988..f723b77d97 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; +import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -27,7 +28,6 @@ import org.thingsboard.server.common.data.notification.NotificationDeliveryMetho import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; -import jakarta.validation.constraints.NotEmpty; import java.util.List; import java.util.Optional; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2ClientRegistrationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2ClientRegistrationTemplate.java index aa15466d0e..15c0641731 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2ClientRegistrationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2ClientRegistrationTemplate.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.oauth2; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -25,7 +26,6 @@ import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.id.OAuth2ClientRegistrationTemplateId; import org.thingsboard.server.common.data.validation.Length; -import jakarta.validation.Valid; import java.util.List; @EqualsAndHashCode(callSuper = true) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2MapperConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2MapperConfig.java index be13a032e2..fdf62d7e53 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2MapperConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2MapperConfig.java @@ -16,13 +16,12 @@ package org.thingsboard.server.common.data.oauth2; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; -import jakarta.validation.Valid; - @Builder(toBuilder = true) @EqualsAndHashCode @Data diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java index 309385d5e4..c0caa68e3a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java @@ -31,11 +31,11 @@ import java.util.List; @NoArgsConstructor public class AttributesEntityView implements Serializable { - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of client-side attribute keys to expose", example = "currentConfiguration") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of client-side attribute keys to expose", example = "[\"currentConfiguration\"]") private List cs = new ArrayList<>(); - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of server-side attribute keys to expose", example = "model") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of server-side attribute keys to expose", example = "[\"model\"]") private List ss = new ArrayList<>(); - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of shared attribute keys to expose", example = "targetConfiguration") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of shared attribute keys to expose", example = "[\"targetConfiguration\"]") private List sh = new ArrayList<>(); public AttributesEntityView(List cs, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java index a2484b0707..999db8178a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java @@ -31,7 +31,7 @@ import java.util.List; @NoArgsConstructor public class TelemetryEntityView implements Serializable { - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of time-series data keys to expose", example = "temperature, humidity") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of time-series data keys to expose", example = "[\"temperature\", \"humidity\"]") private List timeseries; @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with attributes to expose") private AttributesEntityView attributes; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/FilterPredicateValue.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/FilterPredicateValue.java index 6183edf461..b5f71165f6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/FilterPredicateValue.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/FilterPredicateValue.java @@ -18,11 +18,11 @@ package org.thingsboard.server.common.data.query; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.Valid; import lombok.Data; import lombok.Getter; import org.thingsboard.server.common.data.validation.NoXss; -import jakarta.validation.Valid; import java.io.Serializable; @Data diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/StringFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/StringFilterPredicate.java index 89d9dc4653..d5aa81f313 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/StringFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/StringFilterPredicate.java @@ -15,9 +15,8 @@ */ package org.thingsboard.server.common.data.query; -import lombok.Data; - import jakarta.validation.Valid; +import lombok.Data; @Data public class StringFilterPredicate implements SimpleKeyFilterPredicate { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/queue/QueueStats.java b/common/data/src/main/java/org/thingsboard/server/common/data/queue/QueueStats.java index 04d50dfe6a..6c648daf02 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/queue/QueueStats.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/queue/QueueStats.java @@ -19,8 +19,8 @@ import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.BaseData; import org.thingsboard.server.common.data.HasTenantId; -import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.QueueStatsId; +import org.thingsboard.server.common.data.id.TenantId; @EqualsAndHashCode(callSuper = true) @Data diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java b/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java index 1b32cc632e..62b5caafd3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java @@ -18,8 +18,13 @@ package org.thingsboard.server.common.data.relation; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.BaseDataWithAdditionalInfo; +import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.validation.Length; @@ -27,7 +32,9 @@ import java.io.Serializable; @Slf4j @Schema -public class EntityRelation implements Serializable { +@EqualsAndHashCode(exclude = "additionalInfoBytes") +@ToString(exclude = {"additionalInfoBytes"}) +public class EntityRelation implements HasVersion, Serializable { private static final long serialVersionUID = 2807343040519543363L; @@ -35,11 +42,18 @@ public class EntityRelation implements Serializable { public static final String CONTAINS_TYPE = "Contains"; public static final String MANAGES_TYPE = "Manages"; + @Setter private EntityId from; + @Setter private EntityId to; + @Setter @Length(fieldName = "type") private String type; + @Setter private RelationTypeGroup typeGroup; + @Getter + @Setter + private Long version; private transient JsonNode additionalInfo; @JsonIgnore private byte[] additionalInfoBytes; @@ -70,6 +84,7 @@ public class EntityRelation implements Serializable { this.type = entityRelation.getType(); this.typeGroup = entityRelation.getTypeGroup(); this.additionalInfo = entityRelation.getAdditionalInfo(); + this.version = entityRelation.getVersion(); } @Schema(description = "JSON object with [from] Entity Id.", accessMode = Schema.AccessMode.READ_ONLY) @@ -77,37 +92,21 @@ public class EntityRelation implements Serializable { return from; } - public void setFrom(EntityId from) { - this.from = from; - } - @Schema(description = "JSON object with [to] Entity Id.", accessMode = Schema.AccessMode.READ_ONLY) public EntityId getTo() { return to; } - public void setTo(EntityId to) { - this.to = to; - } - @Schema(description = "String value of relation type.", example = "Contains") public String getType() { return type; } - public void setType(String type) { - this.type = type; - } - @Schema(description = "Represents the type group of the relation.", example = "COMMON") public RelationTypeGroup getTypeGroup() { return typeGroup; } - public void setTypeGroup(RelationTypeGroup typeGroup) { - this.typeGroup = typeGroup; - } - @Schema(description = "Additional parameters of the relation",implementation = com.fasterxml.jackson.databind.JsonNode.class) public JsonNode getAdditionalInfo() { return BaseDataWithAdditionalInfo.getJson(() -> additionalInfo, () -> additionalInfoBytes); @@ -117,25 +116,4 @@ public class EntityRelation implements Serializable { BaseDataWithAdditionalInfo.setJson(addInfo, json -> this.additionalInfo = json, bytes -> this.additionalInfoBytes = bytes); } - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - EntityRelation that = (EntityRelation) o; - - if (from != null ? !from.equals(that.from) : that.from != null) return false; - if (to != null ? !to.equals(that.to) : that.to != null) return false; - if (type != null ? !type.equals(that.type) : that.type != null) return false; - return typeGroup == that.typeGroup; - } - - @Override - public int hashCode() { - int result = from != null ? from.hashCode() : 0; - result = 31 * result + (to != null ? to.hashCode() : 0); - result = 31 * result + (type != null ? type.hashCode() : 0); - result = 31 * result + (typeGroup != null ? typeGroup.hashCode() : 0); - return result; - } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java index 5448fcd259..0f7e793878 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.ExportableEntity; import org.thingsboard.server.common.data.HasDefaultOption; import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.TenantId; @@ -36,7 +37,7 @@ import org.thingsboard.server.common.data.validation.NoXss; @Data @EqualsAndHashCode(callSuper = true) @Slf4j -public class RuleChain extends BaseDataWithAdditionalInfo implements HasName, HasTenantId, ExportableEntity, HasDefaultOption { +public class RuleChain extends BaseDataWithAdditionalInfo implements HasName, HasTenantId, ExportableEntity, HasDefaultOption, HasVersion { private static final long serialVersionUID = -5656679015121935465L; @@ -58,6 +59,7 @@ public class RuleChain extends BaseDataWithAdditionalInfo implement private transient JsonNode configuration; private RuleChainId externalId; + private Long version; @JsonIgnore private byte[] configurationBytes; @@ -79,6 +81,7 @@ public class RuleChain extends BaseDataWithAdditionalInfo implement this.root = ruleChain.isRoot(); this.setConfiguration(ruleChain.getConfiguration()); this.setExternalId(ruleChain.getExternalId()); + this.version = ruleChain.getVersion(); } @Override @@ -89,7 +92,7 @@ public class RuleChain extends BaseDataWithAdditionalInfo implement @Schema(description = "JSON object with the Rule Chain Id. " + "Specify this field to update the Rule Chain. " + "Referencing non-existing Rule Chain Id will cause error. " + - "Omit this field to create new rule chain." ) + "Omit this field to create new rule chain.") @Override public RuleChainId getId() { return super.getId(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java index e269f0438d..094582f647 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java @@ -17,20 +17,26 @@ package org.thingsboard.server.common.data.security; import io.swagger.v3.oas.annotations.media.Schema; import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.id.DeviceCredentialsId; import org.thingsboard.server.common.data.id.DeviceId; @Schema @EqualsAndHashCode(callSuper = true) -public class DeviceCredentials extends BaseData implements DeviceCredentialsFilter { +public class DeviceCredentials extends BaseData implements DeviceCredentialsFilter, HasVersion { private static final long serialVersionUID = -7869261127032877765L; private DeviceId deviceId; private DeviceCredentialsType credentialsType; private String credentialsId; private String credentialsValue; - + + @Getter @Setter + private Long version; + public DeviceCredentials() { super(); } @@ -45,6 +51,7 @@ public class DeviceCredentials extends BaseData implements this.credentialsType = deviceCredentials.getCredentialsType(); this.credentialsId = deviceCredentials.getCredentialsId(); this.credentialsValue = deviceCredentials.getCredentialsValue(); + this.version = deviceCredentials.getVersion(); } @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "The Id is automatically generated during device creation. " + @@ -111,4 +118,5 @@ public class DeviceCredentials extends BaseData implements + credentialsId + ", credentialsValue=" + credentialsValue + ", createdTime=" + createdTime + ", id=" + id + "]"; } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java index a334a5f67c..bd2cd1fad7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java @@ -16,14 +16,14 @@ package org.thingsboard.server.common.data.security.model.mfa; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import lombok.Data; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; - import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import lombok.Data; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; + import java.util.List; import java.util.Optional; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/BackupCodeTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/BackupCodeTwoFaAccountConfig.java index fe3636b475..b4eac00687 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/BackupCodeTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/BackupCodeTwoFaAccountConfig.java @@ -16,11 +16,11 @@ package org.thingsboard.server.common.data.security.model.mfa.account; import com.fasterxml.jackson.annotation.JsonGetter; +import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; -import jakarta.validation.constraints.NotEmpty; import java.util.Set; @Data diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java index 2b9542e67b..38ac6f32c1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java @@ -15,13 +15,12 @@ */ package org.thingsboard.server.common.data.security.model.mfa.account; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; - @Data @EqualsAndHashCode(callSuper = true) public class EmailTwoFaAccountConfig extends OtpBasedTwoFaAccountConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java index 9c9359796a..c8ee5af278 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java @@ -15,13 +15,12 @@ */ package org.thingsboard.server.common.data.security.model.mfa.account; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; - @EqualsAndHashCode(callSuper = true) @Data public class SmsTwoFaAccountConfig extends OtpBasedTwoFaAccountConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java index f0450daed1..3dd8f95a5b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java @@ -15,13 +15,12 @@ */ package org.thingsboard.server.common.data.security.model.mfa.account; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; - @Data @EqualsAndHashCode(callSuper = true) public class TotpTwoFaAccountConfig extends TwoFaAccountConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/BackupCodeTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/BackupCodeTwoFaProviderConfig.java index ed8ef1d2a1..30894b3d3a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/BackupCodeTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/BackupCodeTwoFaProviderConfig.java @@ -15,9 +15,8 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; -import lombok.Data; - import jakarta.validation.constraints.Min; +import lombok.Data; @Data public class BackupCodeTwoFaProviderConfig implements TwoFaProviderConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java index a2c167a76a..196360e514 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java @@ -15,9 +15,8 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; -import lombok.Data; - import jakarta.validation.constraints.Min; +import lombok.Data; @Data public abstract class OtpBasedTwoFaProviderConfig implements TwoFaProviderConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java index 79ae76331c..f145eb0cb2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java @@ -15,11 +15,10 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; -import lombok.Data; -import lombok.EqualsAndHashCode; - import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; +import lombok.Data; +import lombok.EqualsAndHashCode; @EqualsAndHashCode(callSuper = true) @Data diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java index d9ccad10e0..5e281f3158 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java @@ -15,9 +15,8 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; -import lombok.Data; - import jakarta.validation.constraints.NotBlank; +import lombok.Data; @Data public class TotpTwoFaProviderConfig implements TwoFaProviderConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java index 4ec88fd92b..1b5459ec1a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java @@ -60,6 +60,6 @@ import java.lang.annotation.Target; @Type(name = "NOTIFICATION_RULE", value = NotificationRule.class), @Type(name = "TB_RESOURCE", value = TbResource.class) }) -@JsonIgnoreProperties(value = {"tenantId", "createdTime"}, ignoreUnknown = true) +@JsonIgnoreProperties(value = {"tenantId", "createdTime", "version"}, ignoreUnknown = true) public @interface JsonTbEntity { } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java index ec922f2362..3285fd4ca0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java @@ -30,7 +30,7 @@ import org.thingsboard.server.common.data.security.DeviceCredentials; public class DeviceExportData extends EntityExportData { @JsonProperty(index = 3) - @JsonIgnoreProperties({"id", "deviceId", "createdTime"}) + @JsonIgnoreProperties({"id", "deviceId", "createdTime", "version"}) private DeviceCredentials credentials; @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java index 2336c283a6..7f60ea5bb7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java @@ -95,4 +95,5 @@ public class EntityExportData> { public boolean hasRelations() { return relations != null; } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/RepositorySettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/RepositorySettings.java index 7c884b6527..7f6f0897b0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/RepositorySettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/RepositorySettings.java @@ -33,6 +33,7 @@ public class RepositorySettings implements Serializable { private String defaultBranch; private boolean readOnly; private boolean showMergeCommits; + private boolean localOnly; public RepositorySettings() { } @@ -48,5 +49,7 @@ public class RepositorySettings implements Serializable { this.defaultBranch = settings.getDefaultBranch(); this.readOnly = settings.isReadOnly(); this.showMergeCommits = settings.isShowMergeCommits(); + this.localOnly = settings.isLocalOnly(); } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java index 4c9006e829..e89f3c1ad8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.util; import java.util.Collection; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -37,6 +38,13 @@ public class CollectionsUtil { return b.stream().filter(p -> !a.contains(p)).collect(Collectors.toSet()); } + /** + * Returns new list with elements that are present in list B(new) but absent in list A(old). + */ + public static List diffLists(List a, List b) { + return b.stream().filter(p -> !a.contains(p)).collect(Collectors.toList()); + } + public static boolean contains(Collection collection, T element) { return isNotEmpty(collection) && collection.contains(element); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/validation/Length.java b/common/data/src/main/java/org/thingsboard/server/common/data/validation/Length.java index 3437530387..31c31d893a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/validation/Length.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/validation/Length.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.validation; import jakarta.validation.Constraint; import jakarta.validation.Payload; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoXss.java b/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoXss.java index 2ca8f546f4..4fad289c5b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoXss.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoXss.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.validation; import jakarta.validation.Constraint; import jakarta.validation.Payload; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/widget/BaseWidgetType.java b/common/data/src/main/java/org/thingsboard/server/common/data/widget/BaseWidgetType.java index 5bf721a2f4..36692f5738 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/widget/BaseWidgetType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/widget/BaseWidgetType.java @@ -17,16 +17,19 @@ package org.thingsboard.server.common.data.widget; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.BaseData; import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.WidgetTypeId; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; @Data -public class BaseWidgetType extends BaseData implements HasName, HasTenantId { +@EqualsAndHashCode(callSuper = true) +public class BaseWidgetType extends BaseData implements HasName, HasTenantId, HasVersion { private static final long serialVersionUID = 8388684344603660756L; @@ -44,6 +47,11 @@ public class BaseWidgetType extends BaseData implements HasName, H @Schema(description = "Whether widget type is deprecated.", example = "true") private boolean deprecated; + @Schema(description = "Whether widget type is SCADA symbol.", example = "true") + private boolean scada; + + private Long version; + public BaseWidgetType() { super(); } @@ -58,12 +66,14 @@ public class BaseWidgetType extends BaseData implements HasName, H this.fqn = widgetType.getFqn(); this.name = widgetType.getName(); this.deprecated = widgetType.isDeprecated(); + this.scada = widgetType.isScada(); + this.version = widgetType.getVersion(); } @Schema(description = "JSON object with the Widget Type Id. " + "Specify this field to update the Widget Type. " + "Referencing non-existing Widget Type Id will cause error. " + - "Omit this field to create new Widget Type." ) + "Omit this field to create new Widget Type.") @Override public WidgetTypeId getId() { return super.getId(); @@ -74,4 +84,5 @@ public class BaseWidgetType extends BaseData implements HasName, H public long getCreatedTime() { return super.getCreatedTime(); } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java index f29fe8fec5..55bd1cf27a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java @@ -18,8 +18,7 @@ package org.thingsboard.server.common.data.widget; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; -import lombok.Getter; -import lombok.Setter; +import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.ExportableEntity; import org.thingsboard.server.common.data.HasImage; import org.thingsboard.server.common.data.HasName; @@ -29,7 +28,8 @@ import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; @Data -@JsonPropertyOrder({ "fqn", "name", "deprecated", "image", "description", "descriptor", "externalId" }) +@EqualsAndHashCode(callSuper = true) +@JsonPropertyOrder({"fqn", "name", "deprecated", "image", "description", "descriptor", "externalId"}) public class WidgetTypeDetails extends WidgetType implements HasName, HasTenantId, HasImage, ExportableEntity { @Schema(description = "Relative or external image URL. Replaced with image data URL (Base64) in case of relative URL and 'inlineImages' option enabled.") @@ -42,8 +42,6 @@ public class WidgetTypeDetails extends WidgetType implements HasName, HasTenantI @Schema(description = "Tags of the widget type") private String[] tags; - @Getter - @Setter private WidgetTypeId externalId; public WidgetTypeDetails() { @@ -65,4 +63,5 @@ public class WidgetTypeDetails extends WidgetType implements HasName, HasTenantI this.tags = widgetTypeDetails.getTags(); this.externalId = widgetTypeDetails.getExternalId(); } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeFilter.java new file mode 100644 index 0000000000..e8d1601b64 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeFilter.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.widget; + +import lombok.Builder; +import lombok.Data; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.List; + +@Data +@Builder +public class WidgetTypeFilter { + + private TenantId tenantId; + private boolean fullSearch; + private boolean scadaFirst; + DeprecatedFilter deprecatedFilter; + List widgetTypes; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetsBundle.java b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetsBundle.java index 7e75f5e58f..005e4ca252 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetsBundle.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetsBundle.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.HasImage; import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.HasTitle; +import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.WidgetsBundleId; import org.thingsboard.server.common.data.validation.Length; @@ -33,7 +34,7 @@ import org.thingsboard.server.common.data.validation.NoXss; @Schema @EqualsAndHashCode(callSuper = true) -public class WidgetsBundle extends BaseData implements HasName, HasTenantId, ExportableEntity, HasTitle, HasImage { +public class WidgetsBundle extends BaseData implements HasName, HasTenantId, ExportableEntity, HasTitle, HasImage, HasVersion { private static final long serialVersionUID = -7627368878362410489L; @@ -61,6 +62,11 @@ public class WidgetsBundle extends BaseData implements HasName, @Schema(description = "Relative or external image URL. Replaced with image data URL (Base64) in case of relative URL and 'inlineImages' option enabled.", accessMode = Schema.AccessMode.READ_ONLY) private String image; + @Getter + @Setter + @Schema(description = "Whether widgets bundle contains SCADA symbol widget types.", accessMode = Schema.AccessMode.READ_ONLY) + private boolean scada; + @NoXss @Length(fieldName = "description", max = 1024) @Getter @@ -76,6 +82,9 @@ public class WidgetsBundle extends BaseData implements HasName, @Getter @Setter private WidgetsBundleId externalId; + @Getter + @Setter + private Long version; public WidgetsBundle() { super(); @@ -91,9 +100,11 @@ public class WidgetsBundle extends BaseData implements HasName, this.alias = widgetsBundle.getAlias(); this.title = widgetsBundle.getTitle(); this.image = widgetsBundle.getImage(); + this.scada = widgetsBundle.isScada(); this.description = widgetsBundle.getDescription(); this.order = widgetsBundle.getOrder(); this.externalId = widgetsBundle.getExternalId(); + this.version = widgetsBundle.getVersion(); } @Schema(description = "JSON object with the Widget Bundle Id. " + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetsBundleFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetsBundleFilter.java new file mode 100644 index 0000000000..932bfa8fc8 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetsBundleFilter.java @@ -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. + */ +package org.thingsboard.server.common.data.widget; + +import lombok.Builder; +import lombok.Data; +import org.thingsboard.server.common.data.id.TenantId; + +@Data +@Builder +public class WidgetsBundleFilter { + + private TenantId tenantId; + private boolean fullSearch; + private boolean scadaFirst; + + public static WidgetsBundleFilter fromTenantId(TenantId tenantId) { + return WidgetsBundleFilter.builder().tenantId(tenantId).fullSearch(false).scadaFirst(false).build(); + } + + public static WidgetsBundleFilter fullSearchFromTenantId(TenantId tenantId) { + return WidgetsBundleFilter.builder().tenantId(tenantId).fullSearch(true).scadaFirst(false).build(); + } + +} diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/UUIDConverterTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/UUIDConverterTest.java index 909eb4c160..52bc53c02b 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/UUIDConverterTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/UUIDConverterTest.java @@ -16,8 +16,8 @@ package org.thingsboard.server.common.data; import com.datastax.oss.driver.api.core.uuid.Uuids; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/audit/ActionTypeTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/audit/ActionTypeTest.java index f746f47e19..6a5ed54896 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/audit/ActionTypeTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/audit/ActionTypeTest.java @@ -28,6 +28,7 @@ import static org.thingsboard.server.common.data.audit.ActionType.DELETED_COMMEN import static org.thingsboard.server.common.data.audit.ActionType.LOCKOUT; import static org.thingsboard.server.common.data.audit.ActionType.LOGIN; import static org.thingsboard.server.common.data.audit.ActionType.LOGOUT; +import static org.thingsboard.server.common.data.audit.ActionType.REST_API_RULE_ENGINE_CALL; import static org.thingsboard.server.common.data.audit.ActionType.RPC_CALL; import static org.thingsboard.server.common.data.audit.ActionType.SMS_SENT; import static org.thingsboard.server.common.data.audit.ActionType.SUSPENDED; @@ -45,7 +46,8 @@ class ActionTypeTest { LOGOUT, LOCKOUT, DELETED_COMMENT, - SMS_SENT + SMS_SENT, + REST_API_RULE_ENGINE_CALL ); // backward-compatibility tests diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java index 23bfe55f1b..0a36b5f56e 100644 --- a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java +++ b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java @@ -15,6 +15,7 @@ */ package org.thingsboard.edge.rpc; +import io.grpc.HttpConnectProxiedSocketAddress; import io.grpc.ManagedChannel; import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; @@ -43,6 +44,7 @@ import org.thingsboard.server.gen.edge.v1.UplinkMsg; import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; import javax.net.ssl.SSLException; +import java.net.InetSocketAddress; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; @@ -67,6 +69,16 @@ public class EdgeGrpcClient implements EdgeRpcClient { private String certResource; @Value("${cloud.rpc.max_inbound_message_size:4194304}") private int maxInboundMessageSize; + @Value("${cloud.rpc.proxy.enabled}") + private boolean proxyEnabled; + @Value("${cloud.rpc.proxy.host:}") + private String proxyHost; + @Value("${cloud.rpc.proxy.port:0}") + private int proxyPort; + @Value("${cloud.rpc.proxy.username:}") + private String proxyUsername; + @Value("${cloud.rpc.proxy.password:}") + private String proxyPassword; @Getter private int serverMaxInboundMessageSize; @@ -88,6 +100,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { .keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS) .keepAliveTimeout(keepAliveTimeoutSec, TimeUnit.SECONDS) .keepAliveWithoutCalls(true); + if (sslEnabled) { try { SslContextBuilder sslContextBuilder = GrpcSslContexts.forClient(); @@ -102,6 +115,18 @@ public class EdgeGrpcClient implements EdgeRpcClient { } else { builder.usePlaintext(); } + + if (proxyEnabled && StringUtils.isNotEmpty(proxyHost) && proxyPort > 0) { + InetSocketAddress proxyAddress = new InetSocketAddress(proxyHost, proxyPort); + InetSocketAddress targetAddress = new InetSocketAddress(rpcHost, rpcPort); + builder.proxyDetector(socketAddress -> HttpConnectProxiedSocketAddress.newBuilder() + .setTargetAddress(targetAddress) + .setProxyAddress(proxyAddress) + .setUsername(proxyUsername) + .setPassword(proxyPassword) + .build()); + } + channel = builder.build(); EdgeRpcServiceGrpc.EdgeRpcServiceStub stub = EdgeRpcServiceGrpc.newStub(channel); log.info("[{}] Sending a connect request to the TB!", edgeKey); diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/edge/FromEdgeSyncResponse.java b/common/message/src/main/java/org/thingsboard/server/common/msg/edge/FromEdgeSyncResponse.java index 733129b4de..baf483310c 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/edge/FromEdgeSyncResponse.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/edge/FromEdgeSyncResponse.java @@ -25,12 +25,13 @@ import java.util.UUID; @Data public class FromEdgeSyncResponse implements EdgeSessionMsg { - private static final long serialVersionUID = -6360890886315347486L; + private static final long serialVersionUID = -6360890556315667486L; private final UUID id; private final TenantId tenantId; private final EdgeId edgeId; private final boolean success; + private final String error; @Override public MsgType getMsgType() { diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/rpc/FromDeviceRpcResponseActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/rpc/FromDeviceRpcResponseActorMsg.java index 6eec68fa2a..36b13dae15 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/rpc/FromDeviceRpcResponseActorMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/rpc/FromDeviceRpcResponseActorMsg.java @@ -20,7 +20,6 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.ToDeviceActorNotificationMsg; -import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; @Data public class FromDeviceRpcResponseActorMsg implements ToDeviceActorNotificationMsg { diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/rpc/ToDeviceRpcRequestActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/rpc/ToDeviceRpcRequestActorMsg.java index 3e397a477b..e013a621fd 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/rpc/ToDeviceRpcRequestActorMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/rpc/ToDeviceRpcRequestActorMsg.java @@ -20,7 +20,6 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.ToDeviceActorNotificationMsg; -import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; /** * Created by ashvayka on 16.04.18. diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/MaxPayloadSizeExceededException.java b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/MaxPayloadSizeExceededException.java new file mode 100644 index 0000000000..a9a206a4df --- /dev/null +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/MaxPayloadSizeExceededException.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.msg.tools; + +public class MaxPayloadSizeExceededException extends RuntimeException { + + public MaxPayloadSizeExceededException() { + super("Payload size exceeds the limit"); + } + +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimitsException.java b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimitsException.java index b39477fa38..32d55048ae 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimitsException.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimitsException.java @@ -30,4 +30,10 @@ public class TbRateLimitsException extends AbstractRateLimitException { super(entityType.name() + " rate limits reached!"); this.entityType = entityType; } + + public TbRateLimitsException(String message) { + super(message); + this.entityType = null; + } + } diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/KvProtoUtil.java b/common/proto/src/main/java/org/thingsboard/server/common/util/KvProtoUtil.java index 74674e1e45..1be79a28cc 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/util/KvProtoUtil.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/util/KvProtoUtil.java @@ -86,8 +86,20 @@ public class KvProtoUtil { .setKv(KvProtoUtil.toKeyValueTypeProto(kvEntry)).build(); } + public static TransportProtos.TsKvProto toTsKvProto(long ts, KvEntry kvEntry, Long version) { + var builder = TransportProtos.TsKvProto.newBuilder() + .setTs(ts) + .setKv(KvProtoUtil.toKeyValueTypeProto(kvEntry)); + + if (version != null) { + builder.setVersion(version); + } + + return builder.build(); + } + public static TsKvEntry fromTsKvProto(TransportProtos.TsKvProto proto) { - return new BasicTsKvEntry(proto.getTs(), fromTsKvProto(proto.getKv())); + return new BasicTsKvEntry(proto.getTs(), fromTsKvProto(proto.getKv()), proto.hasVersion() ? proto.getVersion() : null); } public static TransportProtos.KeyValueProto toKeyValueTypeProto(KvEntry kvEntry) { diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java index e3d587e915..5646a37a68 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.Tenant; @@ -159,6 +160,7 @@ public class ProtoUtils { .setEdgeIdMSB(response.getEdgeId().getId().getMostSignificantBits()) .setEdgeIdLSB(response.getEdgeId().getId().getLeastSignificantBits()) .setSuccess(response.isSuccess()) + .setError(response.getError()) .build(); } @@ -167,7 +169,8 @@ public class ProtoUtils { new UUID(proto.getResponseIdMSB(), proto.getResponseIdLSB()), TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), new EdgeId(new UUID(proto.getEdgeIdMSB(), proto.getEdgeIdLSB())), - proto.getSuccess() + proto.getSuccess(), + proto.getError() ); } @@ -256,42 +259,51 @@ public class ProtoUtils { if (msg.getValues() != null) { for (AttributeKvEntry attributeKvEntry : msg.getValues()) { - TransportProtos.AttributeValueProto.Builder attributeValueBuilder = TransportProtos.AttributeValueProto.newBuilder() - .setLastUpdateTs(attributeKvEntry.getLastUpdateTs()) - .setKey(attributeKvEntry.getKey()); - switch (attributeKvEntry.getDataType()) { - case BOOLEAN -> { - attributeKvEntry.getBooleanValue().ifPresent(attributeValueBuilder::setBoolV); - attributeValueBuilder.setHasV(attributeKvEntry.getBooleanValue().isPresent()); - attributeValueBuilder.setType(TransportProtos.KeyValueType.BOOLEAN_V); - } - case STRING -> { - attributeKvEntry.getStrValue().ifPresent(attributeValueBuilder::setStringV); - attributeValueBuilder.setHasV(attributeKvEntry.getStrValue().isPresent()); - attributeValueBuilder.setType(TransportProtos.KeyValueType.STRING_V); - } - case DOUBLE -> { - attributeKvEntry.getDoubleValue().ifPresent(attributeValueBuilder::setDoubleV); - attributeValueBuilder.setHasV(attributeKvEntry.getDoubleValue().isPresent()); - attributeValueBuilder.setType(TransportProtos.KeyValueType.DOUBLE_V); - } - case LONG -> { - attributeKvEntry.getLongValue().ifPresent(attributeValueBuilder::setLongV); - attributeValueBuilder.setHasV(attributeKvEntry.getLongValue().isPresent()); - attributeValueBuilder.setType(TransportProtos.KeyValueType.LONG_V); - } - case JSON -> { - attributeKvEntry.getJsonValue().ifPresent(attributeValueBuilder::setJsonV); - attributeValueBuilder.setHasV(attributeKvEntry.getJsonValue().isPresent()); - attributeValueBuilder.setType(TransportProtos.KeyValueType.JSON_V); - } - } - builder.addValues(attributeValueBuilder.build()); + builder.addValues(toProto(attributeKvEntry)); } } return builder.build(); } + public static TransportProtos.AttributeValueProto toProto(AttributeKvEntry attributeKvEntry) { + TransportProtos.AttributeValueProto.Builder builder = TransportProtos.AttributeValueProto.newBuilder() + .setLastUpdateTs(attributeKvEntry.getLastUpdateTs()) + .setKey(attributeKvEntry.getKey()); + switch (attributeKvEntry.getDataType()) { + case BOOLEAN: + attributeKvEntry.getBooleanValue().ifPresent(builder::setBoolV); + builder.setHasV(attributeKvEntry.getBooleanValue().isPresent()); + builder.setType(TransportProtos.KeyValueType.BOOLEAN_V); + break; + case STRING: + attributeKvEntry.getStrValue().ifPresent(builder::setStringV); + builder.setHasV(attributeKvEntry.getStrValue().isPresent()); + builder.setType(TransportProtos.KeyValueType.STRING_V); + break; + case DOUBLE: + attributeKvEntry.getDoubleValue().ifPresent(builder::setDoubleV); + builder.setHasV(attributeKvEntry.getDoubleValue().isPresent()); + builder.setType(TransportProtos.KeyValueType.DOUBLE_V); + break; + case LONG: + attributeKvEntry.getLongValue().ifPresent(builder::setLongV); + builder.setHasV(attributeKvEntry.getLongValue().isPresent()); + builder.setType(TransportProtos.KeyValueType.LONG_V); + break; + case JSON: + attributeKvEntry.getJsonValue().ifPresent(builder::setJsonV); + builder.setHasV(attributeKvEntry.getJsonValue().isPresent()); + builder.setType(TransportProtos.KeyValueType.JSON_V); + break; + } + + if (attributeKvEntry.getVersion() != null) { + builder.setVersion(attributeKvEntry.getVersion()); + } + + return builder.build(); + } + private static ToDeviceActorNotificationMsg fromProto(TransportProtos.DeviceAttributesEventMsgProto proto) { return new DeviceAttributesEventNotificationMsg( TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), @@ -500,20 +512,25 @@ public class ProtoUtils { } List result = new ArrayList<>(); for (TransportProtos.AttributeValueProto kvEntry : valuesList) { - boolean hasValue = kvEntry.getHasV(); - KvEntry entry = switch (kvEntry.getType()) { - case BOOLEAN_V -> new BooleanDataEntry(kvEntry.getKey(), hasValue ? kvEntry.getBoolV() : null); - case LONG_V -> new LongDataEntry(kvEntry.getKey(), hasValue ? kvEntry.getLongV() : null); - case DOUBLE_V -> new DoubleDataEntry(kvEntry.getKey(), hasValue ? kvEntry.getDoubleV() : null); - case STRING_V -> new StringDataEntry(kvEntry.getKey(), hasValue ? kvEntry.getStringV() : null); - case JSON_V -> new JsonDataEntry(kvEntry.getKey(), hasValue ? kvEntry.getJsonV() : null); - default -> null; - }; - result.add(new BaseAttributeKvEntry(kvEntry.getLastUpdateTs(), entry)); + result.add(fromProto(kvEntry)); } return result; } + public static AttributeKvEntry fromProto(TransportProtos.AttributeValueProto proto) { + boolean hasValue = proto.getHasV(); + String key = proto.getKey(); + KvEntry entry = switch (proto.getType()) { + case BOOLEAN_V -> new BooleanDataEntry(key, hasValue ? proto.getBoolV() : null); + case LONG_V -> new LongDataEntry(key, hasValue ? proto.getLongV() : null); + case DOUBLE_V -> new DoubleDataEntry(key, hasValue ? proto.getDoubleV() : null); + case STRING_V -> new StringDataEntry(key, hasValue ? proto.getStringV() : null); + case JSON_V -> new JsonDataEntry(key, hasValue ? proto.getJsonV() : null); + default -> null; + }; + return new BaseAttributeKvEntry(entry, proto.getLastUpdateTs(), proto.hasVersion() ? proto.getVersion() : null); + } + public static TransportProtos.DeviceProto toProto(Device device) { var builder = TransportProtos.DeviceProto.newBuilder() .setTenantIdMSB(device.getTenantId().getId().getMostSignificantBits()) @@ -551,6 +568,9 @@ public class ProtoUtils { if (isNotNull(device.getDeviceDataBytes())) { builder.setDeviceData(ByteString.copyFrom(device.getDeviceDataBytes())); } + if (isNotNull(device.getVersion())) { + builder.setVersion(device.getVersion()); + } return builder.build(); } @@ -582,6 +602,9 @@ public class ProtoUtils { if (proto.hasDeviceData()) { device.setDeviceDataBytes(proto.getDeviceData().toByteArray()); } + if (proto.hasVersion()) { + device.setVersion(proto.getVersion()); + } return device; } @@ -637,6 +660,9 @@ public class ProtoUtils { builder.setDefaultEdgeRuleChainIdMSB(getMsb(deviceProfile.getDefaultEdgeRuleChainId())) .setDefaultEdgeRuleChainIdLSB(getLsb(deviceProfile.getDefaultEdgeRuleChainId())); } + if (isNotNull(deviceProfile.getVersion())) { + builder.setVersion(deviceProfile.getVersion()); + } return builder.build(); } @@ -682,6 +708,9 @@ public class ProtoUtils { if (proto.hasDefaultEdgeRuleChainIdMSB() && proto.hasDefaultEdgeRuleChainIdLSB()) { deviceProfile.setDefaultEdgeRuleChainId(getEntityId(proto.getDefaultEdgeRuleChainIdMSB(), proto.getDefaultEdgeRuleChainIdLSB(), RuleChainId::new)); } + if (proto.hasVersion()) { + deviceProfile.setVersion(proto.getVersion()); + } return deviceProfile; } @@ -724,6 +753,9 @@ public class ProtoUtils { if (isNotNull(tenant.getAdditionalInfo())) { builder.setAdditionalInfo(JacksonUtil.toString(tenant.getAdditionalInfo())); } + if (isNotNull(tenant.getVersion())) { + builder.setVersion(tenant.getVersion()); + } return builder.build(); } @@ -763,6 +795,9 @@ public class ProtoUtils { if (proto.hasAdditionalInfo()) { tenant.setAdditionalInfo(JacksonUtil.toJsonNode(proto.getAdditionalInfo())); } + if (proto.hasVersion()) { + tenant.setVersion(proto.getVersion()); + } return tenant; } @@ -831,6 +866,9 @@ public class ProtoUtils { if (isNotNull(resource.getPreview())) { builder.setPreview(ByteString.copyFrom(resource.getPreview())); } + if (isNotNull(resource.getResourceSubType())) { + builder.setResourceSubType(resource.getResourceSubType().name()); + } return builder.build(); } @@ -862,6 +900,9 @@ public class ProtoUtils { if (proto.hasPreview()) { resource.setPreview(proto.getPreview().toByteArray()); } + if (proto.hasResourceSubType()) { + resource.setResourceSubType(ResourceSubType.valueOf(proto.getResourceSubType())); + } return resource; } @@ -906,7 +947,8 @@ public class ProtoUtils { .setRepositoryUri(repositorySettings.getRepositoryUri()) .setAuthMethod(repositorySettings.getAuthMethod().name()) .setReadOnly(repositorySettings.isReadOnly()) - .setShowMergeCommits(repositorySettings.isShowMergeCommits()); + .setShowMergeCommits(repositorySettings.isShowMergeCommits()) + .setLocalOnly(repositorySettings.isLocalOnly()); if (isNotNull(repositorySettings.getUsername())) { builder.setUsername(repositorySettings.getUsername()); @@ -935,6 +977,7 @@ public class ProtoUtils { repositorySettings.setAuthMethod(RepositoryAuthMethod.valueOf(proto.getAuthMethod())); repositorySettings.setReadOnly(proto.getReadOnly()); repositorySettings.setShowMergeCommits(proto.getShowMergeCommits()); + repositorySettings.setLocalOnly(proto.getLocalOnly()); if (proto.hasUsername()) { repositorySettings.setUsername(proto.getUsername()); } @@ -969,6 +1012,9 @@ public class ProtoUtils { if (deviceCredentials.getCredentialsValue() != null) { builder.setCredentialsValue(deviceCredentials.getCredentialsValue()); } + if (deviceCredentials.getVersion() != null) { + builder.setVersion(deviceCredentials.getVersion()); + } return builder.build(); } @@ -980,6 +1026,7 @@ public class ProtoUtils { deviceCredentials.setCredentialsId(proto.getCredentialsId()); deviceCredentials.setCredentialsType(DeviceCredentialsType.valueOf(proto.getCredentialsType().name())); deviceCredentials.setCredentialsValue(proto.hasCredentialsValue() ? proto.getCredentialsValue() : null); + deviceCredentials.setVersion(proto.hasVersion() ? proto.getVersion() : null); return deviceCredentials; } diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 581b993120..7c3bfddc08 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -101,6 +101,12 @@ message SessionInfoProto { bool isGateway = 18; } +message RestApiCallResponseMsgProto { + int64 requestIdMSB = 1; + int64 requestIdLSB = 2; + bytes response = 5; +} + enum SessionEvent { OPEN = 0; CLOSED = 1; @@ -157,11 +163,13 @@ message AttributeValueProto { string string_v = 7; string json_v = 8; optional string key = 9; + optional int64 version = 10; } message TsKvProto { int64 ts = 1; KeyValueProto kv = 2; + optional int64 version = 3; } message TsKvListProto { @@ -209,6 +217,7 @@ message DeviceProto { optional int64 softwareIdLSB = 18; optional int64 externalIdMSB = 19; optional int64 externalIdLSB = 20; + optional int64 version = 21; } message DeviceProfileProto { @@ -239,6 +248,7 @@ message DeviceProfileProto { optional int64 defaultEdgeRuleChainIdLSB = 25; optional int64 externalIdMSB = 26; optional int64 externalIdLSB = 27; + optional int64 version = 28; } message TenantProto { @@ -258,6 +268,7 @@ message TenantProto { optional string phone = 14; optional string email = 15; optional string additionalInfo = 16; + optional int64 version = 17; } message TenantProfileProto { @@ -290,6 +301,7 @@ message TbResourceProto { optional int64 externalIdLSB = 16; optional bytes data = 17; optional bytes preview = 18; + optional string resourceSubType = 19; } message ApiUsageStateProto { @@ -322,6 +334,7 @@ message RepositorySettingsProto { optional string defaultBranch = 8; bool readOnly = 9; bool showMergeCommits = 10; + bool localOnly = 11; } /** @@ -667,6 +680,7 @@ message DeviceCredentialsProto { CredentialsType credentialsType = 6; string credentialsId = 7; optional string credentialsValue = 8; + optional int64 version = 9; } message CredentialsDataProto { @@ -1147,6 +1161,7 @@ message FromEdgeSyncResponseMsgProto { int64 edgeIdMSB = 5; int64 edgeIdLSB = 6; bool success = 7; + string error = 8; } message DeviceEdgeUpdateMsgProto { @@ -1484,6 +1499,7 @@ message ToCoreNotificationMsg { ToEdgeSyncRequestMsgProto toEdgeSyncRequest = 11; FromEdgeSyncResponseMsgProto fromEdgeSyncResponse = 12; ResourceCacheInvalidateMsg resourceCacheInvalidateMsg = 13; + RestApiCallResponseMsgProto restApiCallResponseMsg = 50; } /* Messages that are handled by ThingsBoard RuleEngine Service */ diff --git a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java index 904e85f702..a513d3e311 100644 --- a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java +++ b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java @@ -124,7 +124,7 @@ class ProtoUtilsTest { @Test void protoFromEdgeSyncResponseSerialization() { - FromEdgeSyncResponse msg = new FromEdgeSyncResponse(id, tenantId, edgeId, true); + FromEdgeSyncResponse msg = new FromEdgeSyncResponse(id, tenantId, edgeId, true, "Error Msg"); assertThat(ProtoUtils.fromProto(ProtoUtils.toProto(msg))).as("deserialized").isEqualTo(msg); } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/azure/servicebus/TbServiceBusQueueConfigs.java b/common/queue/src/main/java/org/thingsboard/server/queue/azure/servicebus/TbServiceBusQueueConfigs.java index 093e2f2c19..45d39d1cd6 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/azure/servicebus/TbServiceBusQueueConfigs.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/azure/servicebus/TbServiceBusQueueConfigs.java @@ -15,13 +15,13 @@ */ package org.thingsboard.server.queue.azure.servicebus; +import jakarta.annotation.PostConstruct; import lombok.Getter; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; import org.thingsboard.server.queue.util.PropertyUtils; -import jakarta.annotation.PostConstruct; import java.util.Map; @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java index ac263c10c8..9513565ca1 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java @@ -15,13 +15,13 @@ */ package org.thingsboard.server.queue.common; +import jakarta.annotation.Nonnull; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.TbQueueMsg; -import jakarta.annotation.Nonnull; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplate.java index b7a3db45a0..950519b098 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplate.java @@ -18,6 +18,7 @@ package org.thingsboard.server.queue.common; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; +import jakarta.annotation.Nullable; import lombok.Builder; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -33,7 +34,6 @@ import org.thingsboard.server.queue.TbQueueMsgMetadata; import org.thingsboard.server.queue.TbQueueProducer; import org.thingsboard.server.queue.TbQueueRequestTemplate; -import jakarta.annotation.Nullable; import java.util.List; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DefaultTbServiceInfoProvider.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DefaultTbServiceInfoProvider.java index ddc17bb8ee..4caa3b92b3 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DefaultTbServiceInfoProvider.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DefaultTbServiceInfoProvider.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.queue.discovery; +import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -29,10 +30,8 @@ import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ServiceInfo; import org.thingsboard.server.queue.util.AfterContextReady; -import jakarta.annotation.PostConstruct; import java.net.InetAddress; import java.net.UnknownHostException; -import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/HashPartitionService.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/HashPartitionService.java index 04d55f91c0..53a2152782 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/HashPartitionService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/HashPartitionService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.queue.discovery; import com.google.common.hash.HashFunction; import com.google.common.hash.Hashing; +import jakarta.annotation.PostConstruct; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -36,7 +37,6 @@ import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.discovery.event.ServiceListChangedEvent; import org.thingsboard.server.queue.util.AfterStartUp; -import jakarta.annotation.PostConstruct; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -319,7 +319,7 @@ public class HashPartitionService implements PartitionService { private QueueKey getQueueKey(ServiceType serviceType, String queueName, TenantId tenantId) { TenantId isolatedOrSystemTenantId = getIsolatedOrSystemTenantId(serviceType, tenantId); - if (queueName == null) { + if (queueName == null || queueName.isEmpty()) { queueName = MAIN_QUEUE_NAME; } QueueKey queueKey = new QueueKey(serviceType, queueName, isolatedOrSystemTenantId); @@ -672,6 +672,7 @@ public class HashPartitionService implements PartitionService { public QueueConfig(QueueRoutingInfo queueRoutingInfo) { this.duplicateMsgToAllPartitions = queueRoutingInfo.isDuplicateMsgToAllPartitions(); } + } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java index 97b83fd878..20b2710bbf 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java @@ -17,6 +17,8 @@ package org.thingsboard.server.queue.discovery; import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.ProtocolStringList; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.curator.framework.CuratorFramework; @@ -43,8 +45,6 @@ import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.discovery.event.OtherServiceShutdownEvent; import org.thingsboard.server.queue.util.AfterStartUp; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.List; import java.util.NoSuchElementException; import java.util.concurrent.ConcurrentHashMap; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/event/OtherServiceShutdownEvent.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/event/OtherServiceShutdownEvent.java index 04258258b8..85ef812693 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/event/OtherServiceShutdownEvent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/event/OtherServiceShutdownEvent.java @@ -15,11 +15,9 @@ */ package org.thingsboard.server.queue.discovery.event; -import com.google.protobuf.ProtocolStringList; import lombok.Getter; import org.thingsboard.server.common.msg.queue.ServiceType; -import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.stream.Collectors; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/environment/EnvironmentLogService.java b/common/queue/src/main/java/org/thingsboard/server/queue/environment/EnvironmentLogService.java index 45508ed029..07853855e9 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/environment/EnvironmentLogService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/environment/EnvironmentLogService.java @@ -15,13 +15,12 @@ */ package org.thingsboard.server.queue.environment; +import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.apache.zookeeper.Environment; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; -import jakarta.annotation.PostConstruct; - /** * Created by igor on 11/24/16. */ diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java index 0cb1194167..2bd283f1e5 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java @@ -15,10 +15,8 @@ */ package org.thingsboard.server.queue.kafka; -import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.admin.CreateTopicsResult; -import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsResult; import org.apache.kafka.clients.admin.NewTopic; import org.apache.kafka.clients.consumer.OffsetAndMetadata; import org.apache.kafka.common.TopicPartition; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java index 760487c61e..e03266b3e6 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java @@ -151,6 +151,7 @@ public class TbKafkaSettings { Properties props = toProps(); props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, servers); props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, maxPollRecords); + props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, sessionTimeoutMs); props.put(ConsumerConfig.MAX_PARTITION_FETCH_BYTES_CONFIG, maxPartitionFetchBytes); props.put(ConsumerConfig.FETCH_MAX_BYTES_CONFIG, fetchMaxBytes); props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, maxPollIntervalMs); @@ -193,8 +194,6 @@ public class TbKafkaSettings { } props.put(CommonClientConfigs.REQUEST_TIMEOUT_MS_CONFIG, requestTimeoutMs); - props.put(CommonClientConfigs.SESSION_TIMEOUT_MS_CONFIG, sessionTimeoutMs); - props.putAll(PropertyUtils.getProps(otherInline)); if (other != null) { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaTopicConfigs.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaTopicConfigs.java index c55d8a5b8a..6f237894c2 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaTopicConfigs.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaTopicConfigs.java @@ -15,13 +15,13 @@ */ package org.thingsboard.server.queue.kafka; +import jakarta.annotation.PostConstruct; import lombok.Getter; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.thingsboard.server.queue.util.PropertyUtils; -import jakarta.annotation.PostConstruct; import java.util.Map; @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsMonolithQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsMonolithQueueFactory.java index 26ec51097f..d40a8bac85 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsMonolithQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsMonolithQueueFactory.java @@ -16,6 +16,7 @@ package org.thingsboard.server.queue.provider; import com.google.protobuf.util.JsonFormat; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; @@ -41,8 +42,8 @@ import org.thingsboard.server.queue.TbQueueRequestTemplate; import org.thingsboard.server.queue.common.DefaultTbQueueRequestTemplate; import org.thingsboard.server.queue.common.TbProtoJsQueueMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg; -import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.settings.TbQueueCoreSettings; import org.thingsboard.server.queue.settings.TbQueueRemoteJsInvokeSettings; import org.thingsboard.server.queue.settings.TbQueueRuleEngineSettings; @@ -55,7 +56,6 @@ import org.thingsboard.server.queue.sqs.TbAwsSqsProducerTemplate; import org.thingsboard.server.queue.sqs.TbAwsSqsQueueAttributes; import org.thingsboard.server.queue.sqs.TbAwsSqsSettings; -import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTbCoreQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTbCoreQueueFactory.java index 0c4fe824fa..05e435d7fb 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTbCoreQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTbCoreQueueFactory.java @@ -16,6 +16,7 @@ package org.thingsboard.server.queue.provider; import com.google.protobuf.util.JsonFormat; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; @@ -39,8 +40,8 @@ import org.thingsboard.server.queue.TbQueueRequestTemplate; import org.thingsboard.server.queue.common.DefaultTbQueueRequestTemplate; import org.thingsboard.server.queue.common.TbProtoJsQueueMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg; -import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.settings.TbQueueCoreSettings; import org.thingsboard.server.queue.settings.TbQueueRemoteJsInvokeSettings; import org.thingsboard.server.queue.settings.TbQueueRuleEngineSettings; @@ -53,7 +54,6 @@ import org.thingsboard.server.queue.sqs.TbAwsSqsProducerTemplate; import org.thingsboard.server.queue.sqs.TbAwsSqsQueueAttributes; import org.thingsboard.server.queue.sqs.TbAwsSqsSettings; -import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTbRuleEngineQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTbRuleEngineQueueFactory.java index 000578333a..7e6129a748 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTbRuleEngineQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTbRuleEngineQueueFactory.java @@ -16,6 +16,7 @@ package org.thingsboard.server.queue.provider; import com.google.protobuf.util.JsonFormat; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; @@ -45,7 +46,6 @@ import org.thingsboard.server.queue.sqs.TbAwsSqsProducerTemplate; import org.thingsboard.server.queue.sqs.TbAwsSqsQueueAttributes; import org.thingsboard.server.queue.sqs.TbAwsSqsSettings; -import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTbVersionControlQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTbVersionControlQueueFactory.java index aa79349dac..b14bb8d8e7 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTbVersionControlQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTbVersionControlQueueFactory.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.queue.provider; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; import org.thingsboard.server.gen.transport.TransportProtos; @@ -31,8 +32,6 @@ import org.thingsboard.server.queue.sqs.TbAwsSqsProducerTemplate; import org.thingsboard.server.queue.sqs.TbAwsSqsQueueAttributes; import org.thingsboard.server.queue.sqs.TbAwsSqsSettings; -import jakarta.annotation.PreDestroy; - @Component @ConditionalOnExpression("'${queue.type:null}'=='aws-sqs' && '${service.type:null}'=='tb-vc-executor'") public class AwsSqsTbVersionControlQueueFactory implements TbVersionControlQueueFactory { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTransportQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTransportQueueFactory.java index ec07847631..407ef86a99 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTransportQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTransportQueueFactory.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.queue.provider; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; @@ -43,8 +44,6 @@ import org.thingsboard.server.queue.sqs.TbAwsSqsProducerTemplate; import org.thingsboard.server.queue.sqs.TbAwsSqsQueueAttributes; import org.thingsboard.server.queue.sqs.TbAwsSqsSettings; -import jakarta.annotation.PreDestroy; - @Component @ConditionalOnExpression("'${queue.type:null}'=='aws-sqs' && (('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true') || '${service.type:null}'=='tb-transport')") @Slf4j diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/InMemoryMonolithQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/InMemoryMonolithQueueFactory.java index 310bc75fe4..225290ed63 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/InMemoryMonolithQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/InMemoryMonolithQueueFactory.java @@ -28,8 +28,8 @@ import org.thingsboard.server.queue.TbQueueProducer; import org.thingsboard.server.queue.TbQueueRequestTemplate; import org.thingsboard.server.queue.common.TbProtoJsQueueMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg; -import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.memory.InMemoryStorage; import org.thingsboard.server.queue.memory.InMemoryTbQueueConsumer; import org.thingsboard.server.queue.memory.InMemoryTbQueueProducer; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaMonolithQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaMonolithQueueFactory.java index 9728642b8d..87be03c8c7 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaMonolithQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaMonolithQueueFactory.java @@ -16,6 +16,7 @@ package org.thingsboard.server.queue.provider; import com.google.protobuf.util.JsonFormat; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; @@ -55,7 +56,6 @@ import org.thingsboard.server.queue.settings.TbQueueTransportApiSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; import org.thingsboard.server.queue.settings.TbQueueVersionControlSettings; -import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicLong; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbCoreQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbCoreQueueFactory.java index ddb2be8f2a..f16f21ad66 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbCoreQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbCoreQueueFactory.java @@ -16,6 +16,7 @@ package org.thingsboard.server.queue.provider; import com.google.protobuf.util.JsonFormat; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; @@ -54,7 +55,6 @@ import org.thingsboard.server.queue.settings.TbQueueTransportApiSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; import org.thingsboard.server.queue.settings.TbQueueVersionControlSettings; -import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicLong; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbRuleEngineQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbRuleEngineQueueFactory.java index 31fa7efffb..0b320ba617 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbRuleEngineQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbRuleEngineQueueFactory.java @@ -16,6 +16,7 @@ package org.thingsboard.server.queue.provider; import com.google.protobuf.util.JsonFormat; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; @@ -37,8 +38,8 @@ import org.thingsboard.server.queue.TbQueueRequestTemplate; import org.thingsboard.server.queue.common.DefaultTbQueueRequestTemplate; import org.thingsboard.server.queue.common.TbProtoJsQueueMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg; -import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.kafka.TbKafkaAdmin; import org.thingsboard.server.queue.kafka.TbKafkaConsumerStatsService; import org.thingsboard.server.queue.kafka.TbKafkaConsumerTemplate; @@ -50,7 +51,6 @@ import org.thingsboard.server.queue.settings.TbQueueRemoteJsInvokeSettings; import org.thingsboard.server.queue.settings.TbQueueRuleEngineSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; -import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicLong; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbTransportQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbTransportQueueFactory.java index 40904c7c65..dd260840f5 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbTransportQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbTransportQueueFactory.java @@ -15,10 +15,10 @@ */ package org.thingsboard.server.queue.provider; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; -import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToHousekeeperServiceMsg; @@ -46,8 +46,6 @@ import org.thingsboard.server.queue.settings.TbQueueRuleEngineSettings; import org.thingsboard.server.queue.settings.TbQueueTransportApiSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; -import jakarta.annotation.PreDestroy; - @Component @ConditionalOnExpression("'${queue.type:null}'=='kafka' && (('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true') || '${service.type:null}'=='tb-transport')") @Slf4j diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbVersionControlQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbVersionControlQueueFactory.java index c6633b030c..aacebb8579 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbVersionControlQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbVersionControlQueueFactory.java @@ -15,9 +15,9 @@ */ package org.thingsboard.server.queue.provider; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; -import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToHousekeeperServiceMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToUsageStatsServiceMsg; @@ -37,8 +37,6 @@ import org.thingsboard.server.queue.kafka.TbKafkaTopicConfigs; import org.thingsboard.server.queue.settings.TbQueueCoreSettings; import org.thingsboard.server.queue.settings.TbQueueVersionControlSettings; -import jakarta.annotation.PreDestroy; - @Component @ConditionalOnExpression("'${queue.type:null}'=='kafka' && '${service.type:null}'=='tb-vc-executor'") public class KafkaTbVersionControlQueueFactory implements TbVersionControlQueueFactory { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubMonolithQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubMonolithQueueFactory.java index a3734daa91..00f4a1ec4e 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubMonolithQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubMonolithQueueFactory.java @@ -16,6 +16,7 @@ package org.thingsboard.server.queue.provider; import com.google.protobuf.util.JsonFormat; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; @@ -55,7 +56,6 @@ import org.thingsboard.server.queue.settings.TbQueueTransportApiSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; import org.thingsboard.server.queue.settings.TbQueueVersionControlSettings; -import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTbCoreQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTbCoreQueueFactory.java index c48559467d..091909d8f2 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTbCoreQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTbCoreQueueFactory.java @@ -16,6 +16,7 @@ package org.thingsboard.server.queue.provider; import com.google.protobuf.util.JsonFormat; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; @@ -52,7 +53,6 @@ import org.thingsboard.server.queue.settings.TbQueueRuleEngineSettings; import org.thingsboard.server.queue.settings.TbQueueTransportApiSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; -import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTbRuleEngineQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTbRuleEngineQueueFactory.java index 3895971655..3e607d0bf9 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTbRuleEngineQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTbRuleEngineQueueFactory.java @@ -16,13 +16,13 @@ package org.thingsboard.server.queue.provider; import com.google.protobuf.util.JsonFormat; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.queue.Queue; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.gen.js.JsInvokeProtos; -import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToHousekeeperServiceMsg; @@ -50,7 +50,6 @@ import org.thingsboard.server.queue.settings.TbQueueRemoteJsInvokeSettings; import org.thingsboard.server.queue.settings.TbQueueRuleEngineSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; -import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTbVersionControlQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTbVersionControlQueueFactory.java index 3faf35bbc7..b6da4e2973 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTbVersionControlQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTbVersionControlQueueFactory.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.queue.provider; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; import org.thingsboard.server.gen.transport.TransportProtos; @@ -31,8 +32,6 @@ import org.thingsboard.server.queue.pubsub.TbPubSubSubscriptionSettings; import org.thingsboard.server.queue.settings.TbQueueCoreSettings; import org.thingsboard.server.queue.settings.TbQueueVersionControlSettings; -import jakarta.annotation.PreDestroy; - @Component @ConditionalOnExpression("'${queue.type:null}'=='pubsub' && '${service.type:null}'=='tb-vc-executor'") public class PubSubTbVersionControlQueueFactory implements TbVersionControlQueueFactory { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTransportQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTransportQueueFactory.java index 9f993a553d..a7500d2083 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTransportQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTransportQueueFactory.java @@ -15,10 +15,10 @@ */ package org.thingsboard.server.queue.provider; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; -import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToHousekeeperServiceMsg; @@ -45,8 +45,6 @@ import org.thingsboard.server.queue.settings.TbQueueRuleEngineSettings; import org.thingsboard.server.queue.settings.TbQueueTransportApiSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; -import jakarta.annotation.PreDestroy; - @Component @ConditionalOnExpression("'${queue.type:null}'=='pubsub' && (('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true') || '${service.type:null}'=='tb-transport')") @Slf4j diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqMonolithQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqMonolithQueueFactory.java index 40f4fdc2e5..cf60012449 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqMonolithQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqMonolithQueueFactory.java @@ -16,6 +16,7 @@ package org.thingsboard.server.queue.provider; import com.google.protobuf.util.JsonFormat; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; @@ -55,7 +56,6 @@ import org.thingsboard.server.queue.settings.TbQueueTransportApiSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; import org.thingsboard.server.queue.settings.TbQueueVersionControlSettings; -import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTbCoreQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTbCoreQueueFactory.java index 06fe34c458..57440422be 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTbCoreQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTbCoreQueueFactory.java @@ -16,6 +16,7 @@ package org.thingsboard.server.queue.provider; import com.google.protobuf.util.JsonFormat; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; @@ -51,7 +52,6 @@ import org.thingsboard.server.queue.settings.TbQueueRuleEngineSettings; import org.thingsboard.server.queue.settings.TbQueueTransportApiSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; -import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTbRuleEngineQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTbRuleEngineQueueFactory.java index 8d7016404a..a0a87714ca 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTbRuleEngineQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTbRuleEngineQueueFactory.java @@ -16,6 +16,7 @@ package org.thingsboard.server.queue.provider; import com.google.protobuf.util.JsonFormat; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; @@ -36,8 +37,8 @@ import org.thingsboard.server.queue.TbQueueRequestTemplate; import org.thingsboard.server.queue.common.DefaultTbQueueRequestTemplate; import org.thingsboard.server.queue.common.TbProtoJsQueueMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg; -import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.rabbitmq.TbRabbitMqAdmin; import org.thingsboard.server.queue.rabbitmq.TbRabbitMqConsumerTemplate; import org.thingsboard.server.queue.rabbitmq.TbRabbitMqProducerTemplate; @@ -48,7 +49,6 @@ import org.thingsboard.server.queue.settings.TbQueueRemoteJsInvokeSettings; import org.thingsboard.server.queue.settings.TbQueueRuleEngineSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; -import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTbVersionControlQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTbVersionControlQueueFactory.java index d2842a3492..604955be2c 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTbVersionControlQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTbVersionControlQueueFactory.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.queue.provider; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; import org.thingsboard.server.gen.transport.TransportProtos; @@ -31,8 +32,6 @@ import org.thingsboard.server.queue.rabbitmq.TbRabbitMqSettings; import org.thingsboard.server.queue.settings.TbQueueCoreSettings; import org.thingsboard.server.queue.settings.TbQueueVersionControlSettings; -import jakarta.annotation.PreDestroy; - @Component @ConditionalOnExpression("'${queue.type:null}'=='rabbitmq' && '${service.type:null}'=='tb-vc-executor'") public class RabbitMqTbVersionControlQueueFactory implements TbVersionControlQueueFactory { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTransportQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTransportQueueFactory.java index 13df3414d8..9b8a3d3c49 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTransportQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTransportQueueFactory.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.queue.provider; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; @@ -44,8 +45,6 @@ import org.thingsboard.server.queue.settings.TbQueueRuleEngineSettings; import org.thingsboard.server.queue.settings.TbQueueTransportApiSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; -import jakarta.annotation.PreDestroy; - @Component @ConditionalOnExpression("'${queue.type:null}'=='rabbitmq' && (('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true') || '${service.type:null}'=='tb-transport')") @Slf4j diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusMonolithQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusMonolithQueueFactory.java index c52ec7f7b2..2ec807498c 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusMonolithQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusMonolithQueueFactory.java @@ -16,6 +16,7 @@ package org.thingsboard.server.queue.provider; import com.google.protobuf.util.JsonFormat; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; @@ -54,7 +55,6 @@ import org.thingsboard.server.queue.settings.TbQueueTransportApiSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; import org.thingsboard.server.queue.settings.TbQueueVersionControlSettings; -import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTbCoreQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTbCoreQueueFactory.java index 37fcfad1bd..493e58dba7 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTbCoreQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTbCoreQueueFactory.java @@ -16,6 +16,7 @@ package org.thingsboard.server.queue.provider; import com.google.protobuf.util.JsonFormat; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; @@ -52,7 +53,6 @@ import org.thingsboard.server.queue.settings.TbQueueRuleEngineSettings; import org.thingsboard.server.queue.settings.TbQueueTransportApiSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; -import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTbRuleEngineQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTbRuleEngineQueueFactory.java index cd2c52c21d..92ef01fa00 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTbRuleEngineQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTbRuleEngineQueueFactory.java @@ -16,6 +16,7 @@ package org.thingsboard.server.queue.provider; import com.google.protobuf.util.JsonFormat; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; @@ -48,7 +49,6 @@ import org.thingsboard.server.queue.settings.TbQueueRemoteJsInvokeSettings; import org.thingsboard.server.queue.settings.TbQueueRuleEngineSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; -import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTbVersionControlQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTbVersionControlQueueFactory.java index c3447a1012..0c363488ce 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTbVersionControlQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTbVersionControlQueueFactory.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.queue.provider; +import jakarta.annotation.PreDestroy; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; import org.thingsboard.server.gen.transport.TransportProtos; @@ -31,8 +32,6 @@ import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.settings.TbQueueCoreSettings; import org.thingsboard.server.queue.settings.TbQueueVersionControlSettings; -import jakarta.annotation.PreDestroy; - @Component @ConditionalOnExpression("'${queue.type:null}'=='service-bus' && '${service.type:null}'=='tb-vc-executor'") public class ServiceBusTbVersionControlQueueFactory implements TbVersionControlQueueFactory { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTransportQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTransportQueueFactory.java index f2ed799905..fd493eb2e1 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTransportQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTransportQueueFactory.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.queue.provider; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; @@ -44,8 +45,6 @@ import org.thingsboard.server.queue.settings.TbQueueRuleEngineSettings; import org.thingsboard.server.queue.settings.TbQueueTransportApiSettings; import org.thingsboard.server.queue.settings.TbQueueTransportNotificationSettings; -import jakarta.annotation.PreDestroy; - @Component @ConditionalOnExpression("'${queue.type:null}'=='service-bus' && (('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true') || '${service.type:null}'=='tb-transport')") @Slf4j diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueProducerProvider.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueProducerProvider.java index 3dcd716a96..3fcb49f47c 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueProducerProvider.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueProducerProvider.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.queue.provider; +import jakarta.annotation.PostConstruct; import org.springframework.stereotype.Service; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; @@ -28,8 +29,6 @@ import org.thingsboard.server.queue.TbQueueProducer; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.util.TbCoreComponent; -import jakarta.annotation.PostConstruct; - @Service @TbCoreComponent public class TbCoreQueueProducerProvider implements TbQueueProducerProvider { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbRuleEngineProducerProvider.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbRuleEngineProducerProvider.java index 768d6b9c24..908369ea15 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbRuleEngineProducerProvider.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbRuleEngineProducerProvider.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.queue.provider; +import jakarta.annotation.PostConstruct; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Service; import org.thingsboard.server.gen.transport.TransportProtos; @@ -28,8 +29,6 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToUsageStatsServiceM import org.thingsboard.server.queue.TbQueueProducer; import org.thingsboard.server.queue.common.TbProtoQueueMsg; -import jakarta.annotation.PostConstruct; - @Service @ConditionalOnExpression("'${service.type:null}'=='tb-rule-engine'") public class TbRuleEngineProducerProvider implements TbQueueProducerProvider { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbTransportQueueProducerProvider.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbTransportQueueProducerProvider.java index 010eaf404f..df394cd997 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbTransportQueueProducerProvider.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbTransportQueueProducerProvider.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.queue.provider; +import jakarta.annotation.PostConstruct; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Service; import org.thingsboard.server.gen.transport.TransportProtos; @@ -28,8 +29,6 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToUsageStatsServiceM import org.thingsboard.server.queue.TbQueueProducer; import org.thingsboard.server.queue.common.TbProtoQueueMsg; -import jakarta.annotation.PostConstruct; - @Service @ConditionalOnExpression("'${service.type:null}'=='tb-transport'") public class TbTransportQueueProducerProvider implements TbQueueProducerProvider { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbVersionControlProducerProvider.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbVersionControlProducerProvider.java index 1e697c644a..7547c73f71 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbVersionControlProducerProvider.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbVersionControlProducerProvider.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.queue.provider; +import jakarta.annotation.PostConstruct; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Service; import org.thingsboard.server.gen.transport.TransportProtos; @@ -28,8 +29,6 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToUsageStatsServiceM import org.thingsboard.server.queue.TbQueueProducer; import org.thingsboard.server.queue.common.TbProtoQueueMsg; -import jakarta.annotation.PostConstruct; - @Service @ConditionalOnExpression("'${service.type:null}'=='tb-vc-executor'") public class TbVersionControlProducerProvider implements TbQueueProducerProvider { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubConsumerTemplate.java index 692673b41d..8a2ba90096 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubConsumerTemplate.java @@ -17,7 +17,6 @@ package org.thingsboard.server.queue.pubsub; import com.google.api.core.ApiFuture; import com.google.api.core.ApiFutures; -import com.google.api.gax.core.FixedExecutorProvider; import com.google.cloud.pubsub.v1.stub.GrpcSubscriberStub; import com.google.cloud.pubsub.v1.stub.SubscriberStub; import com.google.cloud.pubsub.v1.stub.SubscriberStubSettings; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubProducerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubProducerTemplate.java index 83331ccfe1..bb1d4e8976 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubProducerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubProducerTemplate.java @@ -18,7 +18,6 @@ package org.thingsboard.server.queue.pubsub; import com.google.api.core.ApiFuture; import com.google.api.core.ApiFutureCallback; import com.google.api.core.ApiFutures; -import com.google.api.gax.core.FixedExecutorProvider; import com.google.cloud.pubsub.v1.Publisher; import com.google.gson.Gson; import com.google.protobuf.ByteString; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubSettings.java b/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubSettings.java index 8652b16eca..a16d3d275f 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubSettings.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubSettings.java @@ -19,6 +19,8 @@ import com.google.api.gax.core.CredentialsProvider; import com.google.api.gax.core.FixedCredentialsProvider; import com.google.api.gax.core.FixedExecutorProvider; import com.google.auth.oauth2.ServiceAccountCredentials; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -26,8 +28,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; import org.thingsboard.common.util.ThingsBoardThreadFactory; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.concurrent.Executors; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubSubscriptionSettings.java b/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubSubscriptionSettings.java index 3b67d6b282..2faf4b1985 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubSubscriptionSettings.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubSubscriptionSettings.java @@ -15,13 +15,13 @@ */ package org.thingsboard.server.queue.pubsub; +import jakarta.annotation.PostConstruct; import lombok.Getter; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; import org.thingsboard.server.queue.util.PropertyUtils; -import jakarta.annotation.PostConstruct; import java.util.Map; @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/rabbitmq/TbRabbitMqQueueArguments.java b/common/queue/src/main/java/org/thingsboard/server/queue/rabbitmq/TbRabbitMqQueueArguments.java index b9b7f319df..acd008fb1d 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/rabbitmq/TbRabbitMqQueueArguments.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/rabbitmq/TbRabbitMqQueueArguments.java @@ -15,13 +15,13 @@ */ package org.thingsboard.server.queue.rabbitmq; +import jakarta.annotation.PostConstruct; import lombok.Getter; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; -import jakarta.annotation.PostConstruct; import java.util.HashMap; import java.util.Map; import java.util.regex.Pattern; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/rabbitmq/TbRabbitMqSettings.java b/common/queue/src/main/java/org/thingsboard/server/queue/rabbitmq/TbRabbitMqSettings.java index 72e1e7b902..432bce1d01 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/rabbitmq/TbRabbitMqSettings.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/rabbitmq/TbRabbitMqSettings.java @@ -16,14 +16,13 @@ package org.thingsboard.server.queue.rabbitmq; import com.rabbitmq.client.ConnectionFactory; +import jakarta.annotation.PostConstruct; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; -import jakarta.annotation.PostConstruct; - @Slf4j @ConditionalOnExpression("'${queue.type:null}'=='rabbitmq'") @Component diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/scheduler/DefaultSchedulerComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/scheduler/DefaultSchedulerComponent.java index 9dc2785005..a166259256 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/scheduler/DefaultSchedulerComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/scheduler/DefaultSchedulerComponent.java @@ -15,11 +15,11 @@ */ package org.thingsboard.server.queue.scheduler; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import org.springframework.stereotype.Component; import org.thingsboard.common.util.ThingsBoardThreadFactory; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.concurrent.Callable; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/sqs/TbAwsSqsAdmin.java b/common/queue/src/main/java/org/thingsboard/server/queue/sqs/TbAwsSqsAdmin.java index e5dfe7738c..92def925d7 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/sqs/TbAwsSqsAdmin.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/sqs/TbAwsSqsAdmin.java @@ -26,15 +26,12 @@ import com.amazonaws.services.sqs.model.CreateQueueRequest; import com.amazonaws.services.sqs.model.GetQueueUrlResult; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.thingsboard.common.util.ExecutorProvider; import org.thingsboard.common.util.ThingsBoardExecutors; -import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.queue.TbQueueAdmin; import org.thingsboard.server.queue.util.PropertyUtils; import java.util.Map; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.function.Function; import java.util.stream.Collectors; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/sqs/TbAwsSqsQueueAttributes.java b/common/queue/src/main/java/org/thingsboard/server/queue/sqs/TbAwsSqsQueueAttributes.java index f9c2ad0e7a..0c61c2d2eb 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/sqs/TbAwsSqsQueueAttributes.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/sqs/TbAwsSqsQueueAttributes.java @@ -16,13 +16,13 @@ package org.thingsboard.server.queue.sqs; import com.amazonaws.services.sqs.model.QueueAttributeName; +import jakarta.annotation.PostConstruct; import lombok.Getter; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; -import jakarta.annotation.PostConstruct; import java.util.HashMap; import java.util.Map; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java b/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java index 4faf5e1a31..e8502145ec 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.queue.usagestats; +import jakarta.annotation.PostConstruct; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,7 +38,6 @@ import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.scheduler.SchedulerComponent; -import jakarta.annotation.PostConstruct; import java.util.EnumMap; import java.util.Random; import java.util.UUID; diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/kafka/TbKafkaSettingsTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/kafka/TbKafkaSettingsTest.java index 23ab877379..3abeadbe60 100644 --- a/common/queue/src/test/java/org/thingsboard/server/queue/kafka/TbKafkaSettingsTest.java +++ b/common/queue/src/test/java/org/thingsboard/server/queue/kafka/TbKafkaSettingsTest.java @@ -49,7 +49,6 @@ class TbKafkaSettingsTest { Properties props = settings.toProps(); assertThat(props).as("TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS").containsEntry("request.timeout.ms", 30000); - assertThat(props).as("TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS").containsEntry("session.timeout.ms", 10000); //other-inline assertThat(props).as("metrics.recording.level").containsEntry("metrics.recording.level", "INFO"); diff --git a/common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java b/common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java index 159ff59cea..51244ade4b 100644 --- a/common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java +++ b/common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java @@ -18,6 +18,8 @@ package org.thingsboard.server.service.script; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -37,8 +39,6 @@ import org.thingsboard.server.queue.TbQueueRequestTemplate; import org.thingsboard.server.queue.common.TbProtoJsQueueMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.Map; import java.util.Optional; import java.util.UUID; diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java index 283044b279..d188111cc3 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java @@ -46,11 +46,25 @@ import java.util.Map; import java.util.Set; import java.util.regex.Matcher; +import static java.lang.Character.MAX_RADIX; +import static java.lang.Character.MIN_RADIX; + @Slf4j public class TbUtils { private static final byte[] HEX_ARRAY = "0123456789ABCDEF".getBytes(StandardCharsets.US_ASCII); + private static final int ZERO_RADIX = 0; + private static final int OCTAL_RADIX = 8; + private static final int DEC_RADIX = 10; + private static final int HEX_RADIX = 16; + private static final int HEX_LEN_MIN = -1; + private static final int HEX_LEN_INT_MAX = 8; + private static final int HEX_LEN_LONG_MAX = 16; + private static final int BYTES_LEN_INT_MAX = 4; + private static final int BYTES_LEN_LONG_MAX = 8; + private static final int BIN_LEN_MAX = 8; + private static final LinkedHashMap mdnEncodingReplacements = new LinkedHashMap<>(); static { @@ -103,6 +117,10 @@ public class TbUtils { String.class, int.class))); parserConfig.addImport("parseFloat", new MethodStub(TbUtils.class.getMethod("parseFloat", String.class))); + parserConfig.addImport("parseFloat", new MethodStub(TbUtils.class.getMethod("parseFloat", + String.class, int.class))); + parserConfig.addImport("parseHexIntLongToFloat", new MethodStub(TbUtils.class.getMethod("parseHexIntLongToFloat", + String.class, boolean.class))); parserConfig.addImport("parseDouble", new MethodStub(TbUtils.class.getMethod("parseDouble", String.class))); parserConfig.addImport("parseLittleEndianHexToInt", new MethodStub(TbUtils.class.getMethod("parseLittleEndianHexToInt", @@ -113,10 +131,18 @@ public class TbUtils { String.class))); parserConfig.addImport("parseHexToInt", new MethodStub(TbUtils.class.getMethod("parseHexToInt", String.class, boolean.class))); + parserConfig.addImport("parseBytesToInt", new MethodStub(TbUtils.class.getMethod("parseBytesToInt", + List.class))); + parserConfig.addImport("parseBytesToInt", new MethodStub(TbUtils.class.getMethod("parseBytesToInt", + List.class, int.class))); parserConfig.addImport("parseBytesToInt", new MethodStub(TbUtils.class.getMethod("parseBytesToInt", List.class, int.class, int.class))); parserConfig.addImport("parseBytesToInt", new MethodStub(TbUtils.class.getMethod("parseBytesToInt", List.class, int.class, int.class, boolean.class))); + parserConfig.addImport("parseBytesToInt", new MethodStub(TbUtils.class.getMethod("parseBytesToInt", + byte[].class))); + parserConfig.addImport("parseBytesToInt", new MethodStub(TbUtils.class.getMethod("parseBytesToInt", + byte[].class, int.class))); parserConfig.addImport("parseBytesToInt", new MethodStub(TbUtils.class.getMethod("parseBytesToInt", byte[].class, int.class, int.class))); parserConfig.addImport("parseBytesToInt", new MethodStub(TbUtils.class.getMethod("parseBytesToInt", @@ -129,10 +155,18 @@ public class TbUtils { String.class))); parserConfig.addImport("parseHexToLong", new MethodStub(TbUtils.class.getMethod("parseHexToLong", String.class, boolean.class))); + parserConfig.addImport("parseBytesToLong", new MethodStub(TbUtils.class.getMethod("parseBytesToLong", + List.class))); + parserConfig.addImport("parseBytesToLong", new MethodStub(TbUtils.class.getMethod("parseBytesToLong", + List.class, int.class))); parserConfig.addImport("parseBytesToLong", new MethodStub(TbUtils.class.getMethod("parseBytesToLong", List.class, int.class, int.class))); parserConfig.addImport("parseBytesToLong", new MethodStub(TbUtils.class.getMethod("parseBytesToLong", List.class, int.class, int.class, boolean.class))); + parserConfig.addImport("parseBytesToLong", new MethodStub(TbUtils.class.getMethod("parseBytesToLong", + byte[].class))); + parserConfig.addImport("parseBytesToLong", new MethodStub(TbUtils.class.getMethod("parseBytesToLong", + byte[].class, int.class))); parserConfig.addImport("parseBytesToLong", new MethodStub(TbUtils.class.getMethod("parseBytesToLong", byte[].class, int.class, int.class))); parserConfig.addImport("parseBytesToLong", new MethodStub(TbUtils.class.getMethod("parseBytesToLong", @@ -146,13 +180,37 @@ public class TbUtils { parserConfig.addImport("parseHexToFloat", new MethodStub(TbUtils.class.getMethod("parseHexToFloat", String.class, boolean.class))); parserConfig.addImport("parseBytesToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesToFloat", - byte[].class, int.class, boolean.class))); + List.class))); + parserConfig.addImport("parseBytesToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesToFloat", + List.class, int.class))); + parserConfig.addImport("parseBytesToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesToFloat", + List.class, int.class, int.class))); + parserConfig.addImport("parseBytesToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesToFloat", + List.class, int.class, int.class, boolean.class))); + parserConfig.addImport("parseBytesToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesToFloat", + byte[].class))); parserConfig.addImport("parseBytesToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesToFloat", byte[].class, int.class))); parserConfig.addImport("parseBytesToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesToFloat", - List.class, int.class, boolean.class))); + byte[].class, int.class, int.class))); parserConfig.addImport("parseBytesToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesToFloat", + byte[].class, int.class, int.class, boolean.class))); + parserConfig.addImport("parseBytesIntToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesIntToFloat", + List.class))); + parserConfig.addImport("parseBytesIntToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesIntToFloat", List.class, int.class))); + parserConfig.addImport("parseBytesIntToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesIntToFloat", + List.class, int.class, int.class))); + parserConfig.addImport("parseBytesIntToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesIntToFloat", + List.class, int.class, int.class, boolean.class))); + parserConfig.addImport("parseBytesIntToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesIntToFloat", + byte[].class))); + parserConfig.addImport("parseBytesIntToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesIntToFloat", + byte[].class, int.class))); + parserConfig.addImport("parseBytesIntToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesIntToFloat", + byte[].class, int.class, int.class))); + parserConfig.addImport("parseBytesIntToFloat", new MethodStub(TbUtils.class.getMethod("parseBytesIntToFloat", + byte[].class, int.class, int.class, boolean.class))); parserConfig.addImport("parseLittleEndianHexToDouble", new MethodStub(TbUtils.class.getMethod("parseLittleEndianHexToDouble", String.class))); parserConfig.addImport("parseBigEndianHexToDouble", new MethodStub(TbUtils.class.getMethod("parseBigEndianHexToDouble", @@ -161,22 +219,82 @@ public class TbUtils { String.class))); parserConfig.addImport("parseHexToDouble", new MethodStub(TbUtils.class.getMethod("parseHexToDouble", String.class, boolean.class))); + parserConfig.addImport("parseBytesToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesToDouble", + List.class))); + parserConfig.addImport("parseBytesToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesToDouble", + List.class, int.class))); + parserConfig.addImport("parseBytesToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesToDouble", + List.class, int.class, int.class))); + parserConfig.addImport("parseBytesToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesToDouble", + List.class, int.class, int.class, boolean.class))); + parserConfig.addImport("parseBytesToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesToDouble", + byte[].class))); parserConfig.addImport("parseBytesToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesToDouble", byte[].class, int.class))); parserConfig.addImport("parseBytesToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesToDouble", - byte[].class, int.class, boolean.class))); + byte[].class, int.class, int.class))); parserConfig.addImport("parseBytesToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesToDouble", + byte[].class, int.class, int.class, boolean.class))); + parserConfig.addImport("parseBytesLongToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesLongToDouble", + List.class))); + parserConfig.addImport("parseBytesLongToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesLongToDouble", List.class, int.class))); - parserConfig.addImport("parseBytesToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesToDouble", - List.class, int.class, boolean.class))); + parserConfig.addImport("parseBytesLongToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesLongToDouble", + List.class, int.class, int.class))); + parserConfig.addImport("parseBytesLongToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesLongToDouble", + List.class, int.class, int.class, boolean.class))); + parserConfig.addImport("parseBytesLongToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesLongToDouble", + byte[].class))); + parserConfig.addImport("parseBytesLongToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesLongToDouble", + byte[].class, int.class))); + parserConfig.addImport("parseBytesLongToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesLongToDouble", + byte[].class, int.class, int.class))); + parserConfig.addImport("parseBytesLongToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesLongToDouble", + byte[].class, int.class, int.class, boolean.class))); parserConfig.addImport("toFixed", new MethodStub(TbUtils.class.getMethod("toFixed", double.class, int.class))); parserConfig.addImport("toFixed", new MethodStub(TbUtils.class.getMethod("toFixed", float.class, int.class))); parserConfig.addImport("hexToBytes", new MethodStub(TbUtils.class.getMethod("hexToBytes", ExecutionContext.class, String.class))); + parserConfig.addImport("intToHex", new MethodStub(TbUtils.class.getMethod("intToHex", + Integer.class))); + parserConfig.addImport("intToHex", new MethodStub(TbUtils.class.getMethod("intToHex", + Integer.class, boolean.class))); + parserConfig.addImport("intToHex", new MethodStub(TbUtils.class.getMethod("intToHex", + Integer.class, boolean.class, boolean.class))); + parserConfig.addImport("intToHex", new MethodStub(TbUtils.class.getMethod("intToHex", + Integer.class, boolean.class, boolean.class, int.class))); + parserConfig.addImport("longToHex", new MethodStub(TbUtils.class.getMethod("longToHex", + Long.class))); + parserConfig.addImport("longToHex", new MethodStub(TbUtils.class.getMethod("longToHex", + Long.class, boolean.class))); + parserConfig.addImport("longToHex", new MethodStub(TbUtils.class.getMethod("longToHex", + Long.class, boolean.class, boolean.class))); + parserConfig.addImport("longToHex", new MethodStub(TbUtils.class.getMethod("longToHex", + Long.class, boolean.class, boolean.class, int.class))); + parserConfig.addImport("intLongToString", new MethodStub(TbUtils.class.getMethod("intLongToString", + Long.class))); + parserConfig.addImport("intLongToString", new MethodStub(TbUtils.class.getMethod("intLongToString", + Long.class, int.class))); + parserConfig.addImport("intLongToString", new MethodStub(TbUtils.class.getMethod("intLongToString", + Long.class, int.class, boolean.class))); + parserConfig.addImport("intLongToString", new MethodStub(TbUtils.class.getMethod("intLongToString", + Long.class, int.class, boolean.class, boolean.class))); + parserConfig.addImport("floatToHex", new MethodStub(TbUtils.class.getMethod("floatToHex", + Float.class))); + parserConfig.addImport("floatToHex", new MethodStub(TbUtils.class.getMethod("floatToHex", + Float.class, boolean.class))); + parserConfig.addImport("doubleToHex", new MethodStub(TbUtils.class.getMethod("doubleToHex", + Double.class))); + parserConfig.addImport("doubleToHex", new MethodStub(TbUtils.class.getMethod("doubleToHex", + Double.class, boolean.class))); + parserConfig.addImport("printUnsignedBytes", new MethodStub(TbUtils.class.getMethod("printUnsignedBytes", + ExecutionContext.class, List.class))); parserConfig.addImport("base64ToHex", new MethodStub(TbUtils.class.getMethod("base64ToHex", String.class))); + parserConfig.addImport("hexToBase64", new MethodStub(TbUtils.class.getMethod("hexToBase64", + String.class))); parserConfig.addImport("base64ToBytes", new MethodStub(TbUtils.class.getMethod("base64ToBytes", String.class))); parserConfig.addImport("bytesToBase64", new MethodStub(TbUtils.class.getMethod("bytesToBase64", @@ -197,6 +315,56 @@ public class TbUtils { String.class))); parserConfig.addImport("decodeURI", new MethodStub(TbUtils.class.getMethod("decodeURI", String.class))); + parserConfig.addImport("raiseError", new MethodStub(TbUtils.class.getMethod("raiseError", + String.class, Object.class))); + parserConfig.addImport("raiseError", new MethodStub(TbUtils.class.getMethod("raiseError", + String.class))); + parserConfig.addImport("isBinary", new MethodStub(TbUtils.class.getMethod("isBinary", + String.class))); + parserConfig.addImport("isOctal", new MethodStub(TbUtils.class.getMethod("isOctal", + String.class))); + parserConfig.addImport("isDecimal", new MethodStub(TbUtils.class.getMethod("isDecimal", + String.class))); + parserConfig.addImport("isHexadecimal", new MethodStub(TbUtils.class.getMethod("isHexadecimal", + String.class))); + parserConfig.addImport("byteArrayToExecutionArrayList", new MethodStub(TbUtils.class.getMethod("bytesToExecutionArrayList", + ExecutionContext.class, byte[].class))); + parserConfig.addImport("padStart", new MethodStub(TbUtils.class.getMethod("padStart", + String.class, int.class, char.class))); + parserConfig.addImport("padEnd", new MethodStub(TbUtils.class.getMethod("padEnd", + String.class, int.class, char.class))); + parserConfig.addImport("parseByteToBinaryArray", new MethodStub(TbUtils.class.getMethod("parseByteToBinaryArray", + byte.class))); + parserConfig.addImport("parseByteToBinaryArray", new MethodStub(TbUtils.class.getMethod("parseByteToBinaryArray", + byte.class, int.class))); + parserConfig.addImport("parseByteToBinaryArray", new MethodStub(TbUtils.class.getMethod("parseByteToBinaryArray", + byte.class, int.class, boolean.class))); + parserConfig.addImport("parseBytesToBinaryArray", new MethodStub(TbUtils.class.getMethod("parseBytesToBinaryArray", + List.class))); + parserConfig.addImport("parseBytesToBinaryArray", new MethodStub(TbUtils.class.getMethod("parseBytesToBinaryArray", + List.class, int.class))); + parserConfig.addImport("parseBytesToBinaryArray", new MethodStub(TbUtils.class.getMethod("parseBytesToBinaryArray", + byte[].class))); + parserConfig.addImport("parseBytesToBinaryArray", new MethodStub(TbUtils.class.getMethod("parseBytesToBinaryArray", + byte[].class))); + parserConfig.addImport("parseBytesToBinaryArray", new MethodStub(TbUtils.class.getMethod("parseBytesToBinaryArray", + byte[].class, int.class))); + parserConfig.addImport("parseLongToBinaryArray", new MethodStub(TbUtils.class.getMethod("parseLongToBinaryArray", + long.class))); + parserConfig.addImport("parseLongToBinaryArray", new MethodStub(TbUtils.class.getMethod("parseLongToBinaryArray", + long.class, int.class))); + parserConfig.addImport("parseBinaryArrayToInt", new MethodStub(TbUtils.class.getMethod("parseBinaryArrayToInt", + List.class))); + parserConfig.addImport("parseBinaryArrayToInt", new MethodStub(TbUtils.class.getMethod("parseBinaryArrayToInt", + List.class, int.class))); + parserConfig.addImport("parseBinaryArrayToInt", new MethodStub(TbUtils.class.getMethod("parseBinaryArrayToInt", + List.class, int.class, int.class))); + parserConfig.addImport("parseBinaryArrayToInt", new MethodStub(TbUtils.class.getMethod("parseBinaryArrayToInt", + byte[].class))); + parserConfig.addImport("parseBinaryArrayToInt", new MethodStub(TbUtils.class.getMethod("parseBinaryArrayToInt", + byte[].class, int.class))); + parserConfig.addImport("parseBinaryArrayToInt", new MethodStub(TbUtils.class.getMethod("parseBinaryArrayToInt", + byte[].class, int.class, int.class))); } public static String btoa(String input) { @@ -210,6 +378,7 @@ public class TbUtils { public static Object decodeToJson(ExecutionContext ctx, List bytesList) throws IOException { return TbJson.parse(ctx, bytesToString(bytesList)); } + public static Object decodeToJson(ExecutionContext ctx, String jsonStr) throws IOException { return TbJson.parse(ctx, jsonStr); } @@ -269,88 +438,160 @@ public class TbUtils { } public static Integer parseInt(String value) { - int radix = getRadix(value); - return parseInt(value, radix); + return parseInt(value, ZERO_RADIX); } public static Integer parseInt(String value, int radix) { - if (StringUtils.isNotBlank(value)) { - try { - String valueP = prepareNumberString(value); - isValidRadix(valueP, radix); - try { - return Integer.parseInt(valueP, radix); - } catch (NumberFormatException e) { - BigInteger bi = new BigInteger(valueP, radix); - if (bi.compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) > 0) - throw new NumberFormatException("Value \"" + value + "\" is greater than the maximum Integer value " + Integer.MAX_VALUE + " !"); - if (bi.compareTo(BigInteger.valueOf(Integer.MIN_VALUE)) < 0) - throw new NumberFormatException("Value \"" + value + "\" is less than the minimum Integer value " + Integer.MIN_VALUE + " !"); - Float f = parseFloat(valueP); - if (f != null) { - return f.intValue(); - } else { - throw new NumberFormatException(e.getMessage()); - } - } - } catch (NumberFormatException e) { - throw new NumberFormatException(e.getMessage()); + return parseInt(value, radix, true); + } + + private static Integer parseInt(String value, int radix, boolean bigEndian) { + String valueP = prepareNumberString(value, bigEndian); + if (valueP != null) { + int radixValue = isValidStringAndRadix(valueP, radix, value); + if (radixValue >= 25 && radixValue <= MAX_RADIX) { + return (Integer) compareIntLongValueMinMax(valueP, radixValue, Integer.MAX_VALUE, Integer.MIN_VALUE); } + return switch (radixValue) { + case MIN_RADIX -> parseBinaryStringAsSignedInteger(valueP); + case OCTAL_RADIX, DEC_RADIX, HEX_RADIX -> Integer.parseInt(valueP, radixValue); + default -> throw new IllegalArgumentException("Invalid radix: [" + radix + "]"); + }; } return null; } public static Long parseLong(String value) { - int radix = getRadix(value); - return parseLong(value, radix); + return parseLong(value, ZERO_RADIX); } public static Long parseLong(String value, int radix) { - if (StringUtils.isNotBlank(value)) { - try { - String valueP = prepareNumberString(value); - isValidRadix(valueP, radix); - try { - return Long.parseLong(valueP, radix); - } catch (NumberFormatException e) { - BigInteger bi = new BigInteger(valueP, radix); - if (bi.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0) - throw new NumberFormatException("Value \"" + value + "\"is greater than the maximum Long value " + Long.MAX_VALUE + " !"); - if (bi.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) < 0) - throw new NumberFormatException("Value \"" + value + "\" is less than the minimum Long value " + Long.MIN_VALUE + " !"); - Double dd = parseDouble(valueP); - if (dd != null) { - return dd.longValue(); - } else { - throw new NumberFormatException(e.getMessage()); - } - } - } catch (NumberFormatException e) { - throw new NumberFormatException(e.getMessage()); + return parseLong(value, radix, true); + } + + private static Long parseLong(String value, int radix, boolean bigEndian) { + String valueP = prepareNumberString(value, bigEndian); + if (valueP != null) { + int radixValue = isValidStringAndRadix(valueP, radix, value); + if (radixValue >= 25 && radixValue <= MAX_RADIX) { + return (Long) compareIntLongValueMinMax(valueP, radixValue, Long.MAX_VALUE, Long.MIN_VALUE); } + return switch (radixValue) { + case MIN_RADIX -> parseBinaryStringAsSignedLong(valueP); + case OCTAL_RADIX, DEC_RADIX, HEX_RADIX -> Long.parseLong(valueP, radixValue); + default -> throw new IllegalArgumentException("Invalid radix: [" + radix + "]"); + }; } return null; } - private static int getRadix(String value, int... radixS) { - return radixS.length > 0 ? radixS[0] : isHexadecimal(value) ? 16 : 10; + private static int parseBinaryStringAsSignedInteger(String binaryString) { + if (binaryString.length() != 32) { + // Pad the binary string to 64 bits if it is not already + binaryString = String.format("%32s", binaryString).replace(' ', '0'); + } + + // If the MSB is 1, the number is negative in two's complement + if (binaryString.charAt(0) == '1') { + // Calculate the two's complement + String invertedBinaryString = invertBinaryString(binaryString); + int positiveValue = Integer.parseInt(invertedBinaryString, MIN_RADIX) + 1; + return -positiveValue; + } else { + return Integer.parseInt(binaryString, MIN_RADIX); + } + } + + private static long parseBinaryStringAsSignedLong(String binaryString) { + if (binaryString.length() != 64) { + // Pad the binary string to 64 bits if it is not already + binaryString = String.format("%64s", binaryString).replace(' ', '0'); + } + + // If the MSB is 1, the number is negative in two's complement + if (binaryString.charAt(0) == '1') { + // Calculate the two's complement + String invertedBinaryString = invertBinaryString(binaryString); + long positiveValue = Long.parseLong(invertedBinaryString, MIN_RADIX) + 1; + return -positiveValue; + } else { + return Long.parseLong(binaryString, MIN_RADIX); + } + } + + private static String invertBinaryString(String binaryString) { + StringBuilder invertedString = new StringBuilder(); + for (char bit : binaryString.toCharArray()) { + invertedString.append(bit == '0' ? '1' : '0'); + } + return invertedString.toString(); + } + + private static int getRadix10_16(String value) { + int radix = isDecimal(value) > 0 ? DEC_RADIX : isHexadecimal(value); + if (radix > 0) { + return radix; + } else { + throw new NumberFormatException("Value: \"" + value + "\" is not numeric or hexDecimal format!"); + } } public static Float parseFloat(String value) { - if (value != null) { - try { - return Float.parseFloat(prepareNumberString(value)); - } catch (NumberFormatException e) { + return parseFloat(value, ZERO_RADIX); + } + + public static Float parseFloat(String value, int radix) { + String valueP = prepareNumberString(value, true); + if (valueP != null) { + return parseFloatFromString(value, valueP, radix); + } + return null; + } + + private static Float parseFloatFromString(String value, String valueP, int radix) { + int radixValue = isValidStringAndRadix(valueP, radix, value); + if (radixValue == HEX_RADIX) { + int bits = (int) Long.parseLong(valueP, HEX_RADIX); + // Hex representation is a standard IEEE 754 float value (eg "0x41200000" for 10.0f). + return Float.intBitsToFloat(bits); + } else { + return Float.parseFloat(value); + } + } + + public static Float parseHexIntLongToFloat(String value, boolean bigEndian) { + String valueP = prepareNumberString(value, bigEndian); + if (valueP != null) { + int radixValue = isValidStringAndRadix(valueP, HEX_RADIX, value); + if (radixValue == HEX_RADIX) { + int bits = (int) Long.parseLong(valueP, HEX_RADIX); + // If the length is not equal to 8 characters, we process it as an integer (eg "0x0A" for 10.0f). + float floatValue = (float) bits; + return Float.valueOf(floatValue); } } return null; } + public static Double parseDouble(String value) { - if (value != null) { - try { - return Double.parseDouble(prepareNumberString(value)); - } catch (NumberFormatException e) { + int radix = getRadix10_16(value); + return parseDouble(value, radix); + } + + public static Double parseDouble(String value, int radix) { + return parseDouble(value, radix, true); + } + + private static Double parseDouble(String value, int radix, boolean bigEndian) { + String valueP = prepareNumberString(value, bigEndian); + if (valueP != null) { + int radixValue = isValidStringAndRadix(valueP, radix, value); + if (radixValue == DEC_RADIX) { + return Double.parseDouble(valueP); + } else { + long bits = Long.parseUnsignedLong(valueP, HEX_RADIX); + return Double.longBitsToDouble(bits); } } return null; @@ -368,9 +609,8 @@ public class TbUtils { return parseHexToInt(hex, true); } - public static int parseHexToInt(String hex, boolean bigEndian) { - byte[] data = prepareHexToBytesNumber(hex, 8); - return parseBytesToInt(data, 0, data.length, bigEndian); + public static Integer parseHexToInt(String value, boolean bigEndian) { + return parseInt(value, HEX_RADIX, bigEndian); } public static long parseLittleEndianHexToLong(String hex) { @@ -385,9 +625,8 @@ public class TbUtils { return parseHexToLong(hex, true); } - public static long parseHexToLong(String hex, boolean bigEndian) { - byte[] data = prepareHexToBytesNumber(hex, 16); - return parseBytesToLong(data, 0, data.length, bigEndian); + public static Long parseHexToLong(String value, boolean bigEndian) { + return parseLong(value, HEX_RADIX, bigEndian); } public static float parseLittleEndianHexToFloat(String hex) { @@ -402,9 +641,12 @@ public class TbUtils { return parseHexToFloat(hex, true); } - public static float parseHexToFloat(String hex, boolean bigEndian) { - byte[] data = prepareHexToBytesNumber(hex, 8); - return parseBytesToFloat(data, 0, bigEndian); + public static Float parseHexToFloat(String value, boolean bigEndian) { + String valueP = prepareNumberString(value, bigEndian); + if (valueP != null) { + return parseFloatFromString(value, valueP, HEX_RADIX); + } + return null; } public static double parseLittleEndianHexToDouble(String hex) { @@ -419,43 +661,178 @@ public class TbUtils { return parseHexToDouble(hex, true); } - public static double parseHexToDouble(String hex, boolean bigEndian) { - byte[] data = prepareHexToBytesNumber(hex, 16); - return parseBytesToDouble(data, 0, bigEndian); + public static double parseHexToDouble(String value, boolean bigEndian) { + return parseDouble(value, HEX_RADIX, bigEndian); } - private static byte[] prepareHexToBytesNumber(String hex, int len) { - int length = hex.length(); - if (length > len) { - throw new IllegalArgumentException("Hex string is too large. Maximum 8 symbols allowed."); + public static ExecutionArrayList hexToBytes(ExecutionContext ctx, String value) { + String hex = prepareNumberString(value, true); + if (hex == null) { + throw new IllegalArgumentException("Hex string must be not empty!"); } - if (length % 2 > 0) { + int len = hex.length(); + if (len % 2 > 0) { throw new IllegalArgumentException("Hex string must be even-length."); } - byte[] data = new byte[length / 2]; - for (int i = 0; i < length; i += 2) { - data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4) + Character.digit(hex.charAt(i + 1), 16)); + int radix = isHexadecimal(value); + if (radix != HEX_RADIX) { + throw new NumberFormatException("Value: \"" + value + "\" is not numeric or hexDecimal format!"); + } + + byte [] data = hexToBytes(hex); + return bytesToExecutionArrayList(ctx, data); + } + + public static List printUnsignedBytes(ExecutionContext ctx, List byteArray) { + ExecutionArrayList data = new ExecutionArrayList<>(ctx); + for (Byte b : byteArray) { + // Convert signed byte to unsigned integer + int unsignedByte = Byte.toUnsignedInt(b); + data.add(unsignedByte); } return data; } - public static ExecutionArrayList hexToBytes(ExecutionContext ctx, String hex) { - int len = hex.length(); - if (len % 2 > 0) { - throw new IllegalArgumentException("Hex string must be even-length."); + public static String intToHex(Integer i) { + return prepareNumberHexString(i.longValue(), true, false, HEX_LEN_MIN, HEX_LEN_INT_MAX); + } + + public static String intToHex(Integer i, boolean bigEndian) { + return prepareNumberHexString(i.longValue(), bigEndian, false, HEX_LEN_MIN, HEX_LEN_INT_MAX); + } + + public static String intToHex(Integer i, boolean bigEndian, boolean pref) { + return prepareNumberHexString(i.longValue(), bigEndian, pref, HEX_LEN_MIN, HEX_LEN_INT_MAX); + } + + public static String intToHex(Integer i, boolean bigEndian, boolean pref, int len) { + return prepareNumberHexString(i.longValue(), bigEndian, pref, len, HEX_LEN_INT_MAX); + } + + public static String longToHex(Long l) { + return prepareNumberHexString(l, true, false, HEX_LEN_MIN, HEX_LEN_LONG_MAX); + } + + public static String longToHex(Long l, boolean bigEndian) { + return prepareNumberHexString(l, bigEndian, false, HEX_LEN_MIN, HEX_LEN_LONG_MAX); + } + + public static String longToHex(Long l, boolean bigEndian, boolean pref) { + return prepareNumberHexString(l, bigEndian, pref, HEX_LEN_MIN, HEX_LEN_LONG_MAX); + } + + public static String longToHex(Long l, boolean bigEndian, boolean pref, int len) { + return prepareNumberHexString(l, bigEndian, pref, len, HEX_LEN_LONG_MAX); + } + + public static String intLongToString(Long number) { + return intLongToString(number, DEC_RADIX); + } + + public static String intLongToString(Long number, int radix) { + return intLongToString(number, radix, true); + } + + public static String intLongToString(Long number, int radix, boolean bigEndian) { + return intLongToString(number, radix, bigEndian, false); + } + + public static String intLongToString(Long number, int radix, boolean bigEndian, boolean pref) { + if (radix >= 25 && radix <= MAX_RADIX) { + return Long.toString(number, radix); } - ExecutionArrayList data = new ExecutionArrayList<>(ctx); - for (int i = 0; i < len; i += 2) { - data.add((byte) ((Character.digit(hex.charAt(i), 16) << 4) - + Character.digit(hex.charAt(i + 1), 16))); + return switch (radix) { + case MIN_RADIX -> formatBinary(Long.toBinaryString(number)); + case OCTAL_RADIX -> Long.toOctalString(number); + case DEC_RADIX -> Long.toString(number); + case HEX_RADIX -> prepareNumberHexString(number, bigEndian, pref, -1, -1); + default -> throw new IllegalArgumentException("Invalid radix: [" + radix + "]"); + }; + } + + private static Number compareIntLongValueMinMax(String valueP, int radix, Number maxValue, Number minValue) { + boolean isInteger = maxValue.getClass().getSimpleName().equals("Integer"); + try { + if (isInteger) { + return Integer.parseInt(valueP, radix); + } else { + return Long.parseLong(valueP, radix); + } + } catch (NumberFormatException e) { + BigInteger bi = new BigInteger(valueP, radix); + long maxValueL = isInteger ? maxValue.longValue() : (long) maxValue; + if (bi.compareTo(BigInteger.valueOf(maxValueL)) > 0) { + throw new NumberFormatException("Value \"" + valueP + "\"is greater than the maximum " + maxValue.getClass().getSimpleName() + " value " + maxValue + " !"); + } + long minValueL = isInteger ? minValue.longValue() : (long) minValue; + if (bi.compareTo(BigInteger.valueOf(minValueL)) < 0) { + throw new NumberFormatException("Value \"" + valueP + "\" is less than the minimum " + minValue.getClass().getSimpleName() + " value " + minValue + " !"); + } + throw new NumberFormatException(e.getMessage()); } - return data; + } + + private static String prepareNumberHexString(Long number, boolean bigEndian, boolean pref, int len, int hexLenMax) { + String hex = Long.toHexString(number).toUpperCase(); + hexLenMax = hexLenMax < 0 ? hex.length() : hexLenMax; + String hexWithoutZeroFF = removeLeadingZero_FF(hex, number, hexLenMax); + hexWithoutZeroFF = bigEndian ? hexWithoutZeroFF : reverseHexStringByOrder(hexWithoutZeroFF); + len = len == HEX_LEN_MIN ? hexWithoutZeroFF.length() : len; + String result = hexWithoutZeroFF.substring(hexWithoutZeroFF.length() - len); + return pref ? "0x" + result : result; + } + + private static String removeLeadingZero_FF(String hex, Long number, int hexLenMax) { + String hexWithoutZero = hex.replaceFirst("^0+(?!$)", ""); // Remove leading zeros except for the last one + hexWithoutZero = hexWithoutZero.length() % 2 > 0 ? "0" + hexWithoutZero : hexWithoutZero; + if (number >= 0) { + return hexWithoutZero; + } else { + String hexWithoutZeroFF = hexWithoutZero.replaceFirst("^F+(?!$)", ""); + hexWithoutZeroFF = hexWithoutZeroFF.length() % 2 > 0 ? "F" + hexWithoutZeroFF : hexWithoutZeroFF; + if (hexWithoutZeroFF.length() > hexLenMax) { + return hexWithoutZeroFF.substring(hexWithoutZeroFF.length() - hexLenMax); + } else if (hexWithoutZeroFF.length() == hexLenMax) { + return hexWithoutZeroFF; + } else { + return "FF" + hexWithoutZeroFF; + } + } + } + + public static String floatToHex(Float f) { + return floatToHex(f, true); + } + + public static String floatToHex(Float f, boolean bigEndian) { + // Convert the float to its raw integer bits representation + int bits = Float.floatToIntBits(f); + + // Format the integer bits as a hexadecimal string + String result = String.format("0x%08X", bits); + return bigEndian ? result : reverseHexStringByOrder(result); + } + + public static String doubleToHex(Double d) { + return doubleToHex(d, true); + } + + public static String doubleToHex(Double d, boolean bigEndian) { + long bits = Double.doubleToRawLongBits(d); + + // Format the integer bits as a hexadecimal string + String result = String.format("0x%016X", bits); + return bigEndian ? result : reverseHexStringByOrder(result); } public static String base64ToHex(String base64) { return bytesToHex(Base64.getDecoder().decode(base64)); } + public static String hexToBase64(String hex) { + return bytesToBase64(hexToBytes(hex)); + } + public static String bytesToBase64(byte[] bytes) { return Base64.getEncoder().encodeToString(bytes); } @@ -464,16 +841,28 @@ public class TbUtils { return Base64.getDecoder().decode(input); } + public static int parseBytesToInt(List data) { + return parseBytesToInt(data, 0); + } + + public static int parseBytesToInt(List data, int offset) { + return parseBytesToInt(data, offset, validateLength(data.size(), offset, BYTES_LEN_INT_MAX)); + } + public static int parseBytesToInt(List data, int offset, int length) { return parseBytesToInt(data, offset, length, true); } public static int parseBytesToInt(List data, int offset, int length, boolean bigEndian) { - final byte[] bytes = new byte[data.size()]; - for (int i = 0; i < bytes.length; i++) { - bytes[i] = data.get(i); - } - return parseBytesToInt(bytes, offset, length, bigEndian); + return parseBytesToInt(Bytes.toArray(data), offset, length, bigEndian); + } + + public static int parseBytesToInt(byte[] data) { + return parseBytesToInt(data, 0); + } + + public static int parseBytesToInt(byte[] data, int offset) { + return parseBytesToInt(data, offset, validateLength(data.length, offset, BYTES_LEN_INT_MAX)); } public static int parseBytesToInt(byte[] data, int offset, int length) { @@ -481,15 +870,7 @@ public class TbUtils { } public static int parseBytesToInt(byte[] data, int offset, int length, boolean bigEndian) { - if (offset > data.length) { - throw new IllegalArgumentException("Offset: " + offset + " is out of bounds for array with length: " + data.length + "!"); - } - if (length > 4) { - throw new IllegalArgumentException("Length: " + length + " is too large. Maximum 4 bytes is allowed!"); - } - if (offset + length > data.length) { - throw new IllegalArgumentException("Offset: " + offset + " and Length: " + length + " is out of bounds for array with length: " + data.length + "!"); - } + validationNumberByLength(data, offset, length, BYTES_LEN_INT_MAX); var bb = ByteBuffer.allocate(4); if (!bigEndian) { bb.order(ByteOrder.LITTLE_ENDIAN); @@ -500,16 +881,28 @@ public class TbUtils { return bb.getInt(); } + public static long parseBytesToLong(List data) { + return parseBytesToLong(data, 0); + } + + public static long parseBytesToLong(List data, int offset) { + return parseBytesToLong(data, offset, validateLength(data.size(), offset, BYTES_LEN_LONG_MAX)); + } + public static long parseBytesToLong(List data, int offset, int length) { return parseBytesToLong(data, offset, length, true); } public static long parseBytesToLong(List data, int offset, int length, boolean bigEndian) { - final byte[] bytes = new byte[data.size()]; - for (int i = 0; i < bytes.length; i++) { - bytes[i] = data.get(i); - } - return parseBytesToLong(bytes, offset, length, bigEndian); + return parseBytesToLong(Bytes.toArray(data), offset, length, bigEndian); + } + + public static long parseBytesToLong(byte[] data) { + return parseBytesToLong(data, 0); + } + + public static long parseBytesToLong(byte[] data, int offset) { + return parseBytesToLong(data, offset, validateLength(data.length, offset, BYTES_LEN_LONG_MAX)); } public static long parseBytesToLong(byte[] data, int offset, int length) { @@ -517,67 +910,176 @@ public class TbUtils { } public static long parseBytesToLong(byte[] data, int offset, int length, boolean bigEndian) { - if (offset > data.length) { - throw new IllegalArgumentException("Offset: " + offset + " is out of bounds for array with length: " + data.length + "!"); - } - if (length > 8) { - throw new IllegalArgumentException("Length: " + length + " is too large. Maximum 4 bytes is allowed!"); - } - if (offset + length > data.length) { - throw new IllegalArgumentException("Offset: " + offset + " and Length: " + length + " is out of bounds for array with length: " + data.length + "!"); - } - var bb = ByteBuffer.allocate(8); + validationNumberByLength(data, offset, length, BYTES_LEN_LONG_MAX); + var bb = ByteBuffer.allocate(BYTES_LEN_LONG_MAX); if (!bigEndian) { bb.order(ByteOrder.LITTLE_ENDIAN); } - bb.position(bigEndian ? 8 - length : 0); + bb.position(bigEndian ? BYTES_LEN_LONG_MAX - length : 0); bb.put(data, offset, length); bb.position(0); return bb.getLong(); } - public static float parseBytesToFloat(byte[] data, int offset) { - return parseBytesToFloat(data, offset, true); + public static float parseBytesToFloat(List data) { + return parseBytesToFloat(data, 0); } public static float parseBytesToFloat(List data, int offset) { - return parseBytesToFloat(data, offset, true); + return parseBytesToFloat(data, offset, validateLength(data.size(), offset, BYTES_LEN_INT_MAX)); } - public static float parseBytesToFloat(List data, int offset, boolean bigEndian) { - return parseBytesToFloat(Bytes.toArray(data), offset, bigEndian); + public static float parseBytesToFloat(List data, int offset, int length) { + return parseBytesToFloat(data, offset, length, true); } - public static float parseBytesToFloat(byte[] data, int offset, boolean bigEndian) { - byte[] bytesToNumber = prepareBytesToNumber(data, offset, 4, bigEndian); - return ByteBuffer.wrap(bytesToNumber).getFloat(); + public static float parseBytesToFloat(List data, int offset, int length, boolean bigEndian) { + return parseBytesToFloat(Bytes.toArray(data), offset, length, bigEndian); } + public static float parseBytesToFloat(byte[] data) { + return parseBytesToFloat(data, 0); + } - public static double parseBytesToDouble(byte[] data, int offset) { - return parseBytesToDouble(data, offset, true); + public static float parseBytesToFloat(byte[] data, int offset) { + return parseBytesToFloat(data, offset, validateLength(data.length, offset, BYTES_LEN_INT_MAX)); + } + + public static float parseBytesToFloat(byte[] data, int offset, int length) { + return parseBytesToFloat(data, offset, length, true); + } + + public static float parseBytesToFloat(byte[] data, int offset, int length, boolean bigEndian) { + var bb = ByteBuffer.allocate(BYTES_LEN_INT_MAX); + if (!bigEndian) { + bb.order(ByteOrder.LITTLE_ENDIAN); + } + bb.position(bigEndian ? BYTES_LEN_INT_MAX - length : 0); + bb.put(data, offset, length); + bb.position(0); + float floatValue = bb.getFloat(); + if (Float.isNaN(floatValue)) { + throw new NumberFormatException("byte[] 0x" + bytesToHex(data) + " is a Not-a-Number (NaN) value"); + } + return floatValue; + } + + public static float parseBytesIntToFloat(List data) { + return parseBytesIntToFloat(data, 0); + } + + public static float parseBytesIntToFloat(List data, int offset) { + return parseBytesIntToFloat(data, offset, validateLength(data.size(), offset, BYTES_LEN_INT_MAX)); + } + + public static float parseBytesIntToFloat(List data, int offset, int length) { + return parseBytesIntToFloat(data, offset, length, true); + } + + public static float parseBytesIntToFloat(List data, int offset, int length, boolean bigEndian) { + return parseBytesIntToFloat(Bytes.toArray(data), offset, length, bigEndian); + } + + public static float parseBytesIntToFloat(byte[] data) { + return parseBytesIntToFloat(data, 0); + } + + public static float parseBytesIntToFloat(byte[] data, int offset) { + return parseBytesIntToFloat(data, offset, validateLength(data.length, offset, BYTES_LEN_INT_MAX)); + } + + public static float parseBytesIntToFloat(byte[] data, int offset, int length) { + return parseBytesIntToFloat(data, offset, length, true); + } + + public static float parseBytesIntToFloat(byte[] data, int offset, int length, boolean bigEndian) { + byte[] bytesToNumber = prepareBytesToNumber(data, offset, length, bigEndian, BYTES_LEN_INT_MAX); + long longValue = parseBytesToLong(bytesToNumber, 0, length); + BigDecimal bigDecimalValue = new BigDecimal(longValue); + return bigDecimalValue.floatValue(); + } + + public static double parseBytesToDouble(List data) { + return parseBytesToDouble(data, 0); } public static double parseBytesToDouble(List data, int offset) { - return parseBytesToDouble(data, offset, true); + return parseBytesToDouble(data, offset, validateLength(data.size(), offset, BYTES_LEN_LONG_MAX)); } - public static double parseBytesToDouble(List data, int offset, boolean bigEndian) { - return parseBytesToDouble(Bytes.toArray(data), offset, bigEndian); + public static double parseBytesToDouble(List data, int offset, int length) { + return parseBytesToDouble(data, offset, length, true); } - public static double parseBytesToDouble(byte[] data, int offset, boolean bigEndian) { - byte[] bytesToNumber = prepareBytesToNumber(data, offset, 8, bigEndian); - return ByteBuffer.wrap(bytesToNumber).getDouble(); + public static double parseBytesToDouble(List data, int offset, int length, boolean bigEndian) { + return parseBytesToDouble(Bytes.toArray(data), offset, length, bigEndian); } - private static byte[] prepareBytesToNumber(byte[] data, int offset, int length, boolean bigEndian) { - if (offset > data.length) { - throw new IllegalArgumentException("Offset: " + offset + " is out of bounds for array with length: " + data.length + "!"); + public static double parseBytesToDouble(byte[] data) { + return parseBytesToDouble(data, 0); + } + + public static double parseBytesToDouble(byte[] data, int offset) { + return parseBytesToDouble(data, offset, validateLength(data.length, offset, BYTES_LEN_LONG_MAX)); + } + + public static double parseBytesToDouble(byte[] data, int offset, int length) { + return parseBytesToDouble(data, offset, length, true); + } + + public static double parseBytesToDouble(byte[] data, int offset, int length, boolean bigEndian) { + var bb = ByteBuffer.allocate(BYTES_LEN_LONG_MAX); + if (!bigEndian) { + bb.order(ByteOrder.LITTLE_ENDIAN); } - if ((offset + length) > data.length) { - throw new IllegalArgumentException("Default length is always " + length + " bytes. Offset: " + offset + " and Length: " + length + " is out of bounds for array with length: " + data.length + "!"); + bb.position(bigEndian ? BYTES_LEN_LONG_MAX - length : 0); + bb.put(data, offset, length); + bb.position(0); + double doubleValue = bb.getDouble(); + if (Double.isNaN(doubleValue)) { + throw new NumberFormatException("byte[] 0x" + bytesToHex(data) + " is a Not-a-Number (NaN) value"); } + return doubleValue; + } + + public static double parseBytesLongToDouble(List data) { + return parseBytesLongToDouble(data, 0); + } + + public static double parseBytesLongToDouble(List data, int offset) { + return parseBytesLongToDouble(data, offset, validateLength(data.size(), offset, BYTES_LEN_LONG_MAX)); + } + + public static double parseBytesLongToDouble(List data, int offset, int length) { + return parseBytesLongToDouble(data, offset, length, true); + } + + public static double parseBytesLongToDouble(List data, int offset, int length, boolean bigEndian) { + return parseBytesLongToDouble(Bytes.toArray(data), offset, length, bigEndian); + } + + public static double parseBytesLongToDouble(byte[] data) { + return parseBytesLongToDouble(data, 0); + } + + public static double parseBytesLongToDouble(byte[] data, int offset) { + return parseBytesLongToDouble(data, offset, validateLength(data.length, offset, BYTES_LEN_LONG_MAX)); + } + + public static double parseBytesLongToDouble(byte[] data, int offset, int length) { + return parseBytesLongToDouble(data, offset, length, true); + } + + public static double parseBytesLongToDouble(byte[] data, int offset, int length, boolean bigEndian) { + byte[] bytesToNumber = prepareBytesToNumber(data, offset, length, bigEndian, BYTES_LEN_LONG_MAX); + BigInteger bigInt = new BigInteger(1, bytesToNumber); + BigDecimal bigDecimalValue = new BigDecimal(bigInt); + return bigDecimalValue.doubleValue(); + } + + private static byte[] prepareBytesToNumber(byte[] data, int offset, int length, boolean bigEndian, + int bytesLenMax) { + validationNumberByLength(data, offset, length, bytesLenMax); byte[] dataBytesArray = Arrays.copyOfRange(data, offset, (offset + length)); if (!bigEndian) { ArrayUtils.reverse(dataBytesArray); @@ -647,6 +1149,15 @@ public class TbUtils { return URLDecoder.decode(uri, StandardCharsets.UTF_8); } + public static void raiseError(String message) { + raiseError(message, null); + } + + 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 map, List excludeList, String path, boolean pathInKey) { if (json instanceof Map.Entry) { Map.Entry entry = (Map.Entry) json; @@ -687,42 +1198,260 @@ public class TbUtils { } } - private static boolean isHexadecimal(String value) { - return value != null && (value.contains("0x") || value.contains("0X")); + private static String prepareNumberString(String value, boolean bigEndian) { + if (StringUtils.isNotBlank(value)) { + value = value.trim(); + value = value.replace("0x", ""); + value = value.replace("0X", ""); + value = value.replace(",", "."); + return bigEndian ? value : reverseHexStringByOrder(value); + } + return null; } - private static String prepareNumberString(String value) { - if (value != null) { - value = value.trim(); - if (isHexadecimal(value)) { - value = value.replace("0x", ""); - value = value.replace("0X", ""); + private static int isValidStringAndRadix(String valueP, int radix, String value) { + int radixValue; + if (radix == 0) { + radixValue = getRadix10_16(valueP); + } else if (radix >= 25 && radix <= MAX_RADIX) { + return radix; + } else { + radixValue = switch (radix) { + case MIN_RADIX -> isBinary(valueP); + case OCTAL_RADIX -> isOctal(valueP); + case DEC_RADIX -> isDecimal(valueP); + case HEX_RADIX -> isHexadecimal(valueP); + default -> throw new IllegalArgumentException("Invalid radix: [" + radix + "]"); + + }; + } + + if (radixValue > 0) { + if (value.startsWith("0x")) radixValue = HEX_RADIX; + if (radixValue == HEX_RADIX) { + valueP = value.startsWith("-") ? value.substring(1) : value; + if (valueP.length() % 2 > 0) { + throw new NumberFormatException("The hexadecimal value: \"" + value + "\" must be of even length, or if the decimal value must be a number!"); + } } - value = value.replace(",", "."); + return radixValue; + } else { + if (radix > 0) { + throw new NumberFormatException("Failed radix [" + radix + "] for value: \"" + value + "\", must be [" + radixValue + "] !"); + } else { + throw new NumberFormatException("Invalid \"" + value + "\". It is not numeric or hexadecimal format!"); + } + } + } + + public static int isBinary(String str) { + if (str == null || str.isEmpty()) { + return -1; + } + return str.matches("[01]+") ? MIN_RADIX : -1; + } + + public static int isOctal(String str) { + if (str == null || str.isEmpty()) { + return -1; + } + return str.matches("[0-7]+") ? OCTAL_RADIX : -1; + } + + public static int isDecimal(String str) { + if (str == null || str.isEmpty()) { + return -1; } - return value; + return str.matches("-?\\d+(\\.\\d+)?") ? DEC_RADIX : -1; } - private static boolean isValidRadix(String value, int radix) { - for (int i = 0; i < value.length(); i++) { - if (i == 0 && value.charAt(i) == '-') { - if (value.length() == 1) - throw new NumberFormatException("Failed radix [" + radix + "] for value: \"" + value + "\"!"); - else - continue; + public static int isHexadecimal(String str) { + if (str == null || str.isEmpty()) { + return -1; + } + return str.matches("^-?(0[xX])?[0-9a-fA-F]+$") ? HEX_RADIX : -1; + } + + public static ExecutionArrayList bytesToExecutionArrayList(ExecutionContext ctx, byte[] byteArray) { + List byteList = new ArrayList<>(); + for (byte b : byteArray) { + byteList.add(b); + } + ExecutionArrayList list = new ExecutionArrayList(byteList, ctx); + return list; + } + + public static String padStart(String str, int targetLength, char padString) { + while (str.length() < targetLength) { + str = padString + str; + } + return str; + } + + public static String padEnd(String str, int targetLength, char padString) { + while (str.length() < targetLength) { + str = str + padString; + } + return str; + } + + public static byte[] parseByteToBinaryArray(byte byteValue) { + return parseByteToBinaryArray(byteValue, BIN_LEN_MAX); + } + + public static byte[] parseByteToBinaryArray(byte byteValue, int binLength) { + return parseByteToBinaryArray(byteValue, binLength, true); + } + + /** + * bigEndian = true + * 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). + */ + public static byte[] parseByteToBinaryArray(byte byteValue, int binLength, boolean bigEndian) { + byte[] bins = new byte[binLength]; + for (int i = 0; i < binLength; i++) { + if(bigEndian) { + bins[binLength - 1 - i] = (byte) ((byteValue >> i) & 1); + } else { + bins[i] = (byte) ((byteValue >> i) & 1); } - if (Character.digit(value.charAt(i), radix) < 0) - throw new NumberFormatException("Failed radix: [" + radix + "] for value: \"" + value + "\"!"); } - return true; + return bins; + } + + public static byte[] parseBytesToBinaryArray(List listValue) { + return parseBytesToBinaryArray(listValue, listValue.size() * BIN_LEN_MAX); } - private static byte isValidIntegerToByte (Integer val) { - if (val > 255 || val.intValue() < -128) { + public static byte[] parseBytesToBinaryArray(List listValue, int binLength) { + return parseBytesToBinaryArray(Bytes.toArray(listValue), binLength); + } + + public static byte[] parseBytesToBinaryArray(byte[] bytesValue) { + return parseLongToBinaryArray(parseBytesToLong(bytesValue), bytesValue.length * BIN_LEN_MAX); + } + + public static byte[] parseBytesToBinaryArray(byte[] bytesValue, int binLength) { + return parseLongToBinaryArray(parseBytesToLong(bytesValue), binLength); + } + + public static byte[] parseLongToBinaryArray(long longValue) { + return parseLongToBinaryArray(longValue, BYTES_LEN_LONG_MAX * BIN_LEN_MAX); + } + + /** + * 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). + */ + public static byte[] parseLongToBinaryArray(long longValue, int binsLength) { + int len = Math.min(binsLength, BYTES_LEN_LONG_MAX * BIN_LEN_MAX); + byte[] bins = new byte[len]; + for (int i = 0; i < len; i++) { + bins[len - 1 - i] = (byte) ((longValue >> i) & 1); + } + return bins; + } + + public static int parseBinaryArrayToInt(List listValue) { + return parseBinaryArrayToInt(listValue, 0); + } + + public static int parseBinaryArrayToInt(List listValue, int offset) { + return parseBinaryArrayToInt(listValue, offset, listValue.size()); + } + + public static int parseBinaryArrayToInt(List listValue, int offset, int length) { + return parseBinaryArrayToInt(Bytes.toArray(listValue), offset, length); + } + + public static int parseBinaryArrayToInt(byte[] bytesValue) { + return parseBinaryArrayToInt(bytesValue, 0); + } + + public static int parseBinaryArrayToInt(byte[] bytesValue, int offset) { + return parseBinaryArrayToInt(bytesValue, offset, bytesValue.length); + } + + public static int parseBinaryArrayToInt(byte[] bytesValue, int offset, int length) { + int result = 0; + int len = Math.min(length + offset, bytesValue.length); + for (int i = offset; i < len; i++) { + result = (result << 1) | (bytesValue[i] & 1); + } + + // For the one byte (8 bit) only If the most significant bit (sign) is set, we convert the result into a negative number + if ((bytesValue.length == BIN_LEN_MAX) + && offset == 0 && bytesValue[0] == 1) { + result -= (1 << (len - offset)); + } + return result; + } + + private static byte isValidIntegerToByte(Integer val) { + if (val > 255 || val < -128) { throw new NumberFormatException("The value '" + val + "' could not be correctly converted to a byte. " + "Integer to byte conversion requires the use of only 8 bits (with a range of min/max = -128/255)!"); } else { return val.byteValue(); } } + + private static String reverseHexStringByOrder(String value) { + if (value.startsWith("-")) { + throw new IllegalArgumentException("The hexadecimal string must be without a negative sign."); + } + boolean isHexPref = value.startsWith("0x"); + String hex = isHexPref ? value.substring(2) : value; + if (hex.length() % 2 > 0) { + throw new IllegalArgumentException("The hexadecimal string must be even-length."); + } + // Split the hex string into bytes (2 characters each) + StringBuilder reversedHex = new StringBuilder(BYTES_LEN_LONG_MAX); + for (int i = hex.length() - 2; i >= 0; i -= 2) { + reversedHex.append(hex, i, i + 2); + } + String result = reversedHex.toString(); + return isHexPref ? "0x" + result : result; + } + + private static void validationNumberByLength(byte[] data, int offset, int length, int bytesLenMax) { + if (offset > data.length) { + throw new IllegalArgumentException("Offset: " + offset + " is out of bounds for array with length: " + data.length + "!"); + } + + if (offset + length > data.length) { + throw new IllegalArgumentException("Offset: " + offset + " and Length: " + length + " is out of bounds for array with length: " + data.length + "!"); + } + + if (length > bytesLenMax) { + throw new IllegalArgumentException("Length: " + length + " is too large. Maximum " + bytesLenMax + " bytes is allowed!"); + } + } + + private static int validateLength(int dataLength, int offset, int bytesLenMax) { + return (dataLength < offset) ? dataLength : Math.min((dataLength - offset), bytesLenMax); + } + + private static String formatBinary(String binaryString) { + int format = binaryString.length() < 8 ? 8 : + binaryString.length() < 16 ? 16 : + binaryString.length() < 32 ? 32 : + binaryString.length() < 64 ? 64 : 0; + return format == 0 ? binaryString : String.format("%" + format + "s", binaryString).replace(' ', '0'); + } + + private static byte[] hexToBytes(String hex) { + byte [] data = new byte[hex.length()/2]; + for (int i = 0; i < hex.length(); i += 2) { + // Extract two characters from the hex string + String byteString = hex.substring(i, i + 2); + // Parse the hex string to a byte + byte byteValue = (byte) Integer.parseInt(byteString, HEX_RADIX); + // Add the byte to the ArrayList + data[i/2] = byteValue; + } + return data; + } } + diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java index b4e7e7411d..0c22f62f1f 100644 --- a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java @@ -15,12 +15,14 @@ */ package org.thingsboard.script.api.tbel; +import com.google.common.collect.Lists; import com.google.common.primitives.Bytes; +import com.google.common.primitives.Ints; import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.mvel2.ExecutionContext; import org.mvel2.ParserContext; import org.mvel2.SandboxedParserConfiguration; @@ -28,31 +30,32 @@ import org.mvel2.execution.ExecutionArrayList; import org.mvel2.execution.ExecutionHashMap; import java.io.IOException; +import java.math.BigDecimal; import java.math.BigInteger; import java.nio.ByteBuffer; import java.util.ArrayList; +import java.util.Arrays; import java.util.Calendar; +import java.util.Collections; import java.util.List; import java.util.Random; +import static java.lang.Character.MAX_RADIX; +import static java.lang.Character.MIN_RADIX; + @Slf4j public class TbUtilsTest { private ExecutionContext ctx; - private final String intValHex = "41EA62CC"; - private final float floatVal = 29.29824f; - private final String floatValStr = "29.29824"; + private final Float floatVal = 29.29824f; - - private final String floatValHexRev = "CC62EA41"; private final float floatValRev = -5.948442E7f; private final long longVal = 0x409B04B10CB295EAL; private final String longValHex = "409B04B10CB295EA"; - private final long longValRev = 0xEA95B20CB1049B40L; private final String longValHexRev = "EA95B20CB1049B40"; - private final String doubleValStr = "1729.1729"; + private final double doubleVal = 1729.1729; private final double doubleValRev = -2.7208640774822924E205; @@ -86,10 +89,9 @@ public class TbUtilsTest { Assertions.assertEquals(0xBAAB, TbUtils.parseHexToInt("ABBA", false)); Assertions.assertEquals(0xAABBCC, TbUtils.parseHexToInt("AABBCC", true)); Assertions.assertEquals(0xAABBCC, TbUtils.parseHexToInt("CCBBAA", false)); - Assertions.assertEquals(0xAABBCCDD, TbUtils.parseHexToInt("AABBCCDD", true)); - Assertions.assertEquals(0xAABBCCDD, TbUtils.parseHexToInt("DDCCBBAA", false)); - Assertions.assertEquals(0xDDCCBBAA, TbUtils.parseHexToInt("DDCCBBAA", true)); - Assertions.assertEquals(0xDDCCBBAA, TbUtils.parseHexToInt("AABBCCDD", false)); + Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseHexToInt("AABBCCDD", true)); + Assertions.assertEquals(0x11BBCC22, TbUtils.parseHexToInt("11BBCC22", true)); + Assertions.assertEquals(0x11BBCC22, TbUtils.parseHexToInt("22CCBB11", false)); } @Test @@ -208,15 +210,28 @@ public class TbUtilsTest { Assertions.assertNull(TbUtils.parseInt("")); Assertions.assertNull(TbUtils.parseInt(" ")); - Assertions.assertEquals(java.util.Optional.of(0).get(), TbUtils.parseInt("0")); - Assertions.assertEquals(java.util.Optional.of(0).get(), TbUtils.parseInt("-0")); + Assertions.assertEquals((Integer) 0, TbUtils.parseInt("0")); + Assertions.assertEquals((Integer) 0, TbUtils.parseInt("-0")); Assertions.assertEquals(java.util.Optional.of(473).get(), TbUtils.parseInt("473")); Assertions.assertEquals(java.util.Optional.of(-255).get(), TbUtils.parseInt("-0xFF")); - Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseInt("FF")); + Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseInt("-0xFF123")); + Assertions.assertEquals(java.util.Optional.of(-255).get(), TbUtils.parseInt("-FF")); + Assertions.assertEquals(java.util.Optional.of(255).get(), TbUtils.parseInt("FF")); + Assertions.assertThrows(IllegalArgumentException.class, () -> TbUtils.parseInt("FFF")); + Assertions.assertEquals(java.util.Optional.of(-2578).get(), TbUtils.parseInt("-0A12")); + Assertions.assertEquals(java.util.Optional.of(-2578).get(), TbUtils.parseHexToInt("-0A12")); + Assertions.assertThrows(IllegalArgumentException.class, () -> TbUtils.parseHexToInt("A12", false)); + Assertions.assertEquals(java.util.Optional.of(-14866).get(), TbUtils.parseBigEndianHexToInt("-3A12")); + Assertions.assertThrows(IllegalArgumentException.class, () -> TbUtils.parseLittleEndianHexToInt("-A12")); + Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseInt("0xFG")); - Assertions.assertEquals(java.util.Optional.of(102).get(), TbUtils.parseInt("1100110", 2)); - Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseInt("1100210", 2)); + Assertions.assertEquals(java.util.Optional.of(102).get(), TbUtils.parseInt("1100110", MIN_RADIX)); + Assertions.assertEquals(java.util.Optional.of(-102).get(), TbUtils.parseInt("1111111111111111111111111111111111111111111111111111111110011010", MIN_RADIX)); + Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseInt("1100210", MIN_RADIX)); + Assertions.assertEquals(java.util.Optional.of(13158).get(), TbUtils.parseInt("11001101100110", MIN_RADIX)); + Assertions.assertEquals(java.util.Optional.of(-13158).get(), TbUtils.parseInt("1111111111111111111111111111111111111111111111111100110010011010", MIN_RADIX)); + Assertions.assertEquals(java.util.Optional.of(63).get(), TbUtils.parseInt("77", 8)); Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseInt("18", 8)); @@ -224,19 +239,32 @@ public class TbUtilsTest { Assertions.assertEquals(java.util.Optional.of(-255).get(), TbUtils.parseInt("-FF", 16)); Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseInt("FG", 16)); - Assertions.assertEquals(java.util.Optional.of(Integer.MAX_VALUE).get(), TbUtils.parseInt(Integer.toString(Integer.MAX_VALUE), 10)); Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseInt(BigInteger.valueOf(Integer.MAX_VALUE).add(BigInteger.valueOf(1)).toString(10), 10)); Assertions.assertEquals(java.util.Optional.of(Integer.MIN_VALUE).get(), TbUtils.parseInt(Integer.toString(Integer.MIN_VALUE), 10)); Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseInt(BigInteger.valueOf(Integer.MIN_VALUE).subtract(BigInteger.valueOf(1)).toString(10), 10)); Assertions.assertEquals(java.util.Optional.of(506070563).get(), TbUtils.parseInt("KonaIn", 30)); + Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseInt("KonaIn", 10)); + Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseInt(".456", 10)); + Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseInt("4562.", 10)); + + Assertions.assertThrows(IllegalArgumentException.class, () -> TbUtils.parseInt("KonaIn", MAX_RADIX + 1)); + Assertions.assertThrows(IllegalArgumentException.class, () -> TbUtils.parseInt("KonaIn", MIN_RADIX - 1)); + Assertions.assertThrows(IllegalArgumentException.class, () -> TbUtils.parseInt("KonaIn", 12)); } @Test public void parseFloat() { + String floatValStr = floatVal.toString(); Assertions.assertEquals(java.util.Optional.of(floatVal).get(), TbUtils.parseFloat(floatValStr)); + String floatValHex = "41EA62CC"; + Assertions.assertEquals(0, Float.compare(floatVal, TbUtils.parseHexToFloat(floatValHex))); + Assertions.assertEquals(0, Float.compare(floatValRev, TbUtils.parseHexToFloat(floatValHex, false))); + Assertions.assertEquals(0, Float.compare(floatVal, TbUtils.parseBigEndianHexToFloat(floatValHex))); + String floatValHexRev = "CC62EA41"; + Assertions.assertEquals(0, Float.compare(floatVal, TbUtils.parseLittleEndianHexToFloat(floatValHexRev))); } @Test @@ -247,22 +275,113 @@ public class TbUtilsTest { } @Test - public void parseHexToFloat() { - Assertions.assertEquals(0, Float.compare(floatVal, TbUtils.parseHexToFloat(intValHex))); - Assertions.assertEquals(0, Float.compare(floatValRev, TbUtils.parseHexToFloat(intValHex, false))); - Assertions.assertEquals(0, Float.compare(floatVal, TbUtils.parseBigEndianHexToFloat(intValHex))); - Assertions.assertEquals(0, Float.compare(floatVal, TbUtils.parseLittleEndianHexToFloat(floatValHexRev))); + public void parseBytesToFloat() { + byte[] floatValByte = {0x0A}; + Assertions.assertEquals(0, Float.compare(1.4E-44f, TbUtils.parseBytesToFloat(floatValByte))); + + floatValByte = new byte[]{65, -22, 98, -52}; + Assertions.assertEquals(0, Float.compare(floatVal, TbUtils.parseBytesToFloat(floatValByte, 0))); + Assertions.assertEquals(0, Float.compare(floatValRev, TbUtils.parseBytesToFloat(floatValByte, 0, 4, false))); + + List floatValList = Bytes.asList(floatValByte); + Assertions.assertEquals(0, Float.compare(floatVal, TbUtils.parseBytesToFloat(floatValList, 0))); + Assertions.assertEquals(0, Float.compare(floatValRev, TbUtils.parseBytesToFloat(floatValList, 0, 4, false))); + + + // 1.1803216E8f == 0x4CE120E4 + floatValByte = new byte[]{0x4C, (byte) 0xE1, (byte) 0x20, (byte) 0xE4}; + float floatExpectedBe = 118.03216f; + float actualBe = TbUtils.parseBytesToFloat(floatValByte, 0, 4, true); + Assertions.assertEquals(0, Float.compare(floatExpectedBe, actualBe / 1000000)); + floatValList = Bytes.asList(floatValByte); + actualBe = TbUtils.parseBytesToFloat(floatValList, 0); + Assertions.assertEquals(0, Float.compare(floatExpectedBe, actualBe / 1000000)); + + float floatExpectedLe = 8.0821E-41f; + Assertions.assertEquals(0, Float.compare(floatExpectedLe, TbUtils.parseBytesToFloat(floatValByte, 0, 2, false))); + floatExpectedBe = 2.7579E-41f; + Assertions.assertEquals(0, Float.compare(floatExpectedBe, TbUtils.parseBytesToFloat(floatValByte, 0, 2))); + + + floatExpectedLe = 3.019557E-39f; + Assertions.assertEquals(0, Float.compare(floatExpectedLe, TbUtils.parseBytesToFloat(floatValList, 0, 3, false))); + + // 4 294 967 295L == {0xFF, 0xFF, 0xFF, 0xFF} + floatValByte = new byte[]{-1, -1, -1, -1}; + String message = "is a Not-a-Number (NaN) value"; + try { + TbUtils.parseBytesToFloat(floatValByte, 0, 4, true); + Assertions.fail("Should throw NumberFormatException"); + } catch (RuntimeException e) { + Assertions.assertTrue(e.getMessage().contains(message)); + } + + // "01752B0367FA000500010488 FFFFFFFF FFFFFFFF 33"; + String intToHexBe = "01752B0367FA000500010488FFFFFFFFFFFFFFFF33"; + floatValList = TbUtils.hexToBytes(ctx, intToHexBe); + try { + TbUtils.parseBytesToFloat(floatValList, 12, 4, false); + Assertions.fail("Should throw NumberFormatException"); + } catch (RuntimeException e) { + Assertions.assertTrue(e.getMessage().contains(message)); + } } @Test - public void arseBytesToFloat() { - byte[] floatValByte = {65, -22, 98, -52}; - Assertions.assertEquals(0, Float.compare(floatVal, TbUtils.parseBytesToFloat(floatValByte, 0))); - Assertions.assertEquals(0, Float.compare(floatValRev, TbUtils.parseBytesToFloat(floatValByte, 0, false))); + public void parseBytesIntToFloat() { + byte[] intValByte = {0x00, 0x00, 0x00, 0x0A}; + Float valueExpected = 10.0f; + Float valueActual = TbUtils.parseBytesIntToFloat(intValByte, 3, 1, true); + Assertions.assertEquals(valueExpected, valueActual); + valueActual = TbUtils.parseBytesIntToFloat(intValByte, 3, 1, false); + Assertions.assertEquals(valueExpected, valueActual); + + valueActual = TbUtils.parseBytesIntToFloat(intValByte, 2, 2, true); + Assertions.assertEquals(valueExpected, valueActual); + valueExpected = 2560.0f; + valueActual = TbUtils.parseBytesIntToFloat(intValByte, 2, 2, false); + Assertions.assertEquals(valueExpected, valueActual); + + valueExpected = 10.0f; + valueActual = TbUtils.parseBytesIntToFloat(intValByte, 0, 4, true); + Assertions.assertEquals(valueExpected, valueActual); + valueExpected = 1.6777216E8f; + valueActual = TbUtils.parseBytesIntToFloat(intValByte, 0, 4, false); + Assertions.assertEquals(valueExpected, valueActual); + + String dataAT101 = "0x01756403671B01048836BF7701F000090722050000"; + List byteAT101 = TbUtils.hexToBytes(ctx, dataAT101); + float latitudeExpected = 24.62495f; + int offset = 9; + valueActual = TbUtils.parseBytesIntToFloat(byteAT101, offset, 4, false); + Assertions.assertEquals(latitudeExpected, valueActual / 1000000); + + float longitudeExpected = 118.030576f; + valueActual = TbUtils.parseBytesIntToFloat(byteAT101, offset + 4, 4, false); + Assertions.assertEquals(longitudeExpected, valueActual / 1000000); + + valueExpected = 9.185175E8f; + valueActual = TbUtils.parseBytesIntToFloat(byteAT101, offset); + Assertions.assertEquals(valueExpected, valueActual); + // 0x36BF + valueExpected = 14015.0f; + valueActual = TbUtils.parseBytesIntToFloat(byteAT101, offset, 2); + Assertions.assertEquals(valueExpected, valueActual); + // 0xBF36 + valueExpected = 48950.0f; + valueActual = TbUtils.parseBytesIntToFloat(byteAT101, offset, 2, false); + Assertions.assertEquals(valueExpected, valueActual); + + valueExpected = 0.0f; + valueActual = TbUtils.parseBytesIntToFloat(byteAT101, byteAT101.size()); + Assertions.assertEquals(valueExpected, valueActual); - List floatVaList = Bytes.asList(floatValByte); - Assertions.assertEquals(0, Float.compare(floatVal, TbUtils.parseBytesToFloat(floatVaList, 0))); - Assertions.assertEquals(0, Float.compare(floatValRev, TbUtils.parseBytesToFloat(floatVaList, 0, false))); + try { + TbUtils.parseBytesIntToFloat(byteAT101, byteAT101.size() + 1); + Assertions.fail("Should throw NumberFormatException"); + } catch (RuntimeException e) { + Assertions.assertTrue(e.getMessage().contains("is out of bounds for array with length:")); + } } @Test @@ -271,15 +390,16 @@ public class TbUtilsTest { Assertions.assertNull(TbUtils.parseLong("")); Assertions.assertNull(TbUtils.parseLong(" ")); - Assertions.assertEquals(java.util.Optional.of(0L).get(), TbUtils.parseLong("0")); - Assertions.assertEquals(java.util.Optional.of(0L).get(), TbUtils.parseLong("-0")); + Assertions.assertEquals((Long) 0L, TbUtils.parseLong("0")); + Assertions.assertEquals((Long) 0L, TbUtils.parseLong("-0")); Assertions.assertEquals(java.util.Optional.of(473L).get(), TbUtils.parseLong("473")); Assertions.assertEquals(java.util.Optional.of(-65535L).get(), TbUtils.parseLong("-0xFFFF")); - Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseLong("FFFFFFFF")); + Assertions.assertEquals(java.util.Optional.of(4294967295L).get(), TbUtils.parseLong("FFFFFFFF")); Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseLong("0xFGFFFFFF")); - Assertions.assertEquals(java.util.Optional.of(13158L).get(), TbUtils.parseLong("11001101100110", 2)); - Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseLong("11001101100210", 2)); + Assertions.assertEquals(java.util.Optional.of(13158L).get(), TbUtils.parseLong("11001101100110", MIN_RADIX)); + Assertions.assertEquals(java.util.Optional.of(-13158L).get(), TbUtils.parseLong("1111111111111111111111111111111111111111111111111100110010011010", MIN_RADIX)); + Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseLong("11001101100210", MIN_RADIX)); Assertions.assertEquals(java.util.Optional.of(9223372036854775807L).get(), TbUtils.parseLong("777777777777777777777", 8)); Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseLong("1787", 8)); @@ -295,10 +415,7 @@ public class TbUtilsTest { Assertions.assertEquals(java.util.Optional.of(218840926543L).get(), TbUtils.parseLong("KonaLong", 27)); Assertions.assertThrows(NumberFormatException.class, () -> TbUtils.parseLong("KonaLong", 10)); - } - @Test - public void parseHexToLong() { Assertions.assertEquals(longVal, TbUtils.parseHexToLong(longValHex)); Assertions.assertEquals(longVal, TbUtils.parseHexToLong(longValHexRev, false)); Assertions.assertEquals(longVal, TbUtils.parseBigEndianHexToLong(longValHex)); @@ -312,14 +429,20 @@ public class TbUtilsTest { Bytes.reverse(longValByte); Assertions.assertEquals(longVal, TbUtils.parseBytesToLong(longValByte, 0, 8, false)); - List longVaList = Bytes.asList(longValByte); + List longVaList = Bytes.asList(longValByte); Assertions.assertEquals(longVal, TbUtils.parseBytesToLong(longVaList, 0, 8, false)); + long longValRev = 0xEA95B20CB1049B40L; Assertions.assertEquals(longValRev, TbUtils.parseBytesToLong(longVaList, 0, 8)); } @Test public void parsDouble() { + String doubleValStr = "1729.1729"; Assertions.assertEquals(java.util.Optional.of(doubleVal).get(), TbUtils.parseDouble(doubleValStr)); + Assertions.assertEquals(0, Double.compare(doubleVal, TbUtils.parseHexToDouble(longValHex))); + Assertions.assertEquals(0, Double.compare(doubleValRev, TbUtils.parseHexToDouble(longValHex, false))); + Assertions.assertEquals(0, Double.compare(doubleVal, TbUtils.parseBigEndianHexToDouble(longValHex))); + Assertions.assertEquals(0, Double.compare(doubleVal, TbUtils.parseLittleEndianHexToDouble(longValHexRev))); } @Test @@ -329,23 +452,74 @@ public class TbUtilsTest { Assertions.assertEquals(0, Double.compare(1729.173, actualD)); } - @Test - public void parseHexToDouble() { - Assertions.assertEquals(0, Double.compare(doubleVal, TbUtils.parseHexToDouble(longValHex))); - Assertions.assertEquals(0, Double.compare(doubleValRev, TbUtils.parseHexToDouble(longValHex, false))); - Assertions.assertEquals(0, Double.compare(doubleVal, TbUtils.parseBigEndianHexToDouble(longValHex))); - Assertions.assertEquals(0, Double.compare(doubleVal, TbUtils.parseLittleEndianHexToDouble(longValHexRev))); - } - @Test public void parseBytesToDouble() { - byte[] doubleValByte = {64, -101, 4, -79, 12, -78, -107, -22}; + byte[] doubleValByte = {0x0A}; + Assertions.assertEquals(0, Double.compare(4.9E-323, TbUtils.parseBytesToDouble(doubleValByte))); + + doubleValByte = new byte[]{64, -101, 4, -79, 12, -78, -107, -22}; Assertions.assertEquals(0, Double.compare(doubleVal, TbUtils.parseBytesToDouble(doubleValByte, 0))); - Assertions.assertEquals(0, Double.compare(doubleValRev, TbUtils.parseBytesToDouble(doubleValByte, 0, false))); + Assertions.assertEquals(0, Double.compare(doubleValRev, TbUtils.parseBytesToDouble(doubleValByte, 0, 8, false))); + + List doubleValList = Bytes.asList(doubleValByte); + Assertions.assertEquals(0, Double.compare(doubleVal, TbUtils.parseBytesToDouble(doubleValList, 0))); + Assertions.assertEquals(0, Double.compare(doubleValRev, TbUtils.parseBytesToDouble(doubleValList, 0, 8, false))); + + doubleValByte = new byte[]{0x7F, (byte) 0xC0, (byte) 0xFF, 0x00, 0x7F, (byte) 0xC0, (byte) 0xFF, 0x00}; + double doubleExpectedBe = 2387013.651780523d; + double doubleExpectedLe = 7.234601680440024E-304d; + double actualBe = TbUtils.parseBytesToDouble(doubleValByte, 0, 8, true); + BigDecimal bigDecimal = new BigDecimal(actualBe); + // We move the decimal point to the left by 301 positions + actualBe = bigDecimal.movePointLeft(301).doubleValue(); + Assertions.assertEquals(0, Double.compare(doubleExpectedBe, actualBe)); + Assertions.assertEquals(0, Double.compare(doubleExpectedLe, TbUtils.parseBytesToDouble(doubleValByte, 0, 8, false))); + + doubleValList = Bytes.asList(doubleValByte); + actualBe = TbUtils.parseBytesToDouble(doubleValList, 0); + bigDecimal = new BigDecimal(actualBe); + actualBe = bigDecimal.movePointLeft(301).doubleValue(); + Assertions.assertEquals(0, Double.compare(doubleExpectedBe, actualBe)); + doubleExpectedLe = 26950.174646662283d; + double actualLe = TbUtils.parseBytesToDouble(doubleValList, 0, 5, false); + bigDecimal = new BigDecimal(actualLe); + actualLe = bigDecimal.movePointRight(316).doubleValue(); + Assertions.assertEquals(0, Double.compare(doubleExpectedLe, actualLe)); + + // 4 294 967 295L == {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF} + doubleValByte = new byte[]{-1, -1, -1, -1, -1, -1, -1, -1}; + String message = "is a Not-a-Number (NaN) value"; + try { + TbUtils.parseBytesToDouble(doubleValByte, 0, 8, true); + Assertions.fail("Should throw NumberFormatException"); + } catch (RuntimeException e) { + Assertions.assertTrue(e.getMessage().contains(message)); + } + } - List doubleVaList = Bytes.asList(doubleValByte); - Assertions.assertEquals(0, Double.compare(doubleVal, TbUtils.parseBytesToDouble(doubleVaList, 0))); - Assertions.assertEquals(0, Double.compare(doubleValRev, TbUtils.parseBytesToDouble(doubleVaList, 0, false))); + @Test + public void parseBytesLongToDouble() { + byte[] longValByte = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0A}; + Double valueExpected = 10.0d; + Double valueActual = TbUtils.parseBytesLongToDouble(longValByte); + Assertions.assertEquals(valueExpected, valueActual); + valueActual = TbUtils.parseBytesLongToDouble(longValByte, 7, 1, true); + Assertions.assertEquals(valueExpected, valueActual); + valueActual = TbUtils.parseBytesLongToDouble(longValByte, 7, 1, false); + Assertions.assertEquals(valueExpected, valueActual); + + valueActual = TbUtils.parseBytesLongToDouble(longValByte, 6, 2, true); + Assertions.assertEquals(valueExpected, valueActual); + valueExpected = 2560.0d; + valueActual = TbUtils.parseBytesLongToDouble(longValByte, 6, 2, false); + Assertions.assertEquals(valueExpected, valueActual); + + valueExpected = 10.0d; + valueActual = TbUtils.parseBytesLongToDouble(longValByte, 0, 8, true); + Assertions.assertEquals(valueExpected, valueActual); + valueExpected = 7.2057594037927936E17d; + valueActual = TbUtils.parseBytesLongToDouble(longValByte, 0, 8, false); + Assertions.assertEquals(valueExpected, valueActual); } @Test @@ -354,16 +528,17 @@ public class TbUtilsTest { ExecutionHashMap expectedJson = new ExecutionHashMap<>(1, ctx); expectedJson.put("hello", "world"); List expectedBytes = TbUtils.stringToBytes(ctx, expectedStr); - Object actualJson = TbUtils.decodeToJson(ctx, expectedBytes); - Assertions.assertEquals(expectedJson,actualJson); + Object actualJson = TbUtils.decodeToJson(ctx, expectedBytes); + Assertions.assertEquals(expectedJson, actualJson); } + @Test public void parseStringDecodeToJson() throws IOException { String expectedStr = "{\"hello\": \"world\"}"; ExecutionHashMap expectedJson = new ExecutionHashMap<>(1, ctx); expectedJson.put("hello", "world"); - Object actualJson = TbUtils.decodeToJson(ctx, expectedStr); - Assertions.assertEquals(expectedJson,actualJson); + Object actualJson = TbUtils.decodeToJson(ctx, expectedStr); + Assertions.assertEquals(expectedJson, actualJson); } @Test @@ -386,8 +561,8 @@ public class TbUtilsTest { @Test public void bytesFromList() { - byte[] arrayBytes = {(byte)0x00, (byte)0x08, (byte)0x10, (byte)0x1C, (byte)0xFF, (byte)0xFC, (byte)0xAD, (byte)0x88, (byte)0x75, (byte)0x74, (byte)0x8A, (byte)0x82}; - Object[] arrayMix = { "0x00", 8, "16", "0x1C", 255, (byte)0xFC, 173, 136, 117, 116, -118, "-126"}; + byte[] arrayBytes = {(byte) 0x00, (byte) 0x08, (byte) 0x10, (byte) 0x1C, (byte) 0xFF, (byte) 0xFC, (byte) 0xAD, (byte) 0x88, (byte) 0x75, (byte) 0x74, (byte) 0x8A, (byte) 0x82}; + Object[] arrayMix = {"0x00", 8, "16", "0x1C", 255, (byte) 0xFC, 173, 136, 117, 116, -118, "-126"}; String expected = new String(arrayBytes); ArrayList listBytes = new ArrayList<>(arrayBytes.length); @@ -397,12 +572,36 @@ public class TbUtilsTest { Assertions.assertEquals(expected, TbUtils.bytesToString(listBytes)); ArrayList listMix = new ArrayList<>(arrayMix.length); - for (Object element : arrayMix) { - listMix.add(element); - } + Collections.addAll(listMix, arrayMix); Assertions.assertEquals(expected, TbUtils.bytesToString(listMix)); } + @Test + public void bytesFromList_SpecSymbol() { + List listHex = new ArrayList<>(Arrays.asList("1D", "0x1D", "1F", "0x1F", "0x20", "0x20")); + byte[] expectedBytes = new byte[]{29, 29, 31, 31, 32, 32}; + String actualStr = TbUtils.bytesToString(listHex); + byte[] actualBytes = actualStr.getBytes(); + Assertions.assertArrayEquals(expectedBytes, actualBytes); + Assertions.assertTrue(actualStr.isBlank()); + listHex = new ArrayList<>(Arrays.asList("0x21", "0x21")); + expectedBytes = new byte[]{33, 33}; + actualStr = TbUtils.bytesToString(listHex); + actualBytes = actualStr.getBytes(); + Assertions.assertArrayEquals(expectedBytes, actualBytes); + Assertions.assertFalse(actualStr.isBlank()); + Assertions.assertEquals("!!", actualStr); + listHex = new ArrayList<>(Arrays.asList("21", "0x21")); + expectedBytes = new byte[]{21, 33}; + actualStr = TbUtils.bytesToString(listHex); + actualBytes = actualStr.getBytes(); + Assertions.assertArrayEquals(expectedBytes, actualBytes); + Assertions.assertFalse(actualStr.isBlank()); + Assertions.assertEquals("!", actualStr.substring(1)); + Assertions.assertEquals('\u0015', actualStr.charAt(0)); + Assertions.assertEquals(21, actualStr.charAt(0)); + } + @Test public void bytesFromList_Error() { List listHex = new ArrayList<>(); @@ -411,14 +610,7 @@ public class TbUtilsTest { TbUtils.bytesToString(listHex); Assertions.fail("Should throw NumberFormatException"); } catch (NumberFormatException e) { - Assertions.assertTrue(e.getMessage().contains("Failed radix: [16] for value: \"FG\"!")); - } - listHex.add(0, "1F"); - try { - TbUtils.bytesToString(listHex); - Assertions.fail("Should throw NumberFormatException"); - } catch (NumberFormatException e) { - Assertions.assertTrue(e.getMessage().contains("Failed radix: [10] for value: \"1F\"!")); + Assertions.assertTrue(e.getMessage().contains("Value: \"FG\" is not numeric or hexDecimal format!")); } List listIntString = new ArrayList<>(); @@ -482,6 +674,400 @@ public class TbUtilsTest { String uriDecodeActual = TbUtils.decodeURI(uriEncodeActual); Assertions.assertEquals(uriOriginal, uriDecodeActual); } + + @Test + public void intToHex_Test() { + Assertions.assertEquals("FFF5EE", TbUtils.intToHex(-2578)); + Assertions.assertEquals("0xFFD8FFA6", TbUtils.intToHex(0xFFD8FFA6, true, true)); + Assertions.assertEquals("0xA6FFD8FF", TbUtils.intToHex(0xFFD8FFA6, false, true)); + Assertions.assertEquals("0x7FFFFFFF", TbUtils.intToHex(Integer.MAX_VALUE, true, true)); + Assertions.assertEquals("0x80000000", TbUtils.intToHex(Integer.MIN_VALUE, true, true)); + Assertions.assertEquals("0xAB", TbUtils.intToHex(0xAB, true, true)); + Assertions.assertEquals("0xABCD", TbUtils.intToHex(0xABCD, true, true)); + Assertions.assertEquals("0xABCDEF", TbUtils.intToHex(0xABCDEF, true, true)); + Assertions.assertEquals("0xCDAB", TbUtils.intToHex(0xABCDEF, false, true, 4)); + Assertions.assertEquals("0xAB", TbUtils.intToHex(171, true, true)); + Assertions.assertEquals("0xAB", TbUtils.intToHex(0xAB, false, true)); + Assertions.assertEquals("0xAB", TbUtils.intToHex(0xAB, true, true, 2)); + Assertions.assertEquals("AB", TbUtils.intToHex(0xAB, false, false, 2)); + Assertions.assertEquals("AB", TbUtils.intToHex(171, true, false)); + Assertions.assertEquals("0xAB", TbUtils.intToHex(0xAB, true, true)); + Assertions.assertEquals("0xAB", TbUtils.intToHex(0xAB, false, true)); + Assertions.assertEquals("AB", TbUtils.intToHex(0xAB, false, false)); + + Assertions.assertEquals("0xABCD", TbUtils.intToHex(0xABCD, true, true)); + Assertions.assertEquals("0xCDAB", TbUtils.intToHex(0xABCD, false, true)); + Assertions.assertEquals("0xCD", TbUtils.intToHex(0xABCD, true, true, 2)); + Assertions.assertEquals("AB", TbUtils.intToHex(0xABCD, false, false, 2)); + } + + @Test + public void longToHex_Test() { + Assertions.assertEquals("0x7FFFFFFFFFFFFFFF", TbUtils.longToHex(Long.MAX_VALUE, true, true)); + Assertions.assertEquals("0x8000000000000000", TbUtils.longToHex(Long.MIN_VALUE, true, true)); + Assertions.assertEquals("0xFFD8FFA6FFD8FFA6", TbUtils.longToHex(0xFFD8FFA6FFD8FFA6L, true, true)); + Assertions.assertEquals("0xA6FFD8FFA6FFCEFF", TbUtils.longToHex(0xFFCEFFA6FFD8FFA6L, false, true)); + Assertions.assertEquals("0xAB", TbUtils.longToHex(0xABL, true, true)); + Assertions.assertEquals("0xABCD", TbUtils.longToHex(0xABCDL, true, true)); + Assertions.assertEquals("0xABCDEF", TbUtils.longToHex(0xABCDEFL, true, true)); + Assertions.assertEquals("0xABEFCDAB", TbUtils.longToHex(0xABCDEFABCDEFL, false, true, 8)); + Assertions.assertEquals("0xAB", TbUtils.longToHex(0xABL, true, true, 2)); + Assertions.assertEquals("AB", TbUtils.longToHex(0xABL, false, false, 2)); + + Assertions.assertEquals("0xFFA6", TbUtils.longToHex(0xFFD8FFA6FFD8FFA6L, true, true, 4)); + Assertions.assertEquals("D8FF", TbUtils.longToHex(0xFFD8FFA6FFD8FFA6L, false, false, 4)); + } + + @Test + public void numberToString_Test() { + Assertions.assertEquals("00111010", TbUtils.intLongToString(58L, MIN_RADIX)); + Assertions.assertEquals("0000000010011110", TbUtils.intLongToString(158L, MIN_RADIX)); + Assertions.assertEquals("00000000000000100000001000000001", TbUtils.intLongToString(131585L, MIN_RADIX)); + Assertions.assertEquals("0111111111111111111111111111111111111111111111111111111111111111", TbUtils.intLongToString(Long.MAX_VALUE, MIN_RADIX)); + Assertions.assertEquals("1000000000000000000000000000000000000000000000000000000000000001", TbUtils.intLongToString(-Long.MAX_VALUE, MIN_RADIX)); + Assertions.assertEquals("1111111111111111111111111111111111111111111111111111111110011010", TbUtils.intLongToString(-102L, MIN_RADIX)); + Assertions.assertEquals("1111111111111111111111111111111111111111111111111100110010011010", TbUtils.intLongToString(-13158L, MIN_RADIX)); + Assertions.assertEquals("777777777777777777777", TbUtils.intLongToString(Long.MAX_VALUE, 8)); + Assertions.assertEquals("1000000000000000000000", TbUtils.intLongToString(Long.MIN_VALUE, 8)); + Assertions.assertEquals("9223372036854775807", TbUtils.intLongToString(Long.MAX_VALUE)); + Assertions.assertEquals("-9223372036854775808", TbUtils.intLongToString(Long.MIN_VALUE)); + Assertions.assertEquals("3366", TbUtils.intLongToString(13158L, 16)); + Assertions.assertEquals("FFCC9A", TbUtils.intLongToString(-13158L, 16)); + Assertions.assertEquals("0xFFCC9A", TbUtils.intLongToString(-13158L, 16, true, true)); + + Assertions.assertEquals("0x0400", TbUtils.intLongToString(1024L, 16, true, true)); + Assertions.assertNotEquals("400", TbUtils.intLongToString(1024L, 16)); + Assertions.assertEquals("0xFFFC00", TbUtils.intLongToString(-1024L, 16, true, true)); + Assertions.assertNotEquals("0xFC00", TbUtils.intLongToString(-1024L, 16, true, true)); + + Assertions.assertEquals("hazelnut", TbUtils.intLongToString(1356099454469L, MAX_RADIX)); + } + + @Test + public void intToHexWithPrintUnsignedBytes_Test() { + Integer value = -40; + String intToHexLe = TbUtils.intToHex(value, false, true); + String intToHexBe = TbUtils.intToHex(value, true, true); + + List hexTopByteLe = TbUtils.hexToBytes(ctx, intToHexLe); + List hexTopByteBe = TbUtils.hexToBytes(ctx, intToHexBe); + + byte[] arrayBytes = {-40, -1}; + List expectedHexTopByteLe = Bytes.asList(arrayBytes); + List expectedHexTopByteBe = Lists.reverse(expectedHexTopByteLe); + Assertions.assertEquals(expectedHexTopByteLe, hexTopByteLe); + Assertions.assertEquals(expectedHexTopByteBe, hexTopByteBe); + + List actualLe = TbUtils.printUnsignedBytes(ctx, hexTopByteLe); + List actualBe = TbUtils.printUnsignedBytes(ctx, hexTopByteBe); + List expectedLe = Ints.asList(216, 255); + List expectedBe = Lists.reverse(expectedLe); + Assertions.assertEquals(expectedLe, actualLe); + Assertions.assertEquals(expectedBe, actualBe); + } + + @Test + public void hexToBytes_Test() { + String input = "0x01752B0367FA000500010488FFFFFFFFFFFFFFFF33"; + byte[] expected = {1, 117, 43, 3, 103, -6, 0, 5, 0, 1, 4, -120, -1, -1, -1, -1, -1, -1, -1, -1, 51}; + List actual = TbUtils.hexToBytes(ctx, input); + Assertions.assertEquals(toList(expected), actual); + try { + input = "0x01752B0367FA000500010488FFFFFFFFFFFFFFFF3"; + TbUtils.hexToBytes(ctx, input); + } catch (IllegalArgumentException e) { + Assertions.assertTrue(e.getMessage().contains("Hex string must be even-length.")); + } + try { + input = "0x01752B0367KA000500010488FFFFFFFFFFFFFFFF33"; + TbUtils.hexToBytes(ctx, input); + } catch (NumberFormatException e) { + Assertions.assertTrue(e.getMessage().contains("Value: \"" + input + "\" is not numeric or hexDecimal format!")); + } + try { + input = ""; + TbUtils.hexToBytes(ctx, input); + } catch (IllegalArgumentException e) { + Assertions.assertTrue(e.getMessage().contains("Hex string must be not empty")); + } + } + + @Test + public void floatToHex_Test() { + Float value = 123456789.00f; + String expectedHex = "0x4CEB79A3"; + String valueHexRev = "0xA379EB4C"; + String actual = TbUtils.floatToHex(value); + Assertions.assertEquals(expectedHex, actual); + Float valueActual = TbUtils.parseHexToFloat(actual); + Assertions.assertEquals(value, valueActual); + valueActual = TbUtils.parseHexToFloat(valueHexRev, false); + Assertions.assertEquals(value, valueActual); + value = 123456789.67f; + expectedHex = "0x4CEB79A3"; + valueHexRev = "0xA379EB4C"; + actual = TbUtils.floatToHex(value); + Assertions.assertEquals(expectedHex, actual); + valueActual = TbUtils.parseHexToFloat(actual); + Assertions.assertEquals(value, valueActual); + valueActual = TbUtils.parseHexToFloat(valueHexRev, false); + Assertions.assertEquals(value, valueActual); + value = 10.0f; + expectedHex = "0x41200000"; + valueHexRev = "0x00002041"; + actual = TbUtils.floatToHex(value); + Assertions.assertEquals(expectedHex, actual); + valueActual = TbUtils.parseHexToFloat(actual); + Assertions.assertEquals(value, valueActual); + valueActual = TbUtils.parseHexToFloat(valueHexRev, false); + Assertions.assertEquals(value, valueActual); + + String valueHex = "0x0000000A"; + float expectedValue = 1.4E-44f; + valueActual = TbUtils.parseHexToFloat(valueHex); + Assertions.assertEquals(expectedValue, valueActual); + actual = TbUtils.floatToHex(expectedValue); + Assertions.assertEquals(valueHex, actual); + } + + // If the length is not equal to 8 characters, we process it as an integer (eg "0x0A" for 10.0f). + @Test + public void parseHexIntLongToFloat_Test() { + Float valueExpected = 10.0f; + Float valueActual = TbUtils.parseHexIntLongToFloat("0x0A", true); + Assertions.assertEquals(valueExpected, valueActual); + valueActual = TbUtils.parseHexIntLongToFloat("0x0A", false); + Assertions.assertEquals(valueExpected, valueActual); + valueActual = TbUtils.parseHexIntLongToFloat("0x00000A", true); + Assertions.assertEquals(valueExpected, valueActual); + valueActual = TbUtils.parseHexIntLongToFloat("0x0A0000", false); + Assertions.assertEquals(valueExpected, valueActual); + valueExpected = 2570.0f; + valueActual = TbUtils.parseHexIntLongToFloat("0x000A0A", true); + Assertions.assertEquals(valueExpected, valueActual); + valueActual = TbUtils.parseHexIntLongToFloat("0x0A0A00", false); + Assertions.assertEquals(valueExpected, valueActual); + } + + @Test + public void doubleToHex_Test() { + String expectedHex = "0x409B04B10CB295EA"; + String actual = TbUtils.doubleToHex(doubleVal); + Assertions.assertEquals(expectedHex, actual); + Double valueActual = TbUtils.parseHexToDouble(actual); + Assertions.assertEquals(doubleVal, valueActual); + actual = TbUtils.doubleToHex(doubleVal, false); + String expectedHexRev = "0xEA95B20CB1049B40"; + Assertions.assertEquals(expectedHexRev, actual); + + String valueHex = "0x000000000000000A"; + Double expectedValue = 4.9E-323; + valueActual = TbUtils.parseHexToDouble(valueHex); + Assertions.assertEquals(expectedValue, valueActual); + actual = TbUtils.doubleToHex(expectedValue); + Assertions.assertEquals(valueHex, actual); + } + + @Test + public void raiseError_Test() { + String message = "frequency_weighting_type must be 0, 1 or 2."; + Object value = 4; + try { + TbUtils.raiseError(message, value); + Assertions.fail("Should throw NumberFormatException"); + } catch (RuntimeException e) { + Assertions.assertTrue(e.getMessage().contains("frequency_weighting_type must be 0, 1 or 2. for value 4")); + } + try { + TbUtils.raiseError(message); + Assertions.fail("Should throw NumberFormatException"); + } catch (RuntimeException e) { + Assertions.assertTrue(e.getMessage().contains("frequency_weighting_type must be 0, 1 or 2.")); + } + } + + @Test + public void isBinary_Test() { + Assertions.assertEquals(2, TbUtils.isBinary("1100110")); + Assertions.assertEquals(-1, TbUtils.isBinary("2100110")); + } + + @Test + public void isOctal_Test() { + Assertions.assertEquals(8, TbUtils.isOctal("4567734")); + Assertions.assertEquals(-1, TbUtils.isOctal("8100110")); + } + + @Test + public void isDecimal_Test() { + Assertions.assertEquals(10, TbUtils.isDecimal("4567039")); + Assertions.assertEquals(-1, TbUtils.isDecimal("C100110")); + } + + @Test + public void isHexadecimal_Test() { + Assertions.assertEquals(16, TbUtils.isHexadecimal("F5D7039")); + Assertions.assertEquals(-1, TbUtils.isHexadecimal("K100110")); + } + + @Test + public void padStart_Test() { + String binaryString = "010011"; + String expected = "00010011"; + Assertions.assertEquals(expected, TbUtils.padStart(binaryString, 8, '0')); + binaryString = "1001010011"; + expected = "1001010011"; + Assertions.assertEquals(expected, TbUtils.padStart(binaryString, 8, '0')); + binaryString = "1001010011"; + expected = "******1001010011"; + Assertions.assertEquals(expected, TbUtils.padStart(binaryString, 16, '*')); + String fullNumber = "203439900FFCD5581"; + String last4Digits = fullNumber.substring(11); + expected = "***********CD5581"; + Assertions.assertEquals(expected, TbUtils.padStart(last4Digits, fullNumber.length(), '*')); + } + + @Test + public void padEnd_Test() { + String binaryString = "010011"; + String expected = "01001100"; + Assertions.assertEquals(expected, TbUtils.padEnd(binaryString, 8, '0')); + binaryString = "1001010011"; + expected = "1001010011"; + Assertions.assertEquals(expected, TbUtils.padEnd(binaryString, 8, '0')); + binaryString = "1001010011"; + expected = "1001010011******"; + Assertions.assertEquals(expected, TbUtils.padEnd(binaryString, 16, '*')); + String fullNumber = "203439900FFCD5581"; + String last4Digits = fullNumber.substring(0, 11); + expected = "203439900FF******"; + Assertions.assertEquals(expected, TbUtils.padEnd(last4Digits, fullNumber.length(), '*')); + } + + @Test + public void parseByteToBinaryArray_Test() { + byte byteVal = 0x39; + byte[] bitArray = {0, 0, 1, 1, 1, 0, 0, 1}; + List expected = toList(bitArray); + byte[] actualArray = TbUtils.parseByteToBinaryArray(byteVal); + List actual = toList(actualArray); + Assertions.assertEquals(expected, actual); + + bitArray = new byte[]{1, 1, 1, 0, 0, 1}; + expected = toList(bitArray); + actualArray = TbUtils.parseByteToBinaryArray(byteVal, bitArray.length); + actual = toList(actualArray); + Assertions.assertEquals(expected, actual); + + bitArray = new byte[]{1, 0, 0, 1, 1, 1}; + expected = toList(bitArray); + actualArray = TbUtils.parseByteToBinaryArray(byteVal, bitArray.length, false); + actual = toList(actualArray); + Assertions.assertEquals(expected, actual); + } + + @Test + public void parseBytesToBinaryArray_Test() { + long longValue = 57L; + byte[] bytesValue = new byte[]{0x39}; // 57 + List listValue = toList(bytesValue); + byte[] bitArray = {0, 0, 1, 1, 1, 0, 0, 1}; + List expected = toList(bitArray); + byte[] actualArray = TbUtils.parseBytesToBinaryArray(listValue); + List actual = toList(actualArray); + Assertions.assertEquals(expected, actual); + + actualArray = TbUtils.parseLongToBinaryArray(longValue, listValue.size() * 8); + actual = toList(actualArray); + Assertions.assertEquals(expected, actual); + + bitArray = new byte[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1}; + expected = toList(bitArray); + actualArray = TbUtils.parseLongToBinaryArray(longValue); + actual = toList(actualArray); + Assertions.assertEquals(expected, actual); + + longValue = 52914L; + bytesValue = new byte[]{(byte) 0xCE, (byte) 0xB2}; // 52914 + listValue = toList(bytesValue); + bitArray = new byte[]{1, 1, 0, 0, 1, 1, 1, 0, 1, 0, 1, 1, 0, 0, 1, 0}; + expected = toList(bitArray); + actualArray = TbUtils.parseBytesToBinaryArray(listValue); + actual = toList(actualArray); + Assertions.assertEquals(expected, actual); + + actualArray = TbUtils.parseLongToBinaryArray(longValue, listValue.size() * 8); + actual = toList(actualArray); + Assertions.assertEquals(expected, actual); + + bitArray = new byte[]{0, 0, 1, 0}; + expected = toList(bitArray); + actualArray = TbUtils.parseLongToBinaryArray(longValue, 4); + actual = toList(actualArray); + Assertions.assertEquals(expected, actual); + + bitArray = new byte[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 1, 0, 1, 1, 0, 0, 1, 0}; + expected = toList(bitArray); + actualArray = TbUtils.parseLongToBinaryArray(longValue); + actual = toList(actualArray); + Assertions.assertEquals(expected, actual); + } + + @Test + public void parseBinaryArrayToInt_Test() { + byte byteVal = (byte) 0x9F; + int expectedVolt = 31; + byte[] actualArray = TbUtils.parseByteToBinaryArray(byteVal); + int actual = TbUtils.parseBinaryArrayToInt(actualArray); + Assertions.assertEquals(byteVal, actual); + int actualVolt = TbUtils.parseBinaryArrayToInt(actualArray, 1, 7); + Assertions.assertEquals(expectedVolt, actualVolt); + boolean expectedLowVoltage = true; + boolean actualLowVoltage = actualArray[7] == 1; + Assertions.assertEquals(expectedLowVoltage, actualLowVoltage); + + byteVal = (byte) 0x39; + actualArray = TbUtils.parseByteToBinaryArray(byteVal, 6, false); + int expectedLowCurrent1Alarm = 1; // 0x39 = 00 11 10 01 (Bin0): 0 == 1 + int expectedHighCurrent1Alarm = 0; // 0x39 = 00 11 10 01 (Bin1): 0 == 0 + int expectedLowCurrent2Alarm = 0; // 0x39 = 00 11 10 01 (Bin2): 0 == 0 + int expectedHighCurrent2Alarm = 1; // 0x39 = 00 11 10 01 (Bin3): 0 == 1 + int expectedLowCurrent3Alarm = 1; // 0x39 = 00 11 10 01 (Bin4): 0 == 1 + int expectedHighCurrent3Alarm = 1; // 0x39 = 00 11 10 01 (Bin5): 0 == 1 + int actualLowCurrent1Alarm = actualArray[0]; + int actualHighCurrent1Alarm = actualArray[1]; + int actualLowCurrent2Alarm = actualArray[2]; + int actualHighCurrent2Alarm = actualArray[3]; + int actualLowCurrent3Alarm = actualArray[4]; + int actualHighCurrent3Alarm = actualArray[5]; + Assertions.assertEquals(expectedLowCurrent1Alarm, actualLowCurrent1Alarm); + Assertions.assertEquals(expectedHighCurrent1Alarm, actualHighCurrent1Alarm); + Assertions.assertEquals(expectedLowCurrent2Alarm, actualLowCurrent2Alarm); + Assertions.assertEquals(expectedHighCurrent2Alarm, actualHighCurrent2Alarm); + Assertions.assertEquals(expectedLowCurrent3Alarm, actualLowCurrent3Alarm); + Assertions.assertEquals(expectedHighCurrent3Alarm, actualHighCurrent3Alarm); + + byteVal = (byte) 0x36; + actualArray = TbUtils.parseByteToBinaryArray(byteVal); + int expectedMultiplier1 = 2; + int expectedMultiplier2 = 1; + int expectedMultiplier3 = 3; + int actualMultiplier1 = TbUtils.parseBinaryArrayToInt(actualArray, 6, 2); + int actualMultiplier2 = TbUtils.parseBinaryArrayToInt(actualArray, 4, 2); + int actualMultiplier3 = TbUtils.parseBinaryArrayToInt(actualArray, 2, 2); + Assertions.assertEquals(expectedMultiplier1, actualMultiplier1); + Assertions.assertEquals(expectedMultiplier2, actualMultiplier2); + Assertions.assertEquals(expectedMultiplier3, actualMultiplier3); + } + + @Test + public void hexToBase64_Test() { + String hex = "014A000A02202007060000014A019F03E800C81B5801014A029F030A0000000000014A032405DD05D41B5836014A049F39000000000000"; + String expected = "AUoACgIgIAcGAAABSgGfA+gAyBtYAQFKAp8DCgAAAAAAAUoDJAXdBdQbWDYBSgSfOQAAAAAAAA=="; + String actual = TbUtils.hexToBase64(hex); + Assertions.assertEquals(expected, actual); + } + private static List toList(byte[] data) { List result = new ArrayList<>(data.length); for (Byte b : data) { diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java index 84625db73e..da9500b5ea 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java @@ -16,8 +16,8 @@ package org.thingsboard.server.transport.coap.adaptors; import org.eclipse.californium.core.coap.Request; -import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.adaptor.AdaptorException; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.gen.transport.TransportProtos; import java.util.Arrays; diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java index 50dadd8e87..72f66c13a6 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java @@ -27,10 +27,10 @@ import org.eclipse.californium.core.coap.MediaTypeRegistry; import org.eclipse.californium.core.coap.Request; import org.eclipse.californium.core.coap.Response; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.StringUtils; -import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.adaptor.AdaptorException; import org.thingsboard.server.common.adaptor.JsonConverter; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.transport.coap.CoapTransportResource; diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/ProtoCoapAdaptor.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/ProtoCoapAdaptor.java index 189620afa1..f7406345d1 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/ProtoCoapAdaptor.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/ProtoCoapAdaptor.java @@ -26,11 +26,11 @@ import org.eclipse.californium.core.coap.MediaTypeRegistry; import org.eclipse.californium.core.coap.Request; import org.eclipse.californium.core.coap.Response; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.StringUtils; -import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.adaptor.AdaptorException; import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.adaptor.ProtoConverter; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.transport.coap.CoapTransportResource; diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/CoapClientContext.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/CoapClientContext.java index f13e58b20b..e507e76716 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/CoapClientContext.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/CoapClientContext.java @@ -17,8 +17,8 @@ package org.thingsboard.server.transport.coap.client; import org.eclipse.californium.core.observe.ObserveRelation; import org.eclipse.californium.core.server.resources.CoapExchange; -import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.adaptor.AdaptorException; +import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.transport.coap.CoapSessionMsgType; diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java index 370c03c27f..1ba7c9aff1 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java @@ -28,21 +28,19 @@ import org.eclipse.californium.core.network.Exchange; import org.eclipse.californium.core.server.resources.CoapExchange; import org.eclipse.californium.core.server.resources.Resource; import org.springframework.util.CollectionUtils; +import org.thingsboard.server.common.adaptor.AdaptorException; import org.thingsboard.server.common.adaptor.ProtoConverter; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.device.profile.CoapDeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.device.profile.DeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.device.profile.EfentoCoapDeviceTypeConfiguration; -import org.thingsboard.server.common.adaptor.AdaptorException; -import org.thingsboard.server.common.adaptor.ProtoConverter; -import org.thingsboard.server.common.adaptor.AdaptorException; import org.thingsboard.server.common.transport.auth.SessionInfoCreator; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.coap.ConfigProtos; import org.thingsboard.server.gen.transport.coap.DeviceInfoProtos; -import org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos; import org.thingsboard.server.gen.transport.coap.MeasurementsProtos; +import org.thingsboard.server.gen.transport.coap.MeasurementsProtos.ProtoChannel; import org.thingsboard.server.transport.coap.AbstractCoapTransportResource; import org.thingsboard.server.transport.coap.CoapTransportContext; import org.thingsboard.server.transport.coap.callback.CoapDeviceAuthCallback; @@ -51,19 +49,21 @@ import org.thingsboard.server.transport.coap.efento.utils.CoapEfentoUtils; import java.nio.ByteBuffer; import java.text.SimpleDateFormat; -import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import static com.google.gson.JsonParser.parseString; import static org.thingsboard.server.transport.coap.CoapTransportService.CONFIGURATION; import static org.thingsboard.server.transport.coap.CoapTransportService.CURRENT_TIMESTAMP; import static org.thingsboard.server.transport.coap.CoapTransportService.DEVICE_INFO; import static org.thingsboard.server.transport.coap.CoapTransportService.MEASUREMENTS; +import static org.thingsboard.server.transport.coap.efento.utils.CoapEfentoUtils.isBinarySensor; +import static org.thingsboard.server.transport.coap.efento.utils.CoapEfentoUtils.isSensorError; @Slf4j public class CoapEfentoTransportResource extends AbstractCoapTransportResource { @@ -222,127 +222,186 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } } - private List getEfentoMeasurements(MeasurementsProtos.ProtoMeasurements protoMeasurements, UUID sessionId) { + List getEfentoMeasurements(MeasurementsProtos.ProtoMeasurements protoMeasurements, UUID sessionId) { String serialNumber = CoapEfentoUtils.convertByteArrayToString(protoMeasurements.getSerialNum().toByteArray()); boolean batteryStatus = protoMeasurements.getBatteryStatus(); int measurementPeriodBase = protoMeasurements.getMeasurementPeriodBase(); int measurementPeriodFactor = protoMeasurements.getMeasurementPeriodFactor(); int signal = protoMeasurements.getSignal(); - List channelsList = protoMeasurements.getChannelsList(); + long nextTransmissionAtMillis = TimeUnit.SECONDS.toMillis(protoMeasurements.getNextTransmissionAt()); + + List channelsList = protoMeasurements.getChannelsList(); + if (CollectionUtils.isEmpty(channelsList)) { + throw new IllegalStateException("[" + sessionId + "]: Failed to get Efento measurements, reason: channels list is empty!"); + } + Map valuesMap = new TreeMap<>(); - if (!CollectionUtils.isEmpty(channelsList)) { - int channel = 0; - JsonObject values; - for (MeasurementsProtos.ProtoChannel protoChannel : channelsList) { - channel++; - boolean isBinarySensor = false; - MeasurementTypeProtos.MeasurementType measurementType = protoChannel.getType(); - String measurementTypeName = measurementType.name(); - if (measurementType.equals(MeasurementTypeProtos.MeasurementType.OK_ALARM) - || measurementType.equals(MeasurementTypeProtos.MeasurementType.FLOODING)) { - isBinarySensor = true; - } - if (measurementPeriodFactor == 0 && isBinarySensor) { - measurementPeriodFactor = 14; - } else { - measurementPeriodFactor = 1; + for (int channel = 0; channel < channelsList.size(); channel++) { + ProtoChannel protoChannel = channelsList.get(channel); + List sampleOffsetsList = protoChannel.getSampleOffsetsList(); + if (CollectionUtils.isEmpty(sampleOffsetsList)) { + log.trace("[{}][{}] sampleOffsetsList list is empty!", sessionId, protoChannel.getType().name()); + continue; + } + boolean isBinarySensor = isBinarySensor(protoChannel.getType()); + int channelPeriodFactor = (measurementPeriodFactor == 0 ? (isBinarySensor ? 14 : 1) : measurementPeriodFactor); + int measurementPeriod = measurementPeriodBase * channelPeriodFactor; + long measurementPeriodMillis = TimeUnit.SECONDS.toMillis(measurementPeriod); + long startTimestampMillis = TimeUnit.SECONDS.toMillis(protoChannel.getTimestamp()); + + for (int i = 0; i < sampleOffsetsList.size(); i++) { + int sampleOffset = sampleOffsetsList.get(i); + if (isSensorError(sampleOffset)) { + log.warn("[{}],[{}] Sensor error value! Ignoring.", sessionId, sampleOffset); + continue; } - int measurementPeriod = measurementPeriodBase * measurementPeriodFactor; - long measurementPeriodMillis = TimeUnit.SECONDS.toMillis(measurementPeriod); - long nextTransmissionAtMillis = TimeUnit.SECONDS.toMillis(protoMeasurements.getNextTransmissionAt()); - int startPoint = protoChannel.getStartPoint(); - int startTimestamp = protoChannel.getTimestamp(); - long startTimestampMillis = TimeUnit.SECONDS.toMillis(startTimestamp); - List sampleOffsetsList = protoChannel.getSampleOffsetsList(); - if (!CollectionUtils.isEmpty(sampleOffsetsList)) { - int sampleOfssetsListSize = sampleOffsetsList.size(); - for (int i = 0; i < sampleOfssetsListSize; i++) { - int sampleOffset = sampleOffsetsList.get(i); - Integer previousSampleOffset = isBinarySensor && i > 0 ? sampleOffsetsList.get(i - 1) : null; - if (sampleOffset == -32768) { - log.warn("[{}],[{}] Sensor error value! Ignoring.", sessionId, sampleOffset); - } else { - switch (measurementType) { - case TEMPERATURE: - values = valuesMap.computeIfAbsent(startTimestampMillis, k -> - CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); - values.addProperty("temperature_" + channel, ((double) (startPoint + sampleOffset)) / 10f); - startTimestampMillis = startTimestampMillis + measurementPeriodMillis; - break; - case WATER_METER: - values = valuesMap.computeIfAbsent(startTimestampMillis, k -> - CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); - values.addProperty("pulse_counter_water_" + channel, ((double) (startPoint + sampleOffset))); - startTimestampMillis = startTimestampMillis + measurementPeriodMillis; - break; - case HUMIDITY: - values = valuesMap.computeIfAbsent(startTimestampMillis, k -> - CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); - values.addProperty("humidity_" + channel, (double) (startPoint + sampleOffset)); - startTimestampMillis = startTimestampMillis + measurementPeriodMillis; - break; - case ATMOSPHERIC_PRESSURE: - values = valuesMap.computeIfAbsent(startTimestampMillis, k -> - CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); - values.addProperty("pressure_" + channel, (double) (startPoint + sampleOffset) / 10f); - startTimestampMillis = startTimestampMillis + measurementPeriodMillis; - break; - case DIFFERENTIAL_PRESSURE: - values = valuesMap.computeIfAbsent(startTimestampMillis, k -> - CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); - values.addProperty("pressure_diff_" + channel, (double) (startPoint + sampleOffset)); - startTimestampMillis = startTimestampMillis + measurementPeriodMillis; - break; - case OK_ALARM: - boolean currentIsOk = sampleOffset < 0; - if (previousSampleOffset != null) { - boolean previousIsOk = previousSampleOffset < 0; - boolean isOk = previousIsOk && currentIsOk; - boolean isAlarm = !previousIsOk && !currentIsOk; - if (isOk || isAlarm) { - break; - } - } - String data = currentIsOk ? "OK" : "ALARM"; - long sampleOffsetMillis = TimeUnit.SECONDS.toMillis(sampleOffset); - long measurementTimestamp = startTimestampMillis + Math.abs(sampleOffsetMillis); - values = valuesMap.computeIfAbsent(measurementTimestamp - 1000, k -> - CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); - values.addProperty("ok_alarm_" + channel, data); - break; - case PULSE_CNT: - values = valuesMap.computeIfAbsent(startTimestampMillis, k -> - CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); - values.addProperty("pulse_cnt_" + channel, (double) (startPoint + sampleOffset)); - startTimestampMillis = startTimestampMillis + measurementPeriodMillis; - break; - case NO_SENSOR: - case UNRECOGNIZED: - log.trace("[{}][{}] Sensor error value! Ignoring.", sessionId, measurementTypeName); - break; - default: - log.trace("[{}],[{}] Unsupported measurementType! Ignoring.", sessionId, measurementTypeName); - break; - } + + JsonObject values; + if (isBinarySensor) { + boolean currentIsOk = sampleOffset < 0; + Integer previousSampleOffset = i > 0 ? sampleOffsetsList.get(i - 1) : null; + if (previousSampleOffset != null) { //compare with previous value + boolean previousIsOk = previousSampleOffset < 0; + if (currentIsOk == previousIsOk) { + break; } } + long sampleOffsetMillis = TimeUnit.SECONDS.toMillis(sampleOffset); + long measurementTimestamp = startTimestampMillis + Math.abs(sampleOffsetMillis); + values = valuesMap.computeIfAbsent(measurementTimestamp - 1000, k -> + CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); + addBinarySample(protoChannel, currentIsOk, values, channel + 1, sessionId); } else { - log.trace("[{}][{}] sampleOffsetsList list is empty!", sessionId, measurementTypeName); + long timestampMillis = startTimestampMillis + i * measurementPeriodMillis; + values = valuesMap.computeIfAbsent(timestampMillis, k -> CoapEfentoUtils.setDefaultMeasurements( + serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); + addContinuesSample(protoChannel, sampleOffset, values, channel + 1, sessionId); } } - } else { - throw new IllegalStateException("[" + sessionId + "]: Failed to get Efento measurements, reason: channels list is empty!"); } - if (!CollectionUtils.isEmpty(valuesMap)) { - List efentoMeasurements = new ArrayList<>(); - for (Long ts : valuesMap.keySet()) { - EfentoTelemetry measurement = new EfentoTelemetry(ts, valuesMap.get(ts)); - efentoMeasurements.add(measurement); - } - return efentoMeasurements; - } else { + + if (CollectionUtils.isEmpty(valuesMap)) { throw new IllegalStateException("[" + sessionId + "]: Failed to collect Efento measurements, reason, values map is empty!"); } + + return valuesMap.entrySet().stream() + .map(entry -> new EfentoTelemetry(entry.getKey(), entry.getValue())) + .collect(Collectors.toList()); + } + + private void addContinuesSample(ProtoChannel protoChannel, int sampleOffset, JsonObject values, int channelNumber, UUID sessionId) { + int startPoint = protoChannel.getStartPoint(); + + switch (protoChannel.getType()) { + case MEASUREMENT_TYPE_TEMPERATURE: + values.addProperty("temperature_" + channelNumber, ((double) (startPoint + sampleOffset)) / 10f); + break; + case MEASUREMENT_TYPE_WATER_METER: + values.addProperty("pulse_counter_water_" + channelNumber, ((double) (startPoint + sampleOffset))); + break; + case MEASUREMENT_TYPE_HUMIDITY: + values.addProperty("humidity_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE: + values.addProperty("pressure_" + channelNumber, (double) (startPoint + sampleOffset) / 10f); + break; + case MEASUREMENT_TYPE_DIFFERENTIAL_PRESSURE: + values.addProperty("pressure_diff_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_PULSE_CNT: + values.addProperty("pulse_cnt_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_IAQ: + values.addProperty("iaq_" + channelNumber, (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_ELECTRICITY_METER: + values.addProperty("watt_hour_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_SOIL_MOISTURE: + values.addProperty("soil_moisture_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_AMBIENT_LIGHT: + values.addProperty("ambient_light_" + channelNumber, (double) (startPoint + sampleOffset) / 10f); + break; + case MEASUREMENT_TYPE_HIGH_PRESSURE: + values.addProperty("high_pressure_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_DISTANCE_MM: + values.addProperty("distance_mm_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_WATER_METER_ACC_MINOR: + values.addProperty("acc_counter_water_minor_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_WATER_METER_ACC_MAJOR: + values.addProperty("acc_counter_water_major_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_HUMIDITY_ACCURATE: + values.addProperty("humidity_relative_" + channelNumber, (double) (startPoint + sampleOffset) / 10f); + break; + case MEASUREMENT_TYPE_STATIC_IAQ: + values.addProperty("static_iaq_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_CO2_EQUIVALENT: + values.addProperty("co2_ppm_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_BREATH_VOC: + values.addProperty("breath_voc_ppm_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_PERCENTAGE: + values.addProperty("percentage_" + channelNumber, (double) (startPoint + sampleOffset) / 100f); + break; + case MEASUREMENT_TYPE_VOLTAGE: + values.addProperty("voltage_" + channelNumber, (double) (startPoint + sampleOffset) / 10f); + break; + case MEASUREMENT_TYPE_CURRENT: + values.addProperty("current_" + channelNumber, (double) (startPoint + sampleOffset) / 100f); + break; + case MEASUREMENT_TYPE_PULSE_CNT_ACC_MINOR: + values.addProperty("pulse_cnt_minor_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_PULSE_CNT_ACC_MAJOR: + values.addProperty("pulse_cnt_major_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_ELEC_METER_ACC_MINOR: + values.addProperty("elec_meter_minor_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_ELEC_METER_ACC_MAJOR: + values.addProperty("elec_meter_major_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MINOR: + values.addProperty("pulse_cnt_wide_minor_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MAJOR: + values.addProperty("pulse_cnt_wide_major_" + channelNumber, (double) (startPoint + sampleOffset)); + break; + case MEASUREMENT_TYPE_CURRENT_PRECISE: + values.addProperty("current_precise_" + channelNumber, (double) (startPoint + sampleOffset)/1000f); + break; + case MEASUREMENT_TYPE_NO_SENSOR: + case UNRECOGNIZED: + log.trace("[{}][{}] Sensor error value! Ignoring.", sessionId, protoChannel.getType().name()); + break; + default: + log.trace("[{}],[{}] Unsupported measurementType! Ignoring.", sessionId, protoChannel.getType().name()); + break; + } + } + + private void addBinarySample(ProtoChannel protoChannel, boolean valueIsOk, JsonObject values, int channel, UUID sessionId) { + switch (protoChannel.getType()) { + case MEASUREMENT_TYPE_OK_ALARM: + values.addProperty("ok_alarm_" + channel, valueIsOk ? "OK" : "ALARM"); + break; + case MEASUREMENT_TYPE_FLOODING: + values.addProperty("flooding_" + channel, valueIsOk ? "OK" : "WATER_DETECTED"); + break; + case MEASUREMENT_TYPE_OUTPUT_CONTROL: + values.addProperty("output_control_" + channel, valueIsOk ? "OFF" : "ON"); + break; + default: + log.trace("[{}],[{}] Unsupported binary measurementType! Ignoring.", sessionId, protoChannel.getType().name()); + break; + } } private EfentoTelemetry getEfentoDeviceInfo(DeviceInfoProtos.ProtoDeviceInfo protoDeviceInfo) { diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java index d87c97543e..96c1805be3 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java @@ -16,11 +16,16 @@ package org.thingsboard.server.transport.coap.efento.utils; import com.google.gson.JsonObject; +import org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType; import java.text.SimpleDateFormat; import java.util.Date; import java.util.TimeZone; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_FLOODING; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_OK_ALARM; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_OUTPUT_CONTROL; + public class CoapEfentoUtils { public static String convertByteArrayToString(byte[] a) { @@ -50,4 +55,12 @@ public class CoapEfentoUtils { return values; } + public static boolean isBinarySensor(MeasurementType type) { + return type == MEASUREMENT_TYPE_OK_ALARM || type == MEASUREMENT_TYPE_FLOODING || type == MEASUREMENT_TYPE_OUTPUT_CONTROL; + } + + public static boolean isSensorError(int sampleOffset) { + return sampleOffset >= 8355840 && sampleOffset <= 8388607; + } + } diff --git a/common/transport/coap/src/main/proto/efento/proto_config.proto b/common/transport/coap/src/main/proto/efento/proto_config.proto index 6212681403..25dbec883f 100644 --- a/common/transport/coap/src/main/proto/efento/proto_config.proto +++ b/common/transport/coap/src/main/proto/efento/proto_config.proto @@ -22,26 +22,13 @@ option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "ConfigProtos"; /* Message containing optional channels control parameters */ -message ProtoChannelControl { +message ProtoOutputControlState { /* Channel index */ uint32 channel_index = 1; - /* Control parameters. Maximal number equals 4. This field is channel specific: */ - /* IO_control channel: */ - /* - control_params[0]: */ - /* - Byte 0: On state configuration */ - /* 0x01 - Low */ - /* 0x02 - High */ - /* 0x03 - High-Z (disconnected) */ - /* - Byte 1: Off state configuration */ - /* 0x01 - Low */ - /* 0x02 - High */ - /* 0x03 - High-Z (disconnected) */ - /* - Byte 2: Power on channel state */ - /* 0x01 - On */ - /* 0x02 - Off */ - repeated uint32 control_params = 2; + /* Channel state ON/OFF. Range (1 - OFF; 2 - ON) */ + uint32 channel_state = 2; } /* Message containing request data for accesing calibration parameters */ @@ -58,6 +45,39 @@ message ProtoCalibrationParameters { repeated int32 parameters = 3; } +enum BleAdvertisingPeriodMode { + + /* Invalid value */ + BLE_ADVERTISING_PERIOD_MODE_UNSPECIFIED = 0; + + /* Default behavior - faster advertising when measurement period is < 15s. */ + BLE_ADVERTISING_PERIOD_MODE_DEFAULT = 1; + + /* User-configured normal interval is used. */ + BLE_ADVERTISING_PERIOD_MODE_NORMAL = 2; + + /* User-configured fast interval is used. */ + BLE_ADVERTISING_PERIOD_MODE_FAST = 3; +} + +/* Message containing BLE advertising period configuration */ +message ProtoBleAdvertisingPeriod { + + /* BLE advertising mode: */ + /* - 1: Default, BLE advertising interval is set to 1022.5ms or some lower value, based on continuous measurement period. */ + /* - 2: Normal, uses user-configured value from 'normal' field. */ + /* - 3: Fast, uses user-configured value from 'fast' field (must be lower than or equal to 'normal' field). */ + BleAdvertisingPeriodMode mode = 1; + + /* BLE advertising interval when in normal mode, configured in 0.625ms steps. */ + /* Range: [32:16384] */ + uint32 normal = 2; + + /* BLE advertising interval when in fast mode, configured in 0.625ms steps. */ + /* Range: [32:16384] */ + uint32 fast = 3; +} + /* Main message sent in the payload. Each field in this message is independent of the others - only parameters that should be */ /* changed need to be sent in the payload. */ /* If the value of a selected parameter shall not be changed, do not include it in the payload */ @@ -115,16 +135,26 @@ message ProtoConfig { /* 65535 - disable transfer limit function */ uint32 transfer_limit_timer = 10; - /* Data (measurements) server IP address */ - /* IP of the data server as string in form x.x.x.x. For example: 18.184.24.239 */ + /* For firmware >= 6.07.00: */ + /* IP or URL address of the data (measurements) server */ + /* The IP or URL of the data server, provided as string with a maximum length of 31 characters */ + /* For example, use "18.184.24.239" for an IP address or "efento.test.io" for a URL */ + /* For firmware < 6.07.00: */ + /* IP address of the data (measurements) server */ + /* For example, use "18.184.24.239" */ string data_server_ip = 11; /* Data (measurements) server port */ /* Range: [1:65535] */ uint32 data_server_port = 12; - /* Update server IP address */ - /* IP of data server as string in form x.x.x.x. For example: 18.184.24.239 */ + /* For firmware >= 6.07.00: */ + /* IP or URL address of the update server */ + /* The IP or URL of the update server, provided as string with a maximum length of 31 characters */ + /* For example, use "18.184.24.239" for an IP address or "efento.test.io" for a URL */ + /* For firmware < 6.07.00: */ + /* IP address of the update server */ + /* For example, use "18.184.24.239" */ string update_server_ip = 13; /* Update server port for UDP transmission */ @@ -144,7 +174,8 @@ message ProtoConfig { /* 0xFFFFFFFF or 1000000 - automatic selection */ uint32 plmn_selection = 17; - /* Device will power off its cellular modem for requested number of seconds. Maximum number of seconds 604800 (7 days) */ + /* Device will power off its cellular modem for requested number of seconds. */ + /* Range: [60:604800] (1 minute : 7 days) */ /* This field is only sent by server */ uint32 disable_modem_request = 18; @@ -263,9 +294,8 @@ message ProtoConfig { /* Calendar configuration. Up to 6 calendars are supported */ repeated ProtoCalendar calendars = 47; - /* Control parameters for channels. Maximal number of requests equals 6 */ - /* This field is only sent by server */ - repeated ProtoChannelControl channels_control_request = 48; + /* DEPRECATED - Used for backward compatibility */ + reserved 48; /* Set/get calibration parameters for single channel. */ ProtoCalibrationParameters calibration_parameters_request = 49; @@ -295,7 +325,7 @@ message ProtoConfig { reserved 52, 53; /* Encryption key configuration. Sensor sends in this field two last bytes of SHA256 hash calculated from its current */ - /* encryption_key configuration. */ + /* encryption_key configuration. When encryption key is disabled one byte 0x7F (DEL) is sent. */ /* Max length: 16 bytes. */ /* 0x7F - encryption key disabled. */ bytes encryption_key = 54; @@ -309,4 +339,14 @@ message ProtoConfig { /* String with special character 0x7F (DEL) only indicates that automatic password is turn on */ /* Password can only be set to custom value if apn_user_name has been configured (is not automatic) */ string apn_password = 56; + + /* Reserved by versions above 06.20.00 */ + reserved 57; + + /* Control output state on channel pin. Maximal number of requests equals 3 */ + /* This field is only sent by server */ + repeated ProtoOutputControlState output_control_state_request = 58; + + /* BLE advertising period configuration. */ + ProtoBleAdvertisingPeriod ble_advertising_period = 59; } \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_device_info.proto b/common/transport/coap/src/main/proto/efento/proto_device_info.proto index 791d01acd7..a0cb9bc2f2 100644 --- a/common/transport/coap/src/main/proto/efento/proto_device_info.proto +++ b/common/transport/coap/src/main/proto/efento/proto_device_info.proto @@ -106,6 +106,11 @@ message ProtoModem /* parameters[32] - Rx_time - [0.1s] - Range: [0:2147483647]. Unknown value: -1 */ /* parameters[33] - Tx_time - [0.1s] - Range: [0:2147483647]. Unknown value: -1 */ repeated sint32 parameters = 2; + + /* ICCID of inserted/soldered sim card. String up to 22 characters long. */ + /* 0x7F if sim card is not detected, empty (not sent) if device does not have modem. */ + /* This field is only sent by device */ + string sim_card_identification = 3; } message ProtoUpdateInfo @@ -115,16 +120,16 @@ message ProtoUpdateInfo uint32 timestamp = 1; /* Update status, possible values: */ - /* - 0x1 - No update yet */ - /* - 0x2 - No error */ - /* - 0x3 - UDP socekt error */ - /* - 0x4 - Hash error */ - /* - 0x5 - Missing packet error */ - /* - 0x6 - Invalid data error */ - /* - 0x7 - Sending timeout error */ - /* - 0x8 - No SW to update error */ - /* - 0x9 - Sending unexpected error */ - /* - 0x10 - Unexpected error */ + /* - 1 - No update yet */ + /* - 2 - No error */ + /* - 3 - UDP socekt error */ + /* - 4 - Hash error */ + /* - 5 - Missing packet error */ + /* - 6 - Invalid data error */ + /* - 7 - Sending timeout error */ + /* - 8 - No SW to update error */ + /* - 9 - Sending unexpected error */ + /* - 10 - Unexpected error */ uint32 status = 2; } diff --git a/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto b/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto index 66a6c95d09..98029bed51 100644 --- a/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto +++ b/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto @@ -19,79 +19,160 @@ option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "MeasurementTypeProtos"; enum MeasurementType { - NO_SENSOR = 0; - - /* [°C] - Celsius degree. Resolution 0.1°C. Range [-273.2-4000.0]. Type: Continuous */ - TEMPERATURE = 1; - - /* [% RH] - Relative humidity. Resolution 1%. Range [0-100]. Type: Continuous */ - HUMIDITY = 2; - - /* [hPa] - Hectopascal (1hPa = 100Pa). Resolution 0.1hPa. Range: [1.0-2000.0]. Type: Continuous */ - ATMOSPHERIC_PRESSURE = 3; - - /* [Pa] - Pascal. Resolution 1Pa. Range [-10000-10000]Type: Continuous */ - DIFFERENTIAL_PRESSURE = 4; - - /* Sign indicates state: (+) ALARM, (-) OK. Type: Binary */ - OK_ALARM = 5; - - /* [IAQ] - Iaq index. Resolution 1IAQ. Range [0-500]. Sensor return also calibration status */ - /* as offset to measured value: */ - /* - offset 3000: Sensor not stabilized (always returns 25 IAQ value) */ - /* - offset 2000: Calibration required (sensor returns not accurate values) */ - /* - offset 1000: Calibration on-going (sensor returns not accurate values) */ - /* - offset 0: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ - IAQ = 6; - - /* Sign indicates water presence: (+) water not detected, (-) water detected. Type: Binary */ - FLOODING = 7; - - /* [NB] Number of pulses. Resolution 1 pulse. Range [0-16711679]. Type: Continuous */ - PULSE_CNT = 8; - - /* [Wh] - Watthour; Resolution 1Wh. Range [0-16711679]. Number of Watthours in a single period. Type: Continuous */ - ELECTRICITY_METER = 9; - - /* [l] - Liter. Resolution 1l. Range [0-16711679]. Number of litres in a single period. Type: Continuous */ - WATER_METER = 10; - - /* [kPa] - Kilopascal (1kPa = 1000Pa); Resolution 1kPa. Range [-1000-0]. Soil moisture (tension). Type: Continuous */ - SOIL_MOISTURE = 11; - - /* [ppm] - Parts per million. Resolution 1ppm. Range [0-1000000]. Carbon monoxide concentration. Type: Continuous */ - CO_GAS = 12; - - /* [ppm] - Parts per million. Resolution 0.01ppm. Range [0-1000000.00]. Nitrogen dioxide concentration. Type: Continuous*/ - NO2_GAS = 13; - - /* [ppm] - Parts per million. Resolution 1ppm. Range [0-1000000]. Hydrogen sulfide concentration. Type: Continuous */ - H2S_GAS = 14; - - /* [lx] - Illuminance. Resolution 0.1lx. Range [0-100000.0]. Type: Continuous */ - AMBIENT_LIGHT = 15; - - /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [0-1000]. */ - /* particles with an aerodynamic diameter less than 1 micrometer. Type: Continuous */ - PM_1_0 = 16; // µg/m^3 - - /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [0-1000]. */ - /* particles with an aerodynamic diameter less than 2.5 micrometers. Type: Continuous */ - PM_2_5 = 17; // µg/m^3 - - /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [0-1000]. */ - /* particles with an aerodynamic diameter less than 10 micrometers. Type: Continuous */ - PM_10_0 = 18; // µg/m^3 - - /* [dB] - Decibels. Resolution 0.1 dB. Range: [0-130.0]. Noise level. Type: Continuous */ - NOISE_LEVEL = 19; // 0.1 dB - - /* [ppm] - Parts per million. Resolution 1ppm. Range [0-1000000]. Ammonia concentration. Type: Continuous */ - NH3_GAS = 20; - - /* [ppm] - Parts per million. Resolution 1ppm. Range [0-1000000]. Methane concentration. Type: Continuous */ - CH4_GAS = 21; + /* [] - No sensor on the channel */ + MEASUREMENT_TYPE_NO_SENSOR = 0; + + /* [°C] - Celsius degree. Resolution 0.1°C. Range [-273.2:4000.0]. Type: Continuous */ + MEASUREMENT_TYPE_TEMPERATURE = 1; + + /* [% RH] - Relative humidity. Resolution 1%. Range [0:100]. Type: Continuous */ + MEASUREMENT_TYPE_HUMIDITY = 2; + + /* [hPa] - Hectopascal (1hPa = 100Pa). Resolution 0.1hPa. Range: [1.0:2000.0]. Atmospheric pressure. Type: Continuous */ + MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE = 3; + + /* [Pa] - Pascal. Resolution 1Pa. Range [-10000:10000]. Differential pressure. Type: Continuous */ + MEASUREMENT_TYPE_DIFFERENTIAL_PRESSURE = 4; + + /* Sign indicates state: (+) ALARM, (-) OK. Type: Binary */ + MEASUREMENT_TYPE_OK_ALARM = 5; + + /* [IAQ] - IAQ index. Resolution 1IAQ. Range [0:500]. To get IAQ index the value should be divided by 3. */ + /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ + /* - 0: Calibration required (sensor returns not accurate values) */ + /* - 1: Calibration on-going (sensor returns not accurate values) */ + /* - 2: Calibration done (best accuracy of IAQ sensor) */ + /* Type: Continuous */ + MEASUREMENT_TYPE_IAQ = 6; + + /* Sign indicates water presence: (+) water not detected, (-) water detected. Type: Binary */ + MEASUREMENT_TYPE_FLOODING = 7; + + /* [NB] Number of pulses. Resolution 1 pulse. Range [0:8000000]. Type: Continuous */ + MEASUREMENT_TYPE_PULSE_CNT = 8; + + /* [Wh] - Watthour; Resolution 1Wh. Range [0:8000000]. Number of Watthours in a single period. Type: Continuous */ + MEASUREMENT_TYPE_ELECTRICITY_METER = 9; + + /* [l] - Liter. Resolution 1l. Range [0:8000000]. Number of litres in a single period. Type: Continuous */ + MEASUREMENT_TYPE_WATER_METER = 10; + + /* [kPa] - Kilopascal (1kPa = 1000Pa); Resolution 1kPa. Range [-1000:0]. Soil moisture (tension). Type: Continuous */ + MEASUREMENT_TYPE_SOIL_MOISTURE = 11; + + /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon monoxide concentration. Type: Continuous */ + MEASUREMENT_TYPE_CO_GAS = 12; + + /* [ppm] - Parts per million. Resolution 1.0ppm. Range [0:1000000]. Nitrogen dioxide concentration. Type: Continuous */ + MEASUREMENT_TYPE_NO2_GAS = 13; + + /* [ppm] - Parts per million. Resolution 0.01ppm. Range [0.00:80000.00]. Hydrogen sulfide concentration. Type: Continuous */ + MEASUREMENT_TYPE_H2S_GAS = 14; + + /* [lx] - Lux. Resolution 0.1lx. Range [0.0:100000.0]. Illuminance. Type: Continuous */ + MEASUREMENT_TYPE_AMBIENT_LIGHT = 15; + + /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3. Range [0:1000]. */ + /* Particles with an aerodynamic diameter less than 1 micrometer. Type: Continuous */ + MEASUREMENT_TYPE_PM_1_0 = 16; + + /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3. Range [0:1000]. */ + /* Particles with an aerodynamic diameter less than 2.5 micrometers. Type: Continuous */ + MEASUREMENT_TYPE_PM_2_5 = 17; + + /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3. Range [0:1000]. */ + /* Particles with an aerodynamic diameter less than 10 micrometers. Type: Continuous */ + MEASUREMENT_TYPE_PM_10_0 = 18; + + /* [dB] - Decibels. Resolution 0.1 dB. Range: [0.0:200.0]. Noise level. Type: Continuous */ + MEASUREMENT_TYPE_NOISE_LEVEL = 19; + + /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Ammonia concentration. Type: Continuous */ + MEASUREMENT_TYPE_NH3_GAS = 20; + + /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Methane concentration. Type: Continuous */ + MEASUREMENT_TYPE_CH4_GAS = 21; + + /* [kPa] - Kilopascal (1kPa = 1000Pa, 100kPa = 1bar). Resolution 1kPa. Range [0:200000]. Pressure. Type: Continuous */ + MEASUREMENT_TYPE_HIGH_PRESSURE = 22; + + /* [mm] - Millimeter. Resolution 1mm. Range [0:100000]. Distance. Type: Continuous */ + MEASUREMENT_TYPE_DISTANCE_MM = 23; + + /* [l] - Liter. Resolution 1l. Range [0:1000000]. Accumulative water meter (minor). Type: Continuous */ + MEASUREMENT_TYPE_WATER_METER_ACC_MINOR = 24; + + /* [hl] - Hectoliter. Resolution 1hl. Range [0:1000000]. Accumulative water meter (major). Type: Continuous */ + MEASUREMENT_TYPE_WATER_METER_ACC_MAJOR = 25; + + /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon dioxide concentration. Type: Continuous */ + MEASUREMENT_TYPE_CO2_GAS = 26; + + /* [% RH] - Relative humidity. Resolution 0.1%. Range [0.0:100.0]. Type: Continuous */ + MEASUREMENT_TYPE_HUMIDITY_ACCURATE = 27; + + /* [sIAQ] - Static IAQ index. Resolution 1IAQ. Range [0:10000]. To get static IAQ index the value should be divided by 3. */ + /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ + /* - 0: Calibration required (sensor returns not accurate values) */ + /* - 1: Calibration on-going (sensor returns not accurate values) */ + /* - 2: Calibration done (best accuracy of IAQ sensor) */ + /* Type: Continuous */ + MEASUREMENT_TYPE_STATIC_IAQ = 28; + + /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. CO2 equivalent. */ + /* To get CO2 equivalent the value should be divided by 3. */ + /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ + /* - 0: Calibration required (sensor returns not accurate values) */ + /* - 1: Calibration on-going (sensor returns not accurate values) */ + /* - 2: Calibration done (best accuracy of IAQ sensor) */ + /* Type: Continuous */ + MEASUREMENT_TYPE_CO2_EQUIVALENT = 29; + + /* [ppm] - Parts per million. Resolution 1ppm. Range [0:100000]. Breath VOC estimate. */ + /* To get breath VOC estimate the value should be divided by 3. */ + /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ + /* - 0: Calibration required (sensor returns not accurate values) */ + /* - 1: Calibration on-going (sensor returns not accurate values) */ + /* - 2: Calibration done (best accuracy of IAQ sensor) */ + /* Type: Continuous */ + MEASUREMENT_TYPE_BREATH_VOC = 30; + + /* Special measurement type reserved for cellular gateway. */ + /* Type: Continuous */ + MEASUREMENT_TYPE_CELLULAR_GATEWAY = 31; + + /* [%] - Percentage. Resolution 0.01%. Range [0.00:100.00]. Type: Continuous */ + MEASUREMENT_TYPE_PERCENTAGE = 32; + + /* [mV] - Milivolt. Resolution 0.1mV. Range [0.0:100000.0]. Type: Continuous */ + MEASUREMENT_TYPE_VOLTAGE = 33; + + /* [mA] - Milliampere. Resolution 0.01mA. Range [0.0:10000.00]. Type: Continuous */ + MEASUREMENT_TYPE_CURRENT = 34; + + /* [NB] Number of pulses. Resolution 1 pulse. Range [0:1000000]. Type: Continuous */ + MEASUREMENT_TYPE_PULSE_CNT_ACC_MINOR = 35; + + /* [kNB] Number of kilopulses. Resolution 1 kilopulse. Range [0:1000000]. Type: Continuous */ + MEASUREMENT_TYPE_PULSE_CNT_ACC_MAJOR = 36; + + /* [Wh] - Watt-hour; Resolution 1Wh. Range [0:1000000]. Number of watt-hours in a single period. Type: Continuous */ + MEASUREMENT_TYPE_ELEC_METER_ACC_MINOR = 37; + + /* [kWh] - Kilowatt-hour; Resolution 1kWh. Range [0:1000000]. Number of kilowatt-hours in a single period. Type: Continuous */ + MEASUREMENT_TYPE_ELEC_METER_ACC_MAJOR = 38; + + /* [NB] Number of pulses (wide range). Resolution 1 pulse. Range [0:999999]. Type: Continuous */ + MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MINOR = 39; + + /* [MNB] Number of megapulses (wide range). Resolution 1 megapulse. Range [0:999999]. Type: Continuous */ + MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MAJOR = 40; + + /* [mA] - Milliampere. Resolution 0.001mA. Range [-4 000.000:4 000.000]. Type: Continuous */ + MEASUREMENT_TYPE_CURRENT_PRECISE = 41; + + /* Sign indicates state: (+) ON, (-) OFF. Type: Binary */ + MEASUREMENT_TYPE_OUTPUT_CONTROL = 42; } diff --git a/common/transport/coap/src/main/proto/efento/proto_measurements.proto b/common/transport/coap/src/main/proto/efento/proto_measurements.proto index f8549feb54..80af07d958 100644 --- a/common/transport/coap/src/main/proto/efento/proto_measurements.proto +++ b/common/transport/coap/src/main/proto/efento/proto_measurements.proto @@ -19,103 +19,108 @@ import "efento/proto_measurement_types.proto"; option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "MeasurementsProtos"; -message ProtoChannel{ - /* Type of channel */ +message ProtoChannel { + + /* Type of channel */ MeasurementType type = 1; - /* Timestamp of the first sample (the oldest one) in seconds since UNIX EPOCH 01-01-1970 */ + /* Timestamp of the first sample (the oldest one) in seconds since UNIX EPOCH 01-01-1970 */ int32 timestamp = 2; - /* Only used for 'Continuous' sensor types. Value used as the starting point for calculating the values of all */ - /* measurements in the package. */ - /* Format defined by 'MeasurementType' field */ + /* Only used for 'Continuous' sensor types. Value used as the starting point for calculating the values of all */ + /* measurements in the package. */ + /* Format defined by 'MeasurementType' field */ sint32 start_point = 4; - /* 'Continuous' sensor types */ - /* Value of the offset from the 'start_point' for each measurement in the package. The oldest sample first ([0]). */ - /* 'sample_offsets' format defined by 'MeasurementType' field. */ - /* Example: MeasurementType = 1 (temperature), start_point = 100, sample_offsets[0] = 15, sample_offsets[1] = 20 */ - /* 1st sample in the package temperature value = 11.5 °C, 2nd sample in the package temperature value = 12 °C */ - /* Calculating timestamps of the measurements: timestamp = 1606391700, measurement_period_base = 60, */ - /* measurement_period_factor = 1. Timestamp of the 1st sample = 1606391700, timestamp of the 2nd sample = 1606391760 */ - /* 'Binary' sensor types: */ - /* Absolute value of the 'sample_offsets' field indicates the offset in seconds from 'timestamp' field. */ - /* Sign (- or +) indicates the state of measurements depend of sensor type. */ - /* Value of this field equals to '1' or '-1' indicates the state at the 'timestamp'. Other values */ - /* indicate the state of the relay at the time (in seconds) equal to 'timestamp' + value. */ - /* Values of this field are incremented starting from 1 (1->0: state at the time */ - /* of 'timestamp', 2->1: state at the time equal to 'timestamp' + 1 s, 3->2 : */ - /* state at the time equal to 'timestamp' + 2 s, etc.). The first and the last sample define the time range of the */ - /* measurements. Only state changes in the time range are included in the 'sample_offsets' field */ - /* Examples: if 'timestamp' value is 1553518060 and 'sample_offsets' equals '1', it means that at 1553518060 the state */ - /* was high, if 'timestamp' value is 1553518060 and 'sample_offsets' equals '-9', it means at 1553518068 the state was low */ + /* 'Continuous' sensor types */ + /* Value of the offset from the 'start_point' for each measurement in the package. The oldest sample first ([0]). */ + /* 'sample_offsets' format defined by 'MeasurementType' field. */ + /* If the 'sample_offset' has a value from the range [8355840: 8388607], it should be interpreted as a sensor error code. */ + /* In that case value of the 'start_point' field should not be added to this 'sample_offset'. See ES6-264 for error codes. */ + /* Example: MeasurementType = 1 (temperature), start_point = 100, sample_offsets[0] = 15, sample_offsets[1] = 20, */ + /* sample_offset[2] = 8388605 */ + /* 1st sample in the package temperature value = 11.5 °C, 2nd sample in the package temperature value = 12 °C */ + /* 3rd sample in the package has no temperature value. It has information about failure of MCP9808 (temperature) sensor. */ + /* Calculating timestamps of the measurements: timestamp = 1606391700, measurement_period_base = 60, */ + /* measurement_period_factor = 1. Timestamp of the 1st sample = 1606391700, timestamp of the 2nd sample = 1606391760, */ + /* timestamp of the 3rd sample 1606391820 */ + + /* 'Binary' sensor types: */ + /* Absolute value of the 'sample_offsets' field indicates the offset in seconds from 'timestamp' field. */ + /* Sign (- or +) indicates the state of measurements depending on the sensor type. */ + /* Value of this field equals to '1' or '-1' indicates the state at the 'timestamp'. Other values */ + /* indicate the state of the relay at the time (in seconds) equal to 'timestamp' + absolute value -1. */ + /* Values of this field are incremented starting from 1 (1->0: state at the time */ + /* of 'timestamp', 2->1: state at the time equal to 'timestamp' + 1 s, 3->2 : */ + /* state at the time equal to 'timestamp' + 2 s, etc.). The first and the last sample define the time range of the */ + /* measurements. Only state changes in the time range are included in the 'sample_offsets' field */ + /* Examples: if 'timestamp' value is 1553518060 and 'sample_offsets' equals '1', it means that at 1553518060 the state */ + /* was high, if 'timestamp' value is 1553518060 and 'sample_offsets' equals '-9', it means at 1553518068 the state was low */ repeated sint32 sample_offsets = 5 [packed=true]; - /* Deprecated - configuration is sent to endpoint 'c' */ - //int32 lo_threshold = 6; + /* Deprecated - configuration is sent to endpoint 'c' */ + /* int32 lo_threshold = 6; */ + reserved 6; - /* Deprecated - configuration is sent to endpoint 'c' */ - //int32 hi_threshold = 7; + /* Deprecated - configuration is sent to endpoint 'c' */ + /* int32 hi_threshold = 7; */ + reserved 7; - /* Deprecated - configurationis sent to endpoint 'c' */ - //int32 diff_threshold = 8; - } + /* Deprecated - configurations sent to endpoint 'c' */ + /* int32 diff_threshold = 8; */ + reserved 8; +} message ProtoMeasurements { - /* serial number of the device */ + /* Serial number of the device */ bytes serial_num = 1; - /* true - battery ok, false - battery low */ + /* Battery status: true - battery ok, false - battery low */ bool battery_status = 2; - /* 'Measurement_period_base' and 'measurement_period_factor' define how often the measurements are taken. */ - /* Sensors of 'Continuous' type take measurement each Measurement_period_base * measurement_period_factor. */ - /* Sensors of 'Binary' type take measurement each Measurement_period_base. */ - /* For backward compatibility with versions 5.x in case of binary/mixed sensors, if the 'measurement_period_factor' is */ - /* not sent (equal to 0), then the default value '14' shall be used for period calculation. */ - /* For backward compatibility with versions 5.x in case of continues sensors, if the measurement_period_factor is */ - /* not sent (equal to 0), then the default value '1' shall be used for period calculation. */ - /* measurement period base in seconds */ + /* 'Measurement_period_base' and 'measurement_period_factor' define how often the measurements are taken. */ + /* Sensors of 'Continuous' type take measurement each Measurement_period_base * measurement_period_factor. */ + /* Sensors of 'Binary' type take measurement each Measurement_period_base. */ + /* For backward compatibility with versions 5.x in case of binary/mixed sensors, if the 'measurement_period_factor' is */ + /* not sent (equal to 0), then the default value '14' shall be used for period calculation. */ + /* For backward compatibility with versions 5.x in case of continues sensors, if the measurement_period_factor is */ + /* not sent (equal to 0), then the default value '1' shall be used for period calculation. */ + /* measurement period base in seconds */ uint32 measurement_period_base = 3; - /* Measurement period factor */ + /* Measurement period factor */ uint32 measurement_period_factor = 8; repeated ProtoChannel channels = 4; - /* Timestamp of the next scheduled transmission. If the device will not send data until this time, */ - /* it should be considered as 'lost' */ + /* Timestamp of the next scheduled transmission. If the device will not send data until this time, */ + /* it should be considered as 'lost' */ uint32 next_transmission_at = 5; - /* reason of transmission - unsigned integer where each bit indicates different */ - /* possible communication reason. Can be more than one */ - /* - bit 0: first message after sensor reset */ - /* - bit 1: user button triggered */ - /* - bit 2: user BLE triggered */ - /* - bit 3-7: number of retries -> incremented after each unsuccessful transmission. Max value 4. */ - /* Set to 0 after a successful transmission. */ - /* - bit 8: channel 1 lower threshold exceeded */ - /* - bit 9: channel 1 lower threshold returned */ - /* - bit 10: channel 1 higher threshold exceeded */ - /* - bit 11: channel 1 higher threshold returned */ - /* - bit 12: channel 1 differential threshold crossed */ - /* - bits 13-17: channel 2 thresholds (same as for channel 1) */ - /* - bits 18-22: channel 3 thresholds (same as for channel 1) */ - /* - bits 23-27: channel 4 or 5 or 6 thresholds (same as for channel 1) */ + /* Reason of transmission - unsigned integer where each bit indicates different possible communication reason. */ + /* Can be more than one: */ + /* - bit 0: first message after sensor reset */ + /* - bit 1: user button triggered */ + /* - bit 2: user BLE triggered */ + /* - bit 3-7: number of retries -> incremented after each unsuccessful transmission. Max value 31. */ + /* Set to 0 after a successful transmission. */ + /* - bit 8...19: rule 1...12 was met */ + /* - bit 20: triggered after the end of the limit */ uint32 transfer_reason = 6; - /* Signal strength level mapped from RSSI */ - /* - 0 : 113 dBm or less */ - /* - 1 : 111 dBm */ - /* - 2...30 : 109...-53 dBm */ - /* - 31 : -51 dBm or greater */ - /* - 99 : Not known or not detectable */ + /* Signal strength level mapped from RSSI: */ + /* - 0: RSSI < -110 dBm */ + /* - 1: -110 dBm <= RSSI < -109 dBm */ + /* - 2...61: -109 <= RSSI < -108 dBm ... -50 dBm <= RSSI < -49 dBm */ + /* - 62: -49 dBm <= RSSI < -48 dBm */ + /* - 63: RSSI >= -48 dBm */ + /* - 99: Not known or not detectable */ uint32 signal = 7; - /* Hash of the current configuration. Hash value changes each time a device receives a new configuration */ + /* Hash of the current configuration. Hash value changes each time a device receives a new configuration */ uint32 hash = 9; - /* Optional string up to 36 bytes long. Can be set to any user define value or hold device's IMEI */ + /* Optional string up to 36 bytes long. Can be set to any user define value or hold device's IMEI */ string cloud_token = 16; } \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_rule.proto b/common/transport/coap/src/main/proto/efento/proto_rule.proto index 4079683a3f..12550426b1 100644 --- a/common/transport/coap/src/main/proto/efento/proto_rule.proto +++ b/common/transport/coap/src/main/proto/efento/proto_rule.proto @@ -50,11 +50,19 @@ option java_outer_classname = "ProtoRuleProtos"; /* - CO2_EQUIVALENT - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon dioxide equivalent. */ /* - BREATH_VOC - [ppm] - Parts per million. Resolution 1ppm. Range [0:100000]. Breath VOC estimate. */ /* - PERCENTAGE - [%] - Percentage. Resolution 0.01%. Range [0.00:100.00]. */ +/* - VOLTAGE - [mV] - Milivolt. Resolution 0.1mV. Range [0.0:100000.0]. */ +/* - CURRENT - [mA] - Miliampere. Resolution 0.01mA. Range [0.00:10000.00]. */ /* - PULSE_CNT_ACC_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [0:1000000]. Accumulative pulse counter (minor). */ /* - PULSE_CNT_ACC_MAJOR - [kNB] - Number of kilopulses. Resolution 1 kilopulse. Range [0:1000000]. */ /* Accumulative pulse counter (major). */ /* - ELEC_METER_ACC_MINOR - [Wh] - Watt-hour. Resolution 1Wh. Range [0:1000000]. Accumulative electricity meter (minor). */ /* - ELEC_METER_ACC_MAJOR - [kWh] - Kilowatt-hour. Resolution 1kWh. Range [0:1000000]. Accumulative electricity meter (major). */ +/* - PULSE_CNT_ACC_WIDE_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [0:999999]. */ +/* Accumulative pulse counter wide range (minor). */ +/* - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] - Number of megapulses. Resolution 1 megapulse. Range [0:999999]. */ +/* Accumulative pulse counter wide range (major). */ +/* - CURRENT_PRECISE - [mA] - Miliampere. Resolution 0.001mA. Range [-4 000.000:4 000.000]. */ +/* - OUTPUT_CONTROL - Not applicable */ /* Encoding R: used to set relative values in the Rules (e.g. differential threshold and hysteresis) */ /* - TEMPERATURE - [°C] - Celsius degree. Resolution 0.1°C. Range [0.1:4273.2]. */ @@ -88,11 +96,19 @@ option java_outer_classname = "ProtoRuleProtos"; /* - CO2_EQUIVALENT - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Carbon dioxide equivalent. */ /* - BREATH_VOC - [ppm] - Parts per million. Resolution 1ppm. Range [1:100000]. Breath VOC estimate. */ /* - PERCENTAGE - [%] - Percentage. Resolution 0.01%. Range [0.01:100.00]. */ +/* - VOLTAGE - [mV] - Milivolt. Resolution 0.1mV. Range [0.1:100000.0]. */ +/* - CURRENT - [mA] - Miliampere. Resolution 0.01mA. Range [0.01:10000.00]. */ /* - PULSE_CNT_ACC_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [1:1000000]. Accumulative pulse counter (minor). */ /* - PULSE_CNT_ACC_MAJOR - [kNB] - Number of kilopulses. Resolution 1 kilopulse. Range [1:1000000]. */ /* Accumulative pulse counter (major). */ /* - ELEC_METER_ACC_MINOR - [Wh] - Watt-hour. Resolution 1Wh. Range [1:1000000]. Accumulative electricity meter (minor). */ /* - ELEC_METER_ACC_MAJOR - [kWh] - Kilowatt-hour. Resolution 1kWh. Range [1:1000000]. Accumulative electricity meter (major). */ +/* - PULSE_CNT_ACC_WIDE_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [1:999999]. */ +/* Accumulative pulse counter wide range (minor). */ +/* - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] - Number of megapulses. Resolution 1 megapulse. Range [1:999999]. */ +/* Accumulative pulse counter wide range (major). */ +/* - CURRENT_PRECISE - [mA] - Miliampere. Resolution 0.001mA. Range [0.001:8 000.000]. */ +/* - OUTPUT_CONTROL - Not applicable */ /* Condition to be checked by the device. If the condition is true, an action is triggered */ enum Condition { @@ -202,6 +218,10 @@ enum Action { /* To trigger the transmission with ACK */ ACTION_TRIGGER_TRANSMISSION_WITH_ACK = 3; + + /* To change BLE advertising period mode to fast (with lower user-configured advertising interval). */ + /* Once the rule is deactived avertising period mode returns to previously configured value. */ + ACTION_FAST_ADVERTISING_MODE = 4; } /* Type of a rule calendars. */ diff --git a/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentTransportResourceTest.java b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentTransportResourceTest.java new file mode 100644 index 0000000000..1b6327d62d --- /dev/null +++ b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentTransportResourceTest.java @@ -0,0 +1,321 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.coap.efento; + +import com.google.protobuf.ByteString; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType; +import org.thingsboard.server.gen.transport.coap.MeasurementsProtos; +import org.thingsboard.server.gen.transport.coap.MeasurementsProtos.ProtoMeasurements; +import org.thingsboard.server.transport.coap.CoapTransportContext; +import org.thingsboard.server.transport.coap.efento.utils.CoapEfentoUtils; + +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_AMBIENT_LIGHT; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_BREATH_VOC; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_CO2_EQUIVALENT; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_CURRENT; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_CURRENT_PRECISE; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_DIFFERENTIAL_PRESSURE; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_DISTANCE_MM; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_ELECTRICITY_METER; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_ELEC_METER_ACC_MAJOR; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_ELEC_METER_ACC_MINOR; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_FLOODING; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_HIGH_PRESSURE; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_HUMIDITY; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_HUMIDITY_ACCURATE; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_IAQ; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_OK_ALARM; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_OUTPUT_CONTROL; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_PERCENTAGE; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_PULSE_CNT; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_PULSE_CNT_ACC_MAJOR; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_PULSE_CNT_ACC_MINOR; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MAJOR; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MINOR; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_SOIL_MOISTURE; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_STATIC_IAQ; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_VOLTAGE; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_WATER_METER; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_WATER_METER_ACC_MAJOR; +import static org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType.MEASUREMENT_TYPE_WATER_METER_ACC_MINOR; +import static org.thingsboard.server.transport.coap.efento.utils.CoapEfentoUtils.convertTimestampToUtcString; + +class CoapEfentTransportResourceTest { + + private static CoapEfentoTransportResource coapEfentoTransportResource; + + @BeforeAll + static void setUp() { + var ctxMock = mock(CoapTransportContext.class); + coapEfentoTransportResource = new CoapEfentoTransportResource(ctxMock, "testName"); + } + + @Test + void checkContinuousSensorWithSomeMeasurements() { + long tsInSec = Instant.now().getEpochSecond(); + ProtoMeasurements measurements = ProtoMeasurements.newBuilder() + .setSerialNum(integerToByteString(1234)) + .setCloudToken("test_token") + .setMeasurementPeriodBase(180) + .setMeasurementPeriodFactor(5) + .setBatteryStatus(true) + .setSignal(0) + .setNextTransmissionAt(1000) + .setTransferReason(0) + .setHash(0) + .addAllChannels(List.of(MeasurementsProtos.ProtoChannel.newBuilder() + .setType(MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) + .setTimestamp(Math.toIntExact(tsInSec)) + .addAllSampleOffsets(List.of(223, 224)) + .build(), + MeasurementsProtos.ProtoChannel.newBuilder() + .setType(MeasurementType.MEASUREMENT_TYPE_HUMIDITY) + .setTimestamp(Math.toIntExact(tsInSec)) + .addAllSampleOffsets(List.of(20, 30)) + .build() + )) + .build(); + List efentoMeasurements = coapEfentoTransportResource.getEfentoMeasurements(measurements, UUID.randomUUID()); + assertThat(efentoMeasurements).hasSize(2); + assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); + assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get("temperature_1").getAsDouble()).isEqualTo(22.3); + assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get("humidity_2").getAsDouble()).isEqualTo(20); + assertThat(efentoMeasurements.get(1).getTs()).isEqualTo((tsInSec + 180 * 5) * 1000); + assertThat(efentoMeasurements.get(1).getValues().getAsJsonObject().get("temperature_1").getAsDouble()).isEqualTo(22.4); + assertThat(efentoMeasurements.get(1).getValues().getAsJsonObject().get("humidity_2").getAsDouble()).isEqualTo(30); + checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 5, false); + } + + @ParameterizedTest + @MethodSource + void checkContinuousSensor(MeasurementType measurementType, List sampleOffsets, String property, double expectedValue) { + long tsInSec = Instant.now().getEpochSecond(); + ProtoMeasurements measurements = ProtoMeasurements.newBuilder() + .setSerialNum(integerToByteString(1234)) + .setCloudToken("test_token") + .setMeasurementPeriodBase(180) + .setMeasurementPeriodFactor(0) + .setBatteryStatus(true) + .setSignal(0) + .setNextTransmissionAt(1000) + .setTransferReason(0) + .setHash(0) + .addAllChannels(List.of(MeasurementsProtos.ProtoChannel.newBuilder() + .setType(measurementType) + .setTimestamp(Math.toIntExact(tsInSec)) + .addAllSampleOffsets(sampleOffsets) + .build() + )) + .build(); + List efentoMeasurements = coapEfentoTransportResource.getEfentoMeasurements(measurements, UUID.randomUUID()); + assertThat(efentoMeasurements).hasSize(1); + assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); + assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get(property).getAsDouble()).isEqualTo(expectedValue); + checkDefaultMeasurements(measurements, efentoMeasurements, 180, false); + } + + private static Stream checkContinuousSensor() { + return Stream.of( + Arguments.of(MEASUREMENT_TYPE_TEMPERATURE, List.of(223), "temperature_1", 22.3), + Arguments.of(MEASUREMENT_TYPE_WATER_METER, List.of(1050), "pulse_counter_water_1", 1050), + Arguments.of(MEASUREMENT_TYPE_HUMIDITY, List.of(20), "humidity_1", 20), + Arguments.of(MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE, List.of(1013), "pressure_1", 101.3), + Arguments.of(MEASUREMENT_TYPE_DIFFERENTIAL_PRESSURE, List.of(500), "pressure_diff_1", 500), + Arguments.of(MEASUREMENT_TYPE_PULSE_CNT, List.of(300), "pulse_cnt_1", 300), + Arguments.of(MEASUREMENT_TYPE_IAQ, List.of(150), "iaq_1", 150), + Arguments.of(MEASUREMENT_TYPE_ELECTRICITY_METER, List.of(1200), "watt_hour_1", 1200), + Arguments.of(MEASUREMENT_TYPE_SOIL_MOISTURE, List.of(35), "soil_moisture_1", 35), + Arguments.of(MEASUREMENT_TYPE_AMBIENT_LIGHT, List.of(500), "ambient_light_1", 50), + Arguments.of(MEASUREMENT_TYPE_HIGH_PRESSURE, List.of(200000), "high_pressure_1", 200000), + Arguments.of(MEASUREMENT_TYPE_DISTANCE_MM, List.of(1500), "distance_mm_1", 1500), + Arguments.of(MEASUREMENT_TYPE_WATER_METER_ACC_MINOR, List.of(125), "acc_counter_water_minor_1", 125), + Arguments.of(MEASUREMENT_TYPE_WATER_METER_ACC_MAJOR, List.of(2500), "acc_counter_water_major_1", 2500), + Arguments.of(MEASUREMENT_TYPE_HUMIDITY_ACCURATE, List.of(525), "humidity_relative_1", 52.5), + Arguments.of(MEASUREMENT_TYPE_STATIC_IAQ, List.of(110), "static_iaq_1", 110), + Arguments.of(MEASUREMENT_TYPE_CO2_EQUIVALENT, List.of(450), "co2_ppm_1", 450), + Arguments.of(MEASUREMENT_TYPE_BREATH_VOC, List.of(220), "breath_voc_ppm_1", 220), + Arguments.of(MEASUREMENT_TYPE_PERCENTAGE, List.of(80), "percentage_1", 0.80), + Arguments.of(MEASUREMENT_TYPE_VOLTAGE, List.of(2400), "voltage_1", 240), + Arguments.of(MEASUREMENT_TYPE_CURRENT, List.of(550), "current_1", 5.5), + Arguments.of(MEASUREMENT_TYPE_PULSE_CNT_ACC_MINOR, List.of(180), "pulse_cnt_minor_1", 180), + Arguments.of(MEASUREMENT_TYPE_PULSE_CNT_ACC_MAJOR, List.of(1200), "pulse_cnt_major_1", 1200), + Arguments.of(MEASUREMENT_TYPE_ELEC_METER_ACC_MINOR, List.of(550), "elec_meter_minor_1", 550), + Arguments.of(MEASUREMENT_TYPE_ELEC_METER_ACC_MAJOR, List.of(5500), "elec_meter_major_1", 5500), + Arguments.of(MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MINOR, List.of(230), "pulse_cnt_wide_minor_1", 230), + Arguments.of(MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MAJOR, List.of(1700), "pulse_cnt_wide_major_1", 1700), + Arguments.of(MEASUREMENT_TYPE_CURRENT_PRECISE, List.of(275), "current_precise_1", 0.275) + ); + } + + @Test + void checkBinarySensor() { + long tsInSec = Instant.now().getEpochSecond(); + ProtoMeasurements measurements = ProtoMeasurements.newBuilder() + .setSerialNum(integerToByteString(1234)) + .setCloudToken("test_token") + .setMeasurementPeriodBase(180) + .setMeasurementPeriodFactor(0) + .setBatteryStatus(true) + .setSignal(0) + .setNextTransmissionAt(1000) + .setTransferReason(0) + .setHash(0) + .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() + .setType(MEASUREMENT_TYPE_OK_ALARM) + .setTimestamp(Math.toIntExact(tsInSec)) + .addAllSampleOffsets(List.of(1, 1)) + .build()) + .build(); + List efentoMeasurements = coapEfentoTransportResource.getEfentoMeasurements(measurements, UUID.randomUUID()); + assertThat(efentoMeasurements).hasSize(1); + assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); + assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get("ok_alarm_1").getAsString()).isEqualTo("ALARM"); + checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 14, true); + } + + @ParameterizedTest + @MethodSource + void checkBinarySensorWhenValueIsVarying(MeasurementType measurementType, String property, String expectedValueWhenOffsetNotOk, String expectedValueWhenOffsetOk) { + long tsInSec = Instant.now().getEpochSecond(); + ProtoMeasurements measurements = ProtoMeasurements.newBuilder() + .setSerialNum(integerToByteString(1234)) + .setCloudToken("test_token") + .setMeasurementPeriodBase(180) + .setMeasurementPeriodFactor(1) + .setBatteryStatus(true) + .setSignal(0) + .setNextTransmissionAt(1000) + .setTransferReason(0) + .setHash(0) + .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() + .setType(measurementType) + .setTimestamp(Math.toIntExact(tsInSec)) + .addAllSampleOffsets(List.of(1, -10)) + .build()) + .build(); + List efentoMeasurements = coapEfentoTransportResource.getEfentoMeasurements(measurements, UUID.randomUUID()); + assertThat(efentoMeasurements).hasSize(2); + assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); + assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get(property).getAsString()).isEqualTo(expectedValueWhenOffsetNotOk); + assertThat(efentoMeasurements.get(1).getTs()).isEqualTo((tsInSec + 9) * 1000); + assertThat(efentoMeasurements.get(1).getValues().getAsJsonObject().get(property).getAsString()).isEqualTo(expectedValueWhenOffsetOk); + checkDefaultMeasurements(measurements, efentoMeasurements, 180, true); + } + + private static Stream checkBinarySensorWhenValueIsVarying() { + return Stream.of( + Arguments.of(MEASUREMENT_TYPE_OK_ALARM, "ok_alarm_1", "ALARM", "OK"), + Arguments.of(MEASUREMENT_TYPE_FLOODING, "flooding_1", "WATER_DETECTED", "OK"), + Arguments.of(MEASUREMENT_TYPE_OUTPUT_CONTROL, "output_control_1", "ON", "OFF") + ); + } + + @Test + void checkExceptionWhenChannelsListIsEmpty() { + ProtoMeasurements measurements = ProtoMeasurements.newBuilder() + .setSerialNum(integerToByteString(1234)) + .setCloudToken("test_token") + .setMeasurementPeriodBase(180) + .setMeasurementPeriodFactor(1) + .setBatteryStatus(true) + .setSignal(0) + .setNextTransmissionAt(1000) + .setTransferReason(0) + .setHash(0) + .build(); + UUID sessionId = UUID.randomUUID(); + + assertThatThrownBy(() -> coapEfentoTransportResource.getEfentoMeasurements(measurements, sessionId)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("[" + sessionId + "]: Failed to get Efento measurements, reason: channels list is empty!"); + } + + @Test + void checkExceptionWhenValuesMapIsEmpty() { + long tsInSec = Instant.now().getEpochSecond(); + ProtoMeasurements measurements = ProtoMeasurements.newBuilder() + .setSerialNum(integerToByteString(1234)) + .setCloudToken("test_token") + .setMeasurementPeriodBase(180) + .setMeasurementPeriodFactor(1) + .setBatteryStatus(true) + .setSignal(0) + .setNextTransmissionAt(1000) + .setTransferReason(0) + .setHash(0) + .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() + .setType(MEASUREMENT_TYPE_TEMPERATURE) + .setTimestamp(Math.toIntExact(tsInSec)) + .build()) + .build(); + UUID sessionId = UUID.randomUUID(); + + assertThatThrownBy(() -> coapEfentoTransportResource.getEfentoMeasurements(measurements, sessionId)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("[" + sessionId + "]: Failed to collect Efento measurements, reason, values map is empty!"); + } + + public static ByteString integerToByteString(Integer intValue) { + // Allocate a ByteBuffer with the size of an integer (4 bytes) + ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES); + + // Put the integer value into the ByteBuffer + buffer.putInt(intValue); + + // Convert the ByteBuffer to a byte array + byte[] byteArray = buffer.array(); + + // Create a ByteString from the byte array + return ByteString.copyFrom(byteArray); + } + + private void checkDefaultMeasurements(ProtoMeasurements incomingMeasurements, + List actualEfentoMeasurements, + long expectedMeasurementInterval, + boolean isBinarySensor) { + for (int i = 0; i < actualEfentoMeasurements.size(); i++) { + CoapEfentoTransportResource.EfentoTelemetry actualEfentoMeasurement = actualEfentoMeasurements.get(i); + assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("serial").getAsString()).isEqualTo(CoapEfentoUtils.convertByteArrayToString(incomingMeasurements.getSerialNum().toByteArray())); + assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("battery").getAsString()).isEqualTo(incomingMeasurements.getBatteryStatus() ? "ok" : "low"); + MeasurementsProtos.ProtoChannel protoChannel = incomingMeasurements.getChannelsList().get(0); + long measuredAt = isBinarySensor ? + TimeUnit.SECONDS.toMillis(protoChannel.getTimestamp()) + Math.abs(TimeUnit.SECONDS.toMillis(protoChannel.getSampleOffsetsList().get(i))) - 1000 : + TimeUnit.SECONDS.toMillis(protoChannel.getTimestamp() + i * expectedMeasurementInterval); + assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("measured_at").getAsString()).isEqualTo(convertTimestampToUtcString(measuredAt)); + assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("next_transmission_at").getAsString()).isEqualTo(convertTimestampToUtcString(TimeUnit.SECONDS.toMillis(incomingMeasurements.getNextTransmissionAt()))); + assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("signal").getAsLong()).isEqualTo(incomingMeasurements.getSignal()); + assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("measurement_interval").getAsDouble()).isEqualTo(expectedMeasurementInterval); + } + } + +} diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index 58a5629dae..7e5711e1de 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -21,9 +21,14 @@ import com.google.gson.JsonParser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.core.io.ByteArrayResource; import org.springframework.http.HttpHeaders; @@ -31,6 +36,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -38,6 +44,8 @@ import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.StringUtils; @@ -45,11 +53,11 @@ import org.thingsboard.server.common.data.TbTransportService; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.ota.OtaPackageType; import org.thingsboard.server.common.data.rpc.RpcStatus; +import org.thingsboard.server.common.msg.tools.MaxPayloadSizeExceededException; import org.thingsboard.server.common.transport.SessionMsgListener; import org.thingsboard.server.common.transport.TransportContext; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; -import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.transport.auth.SessionInfoCreator; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; import org.thingsboard.server.gen.transport.TransportProtos; @@ -68,7 +76,7 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMs import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; -import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; import java.util.Arrays; import java.util.List; import java.util.UUID; @@ -128,6 +136,9 @@ public class DeviceApiController implements TbTransportService { private static final String ACCESS_TOKEN_PARAM_DESCRIPTION = "Your device access token."; + @Value("${transport.http.max_payload_size:65536}") + private int maxPayloadSize; + @Autowired private HttpTransportContext transportContext; @@ -191,8 +202,8 @@ public class DeviceApiController implements TbTransportService { return responseWriter; } - @Operation(summary = "Post time-series data (postTelemetry)", - description = "Post time-series data on behalf of device. " + @Operation(summary = "Post time series data (postTelemetry)", + description = "Post time series data on behalf of device. " + "\n Example of the request: " + TS_PAYLOAD + REQUIRE_ACCESS_TOKEN) @@ -265,6 +276,11 @@ public class DeviceApiController implements TbTransportService { @Operation(summary = "Reply to RPC commands (replyToCommand)", description = "Replies to server originated RPC command identified by 'requestId' parameter. The response is arbitrary JSON.\n\n" + REQUIRE_ACCESS_TOKEN) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "RPC reply to command request was sent to Core."), + @ApiResponse(responseCode = "400", description = "Invalid structure of the request."), + @ApiResponse(responseCode = "413", description = "Request payload is too large."), + }) @RequestMapping(value = "/{deviceToken}/rpc/{requestId}", method = RequestMethod.POST) public DeferredResult replyToCommand( @Parameter(description = ACCESS_TOKEN_PARAM_DESCRIPTION, required = true , schema = @Schema(defaultValue = "YOUR_DEVICE_ACCESS_TOKEN")) @@ -272,7 +288,8 @@ public class DeviceApiController implements TbTransportService { @Parameter(description = "RPC request id from the incoming RPC request", required = true , schema = @Schema(defaultValue = "123")) @PathVariable("requestId") Integer requestId, @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Reply to the RPC request, JSON. For example: {\"status\":\"success\"}", required = true) - @RequestBody String json) { + @RequestBody String json, HttpServletRequest httpServletRequest) { + checkPayloadSize(httpServletRequest); DeferredResult responseWriter = new DeferredResult(); transportContext.getTransportService().process(DeviceTransportType.DEFAULT, ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { @@ -292,12 +309,18 @@ public class DeviceApiController implements TbTransportService { "{\"result\": 4}" + MARKDOWN_CODE_BLOCK_END + REQUIRE_ACCESS_TOKEN) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "RPC request to server was sent to Rule Engine."), + @ApiResponse(responseCode = "400", description = "Invalid structure of the request."), + @ApiResponse(responseCode = "413", description = "Request payload too large."), + }) @RequestMapping(value = "/{deviceToken}/rpc", method = RequestMethod.POST) public DeferredResult postRpcRequest( @Parameter(description = ACCESS_TOKEN_PARAM_DESCRIPTION, required = true , schema = @Schema(defaultValue = "YOUR_DEVICE_ACCESS_TOKEN")) @PathVariable("deviceToken") String deviceToken, @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "The RPC request JSON", required = true) - @RequestBody String json) { + @RequestBody String json, HttpServletRequest httpServletRequest) { + checkPayloadSize(httpServletRequest); DeferredResult responseWriter = new DeferredResult(); transportContext.getTransportService().process(DeviceTransportType.DEFAULT, ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { @@ -420,6 +443,12 @@ public class DeviceApiController implements TbTransportService { return responseWriter; } + private void checkPayloadSize(HttpServletRequest httpServletRequest) { + if (httpServletRequest.getContentLength() > maxPayloadSize) { + throw new MaxPayloadSizeExceededException(); + } + } + private DeferredResult getOtaPackageCallback(String deviceToken, String title, String version, int size, int chunk, OtaPackageType firmwareType) { DeferredResult responseWriter = new DeferredResult<>(); transportContext.getTransportService().process(DeviceTransportType.DEFAULT, ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), @@ -608,6 +637,20 @@ public class DeviceApiController implements TbTransportService { } + @ExceptionHandler(MaxPayloadSizeExceededException.class) + public void handle(MaxPayloadSizeExceededException exception, HttpServletRequest request, HttpServletResponse response) { + log.debug("Too large payload size. Url: {}, client ip: {}, content length: {}", request.getRequestURL(), + request.getRemoteAddr(), request.getContentLength()); + if (!response.isCommitted()) { + try { + response.setStatus(HttpStatus.PAYLOAD_TOO_LARGE.value()); + JacksonUtil.writeValue(response.getWriter(), exception.getMessage()); + } catch (IOException e) { + log.error("Can't handle exception", e); + } + } + } + private static MediaType parseMediaType(String contentType) { try { return MediaType.parseMediaType(contentType); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/TbLwM2MDtlsBootstrapCertificateVerifier.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/TbLwM2MDtlsBootstrapCertificateVerifier.java index b341b09b70..d31d47fcd4 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/TbLwM2MDtlsBootstrapCertificateVerifier.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/TbLwM2MDtlsBootstrapCertificateVerifier.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.transport.lwm2m.bootstrap.secure; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.eclipse.californium.elements.auth.RawPublicKeyIdentity; @@ -41,7 +42,6 @@ import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; import org.thingsboard.server.transport.lwm2m.secure.TbLwM2MSecurityInfo; import org.thingsboard.server.transport.lwm2m.server.client.LwM2MAuthException; -import jakarta.annotation.PostConstruct; import javax.security.auth.x500.X500Principal; import java.net.InetSocketAddress; import java.security.PublicKey; @@ -82,7 +82,7 @@ public class TbLwM2MDtlsBootstrapCertificateVerifier implements NewAdvancedCerti staticCertificateVerifier = new StaticNewAdvancedCertificateVerifier(trustedCertificates, new RawPublicKeyIdentity[0], null); } } catch (Exception e) { - log.warn("ailed to initialize the LwM2M certificate verifier", e); + log.warn("Failed to initialize the LwM2M certificate verifier", e); } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/secure/TbLwM2MDtlsCertificateVerifier.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/secure/TbLwM2MDtlsCertificateVerifier.java index 64ab50ca3b..ae737d2343 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/secure/TbLwM2MDtlsCertificateVerifier.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/secure/TbLwM2MDtlsCertificateVerifier.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.transport.lwm2m.secure; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.eclipse.californium.elements.auth.RawPublicKeyIdentity; @@ -47,7 +48,6 @@ import org.thingsboard.server.transport.lwm2m.server.client.LwM2MAuthException; import org.thingsboard.server.transport.lwm2m.server.store.TbLwM2MDtlsSessionStore; import org.thingsboard.server.transport.lwm2m.server.store.TbMainSecurityStore; -import jakarta.annotation.PostConstruct; import javax.security.auth.x500.X500Principal; import java.net.InetSocketAddress; import java.security.PublicKey; diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mVersionedModelProvider.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mVersionedModelProvider.java index 78f76adc1d..ab52a024da 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mVersionedModelProvider.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mVersionedModelProvider.java @@ -108,9 +108,9 @@ public class LwM2mVersionedModelProvider implements LwM2mModelProvider { @Override public ObjectModel getObjectModel(int objectId) { LwM2mClient lwM2mClient = lwM2mClientContext.getClientByEndpoint(registration.getEndpoint()); - String version = lwM2mClient.getSupportedObjectVersion(objectId).toString(); + var version = lwM2mClient.getSupportedObjectVersion(objectId); if (version != null) { - return this.getObjectModelDynamic(objectId, version); + return this.getObjectModelDynamic(objectId, version.toString()); } return null; } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/model/LwM2MModelConfigServiceImpl.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/model/LwM2MModelConfigServiceImpl.java index 1e1cc5ddce..403ec7f0a7 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/model/LwM2MModelConfigServiceImpl.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/model/LwM2MModelConfigServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.transport.lwm2m.server.model; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; @@ -38,7 +39,6 @@ import org.thingsboard.server.transport.lwm2m.server.log.LwM2MTelemetryLogServic import org.thingsboard.server.transport.lwm2m.server.store.TbLwM2MModelConfigStore; import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; -import jakarta.annotation.PreDestroy; import java.util.List; import java.util.Map; import java.util.Set; diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java index 0b5e995ea5..f779ae167e 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.transport.lwm2m.server.ota; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.eclipse.leshan.core.node.codec.CodecException; @@ -57,8 +59,6 @@ import org.thingsboard.server.transport.lwm2m.server.ota.software.SoftwareUpdate import org.thingsboard.server.transport.lwm2m.server.store.TbLwM2MClientOtaInfoStore; import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.ArrayList; import java.util.List; import java.util.Map; diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java index 0fff841685..4655a27316 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java @@ -58,9 +58,9 @@ import org.thingsboard.server.transport.lwm2m.server.downlink.TbLwM2MWriteAttrib import org.thingsboard.server.transport.lwm2m.server.downlink.TbLwM2MWriteReplaceRequest; import org.thingsboard.server.transport.lwm2m.server.downlink.TbLwM2MWriteResponseCallback; import org.thingsboard.server.transport.lwm2m.server.downlink.TbLwM2MWriteUpdateRequest; -import org.thingsboard.server.transport.lwm2m.server.downlink.composite.TbLwM2MObserveCompositeCallback; import org.thingsboard.server.transport.lwm2m.server.downlink.composite.TbLwM2MCancelObserveCompositeCallback; import org.thingsboard.server.transport.lwm2m.server.downlink.composite.TbLwM2MCancelObserveCompositeRequest; +import org.thingsboard.server.transport.lwm2m.server.downlink.composite.TbLwM2MObserveCompositeCallback; import org.thingsboard.server.transport.lwm2m.server.downlink.composite.TbLwM2MObserveCompositeRequest; import org.thingsboard.server.transport.lwm2m.server.downlink.composite.TbLwM2MReadCompositeCallback; import org.thingsboard.server.transport.lwm2m.server.downlink.composite.TbLwM2MReadCompositeRequest; diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientTest.java index 7d4d795738..1441e036a0 100644 --- a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientTest.java +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientTest.java @@ -19,8 +19,8 @@ import org.eclipse.leshan.core.endpoint.EndpointUriUtil; import org.eclipse.leshan.core.link.Link; import org.eclipse.leshan.core.peer.IpPeer; import org.eclipse.leshan.server.registration.Registration; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import java.net.InetSocketAddress; diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java index 9556180300..16d72ff89f 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -16,6 +16,7 @@ package org.thingsboard.server.transport.mqtt; import io.netty.handler.ssl.SslHandler; +import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; @@ -27,7 +28,6 @@ import org.thingsboard.server.common.transport.TransportContext; import org.thingsboard.server.transport.mqtt.adaptors.JsonMqttAdaptor; import org.thingsboard.server.transport.mqtt.adaptors.ProtoMqttAdaptor; -import jakarta.annotation.PostConstruct; import java.net.InetSocketAddress; import java.util.concurrent.atomic.AtomicInteger; diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java index fe91657f15..ffc7e9e561 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java @@ -23,6 +23,8 @@ import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.util.AttributeKey; import io.netty.util.ResourceLeakDetector; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -31,8 +33,6 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.TbTransportService; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.net.InetSocketAddress; /** diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java index 8cd215f1f1..0970fe7882 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java @@ -27,11 +27,11 @@ import io.netty.handler.codec.mqtt.MqttPublishMessage; import io.netty.handler.codec.mqtt.MqttPublishVariableHeader; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.adaptor.AdaptorException; +import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.device.profile.MqttTopics; import org.thingsboard.server.common.data.ota.OtaPackageType; -import org.thingsboard.server.common.adaptor.AdaptorException; -import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.transport.mqtt.session.MqttDeviceAwareSessionContext; diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java index 9cf15f9de1..ec2aa09988 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java @@ -23,8 +23,8 @@ import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttMessageType; import io.netty.handler.codec.mqtt.MqttPublishMessage; import io.netty.handler.codec.mqtt.MqttPublishVariableHeader; -import org.thingsboard.server.common.data.ota.OtaPackageType; import org.thingsboard.server.common.adaptor.AdaptorException; +import org.thingsboard.server.common.data.ota.OtaPackageType; import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ClaimDeviceMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportHandlerTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportHandlerTest.java index 250c33441c..d6e91ec1fd 100644 --- a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportHandlerTest.java +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportHandlerTest.java @@ -34,7 +34,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.Spy; -import org.mockito.junit.MockitoJUnitRunner; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.DataConstants; @@ -68,7 +67,6 @@ import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.willDoNothing; -import static org.mockito.BDDMockito.willReturn; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandlerTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandlerTest.java index 670bd98af1..4a71730a5c 100644 --- a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandlerTest.java +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandlerTest.java @@ -19,7 +19,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; -import org.mockito.MockitoAnnotations; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.server.common.data.id.CustomerId; @@ -36,13 +35,11 @@ import java.util.concurrent.ConcurrentHashMap; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.willCallRealMethod; +import static org.mockito.Mockito.any; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) public class GatewaySessionHandlerTest { diff --git a/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java b/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java index 12008c10b5..3a1d440d20 100644 --- a/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java +++ b/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java @@ -22,6 +22,8 @@ import com.google.common.util.concurrent.ListeningScheduledExecutorService; import com.google.common.util.concurrent.MoreExecutors; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Builder; import lombok.Data; import lombok.Getter; @@ -65,8 +67,6 @@ import org.thingsboard.server.transport.snmp.SnmpTransportContext; import org.thingsboard.server.transport.snmp.session.DeviceSessionContext; import org.thingsboard.server.transport.snmp.session.ScheduledTask; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java index b0c085b2e9..a1ed00844d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java @@ -16,6 +16,8 @@ package org.thingsboard.server.common.transport; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Data; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -26,8 +28,6 @@ import org.thingsboard.server.common.transport.limits.TransportRateLimitService; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import org.thingsboard.server.queue.scheduler.SchedulerComponent; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.concurrent.ExecutorService; /** diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java index c758d707fc..49630f7c02 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java @@ -15,11 +15,10 @@ */ package org.thingsboard.server.common.transport.config.ssl; +import jakarta.annotation.PostConstruct; import lombok.Data; import lombok.extern.slf4j.Slf4j; -import jakarta.annotation.PostConstruct; - @Slf4j @Data public class SslCredentialsConfig { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index cc91baca0c..8e48061db6 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -948,7 +948,7 @@ public class DefaultTransportService extends TransportActivityManager implements String resourceId = msg.getResourceKey(); transportResourceCache.evict(tenantId, resourceType, resourceId); sessions.forEach((id, mdRez) -> { - log.warn("ResourceDelete - [{}] [{}]", id, mdRez); + log.trace("ResourceDelete - [{}] [{}]", id, mdRez); transportCallbackExecutor.submit(() -> mdRez.getListener().onResourceDelete(msg)); }); } else if (toSessionMsg.getQueueUpdateMsgsCount() > 0) { diff --git a/common/util/src/main/java/org/thingsboard/common/util/AbstractListeningExecutor.java b/common/util/src/main/java/org/thingsboard/common/util/AbstractListeningExecutor.java index 7f8df67ce9..bb205a122e 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/AbstractListeningExecutor.java +++ b/common/util/src/main/java/org/thingsboard/common/util/AbstractListeningExecutor.java @@ -18,9 +18,9 @@ package org.thingsboard.common.util; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; - import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; + import java.util.concurrent.Callable; /** diff --git a/common/util/src/main/java/org/thingsboard/common/util/DeduplicationUtil.java b/common/util/src/main/java/org/thingsboard/common/util/DeduplicationUtil.java new file mode 100644 index 0000000000..fd25e8b924 --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/DeduplicationUtil.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.common.util; + +import org.springframework.util.ConcurrentReferenceHashMap; + +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.springframework.util.ConcurrentReferenceHashMap.ReferenceType.SOFT; + +public class DeduplicationUtil { + + private static final ConcurrentMap cache = new ConcurrentReferenceHashMap<>(16, SOFT); + + public static boolean alreadyProcessed(Object deduplicationKey, long deduplicationDuration) { + AtomicBoolean alreadyProcessed = new AtomicBoolean(false); + cache.compute(deduplicationKey, (key, lastProcessedTs) -> { + if (lastProcessedTs != null) { + long passed = System.currentTimeMillis() - lastProcessedTs; + if (passed <= deduplicationDuration) { + alreadyProcessed.set(true); + return lastProcessedTs; + } + } + return System.currentTimeMillis(); + }); + return alreadyProcessed.get(); + } + +} diff --git a/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultClusterVersionControlService.java b/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultClusterVersionControlService.java index 34171c19cd..99401bbdf0 100644 --- a/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultClusterVersionControlService.java +++ b/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultClusterVersionControlService.java @@ -202,7 +202,7 @@ public class DefaultClusterVersionControlService extends TbApplicationEventListe try { Futures.allAsList(futures).get(packProcessingTimeout, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { - log.info("Timeout for processing the version control tasks.", e); + log.error("Timeout for processing the version control tasks.", e); } consumer.commit(); } diff --git a/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitRepositoryService.java b/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitRepositoryService.java index 46f4f83a04..bf3e202c2d 100644 --- a/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitRepositoryService.java +++ b/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitRepositoryService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.sync.vc; +import jakarta.annotation.PostConstruct; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.FileUtils; @@ -36,7 +37,6 @@ import org.thingsboard.server.common.data.sync.vc.VersionCreationResult; import org.thingsboard.server.common.data.sync.vc.VersionedEntityInfo; import org.thingsboard.server.service.sync.vc.GitRepository.Diff; -import jakarta.annotation.PostConstruct; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -81,13 +81,20 @@ public class DefaultGitRepositoryService implements GitRepositoryService { GitRepository repository = checkRepository(commit.getTenantId()); String branch = commit.getBranch(); try { - repository.fetch(); - - repository.createAndCheckoutOrphanBranch(commit.getWorkingBranch()); - repository.resetAndClean(); - - if (repository.listRemoteBranches().contains(new BranchInfo(branch, false))) { - repository.merge(branch); + List branches = repository.listBranches().stream().map(BranchInfo::getName).toList(); + if (repository.getSettings().isLocalOnly()) { + if (branches.contains(commit.getBranch())) { + repository.checkoutBranch(commit.getBranch()); + } else { + repository.createAndCheckoutOrphanBranch(commit.getBranch()); + } + repository.resetAndClean(); + } else { + repository.createAndCheckoutOrphanBranch(commit.getWorkingBranch()); + repository.resetAndClean(); + if (branches.contains(branch)) { + repository.merge(branch); + } } } catch (IOException | GitAPIException gitAPIException) { //TODO: analyze and return meaningful exceptions that we can show to the client; @@ -185,7 +192,7 @@ public class DefaultGitRepositoryService implements GitRepositoryService { public List listBranches(TenantId tenantId) { GitRepository repository = checkRepository(tenantId); try { - return repository.listRemoteBranches(); + return repository.listBranches(); } catch (GitAPIException gitAPIException) { //TODO: analyze and return meaningful exceptions that we can show to the client; throw new RuntimeException(gitAPIException); @@ -233,9 +240,9 @@ public class DefaultGitRepositoryService implements GitRepositoryService { @Override public void initRepository(TenantId tenantId, RepositorySettings settings) throws Exception { - testRepository(tenantId, settings); - - clearRepository(tenantId); + if (!settings.isLocalOnly()) { + clearRepository(tenantId); + } cloneRepository(tenantId, settings); } @@ -247,11 +254,10 @@ public class DefaultGitRepositoryService implements GitRepositoryService { @Override public void clearRepository(TenantId tenantId) throws IOException { - GitRepository repository = repositories.get(tenantId); + GitRepository repository = repositories.remove(tenantId); if (repository != null) { log.debug("[{}] Clear tenant repository started.", tenantId); FileUtils.deleteDirectory(new File(repository.getDirectory())); - repositories.remove(tenantId); log.debug("[{}] Clear tenant repository completed.", tenantId); } } @@ -276,15 +282,23 @@ public class DefaultGitRepositoryService implements GitRepositoryService { private GitRepository cloneRepository(TenantId tenantId, RepositorySettings settings) throws Exception { log.debug("[{}] Init tenant repository started.", tenantId); - Path repositoryDirectory = Path.of(repositoriesFolder, tenantId.getId().toString()); + Path repositoryDirectory = Path.of(repositoriesFolder, settings.isLocalOnly() ? "local_" + settings.getRepositoryUri() : tenantId.getId().toString()); + GitRepository repository; if (Files.exists(repositoryDirectory)) { - FileUtils.forceDelete(repositoryDirectory.toFile()); + repository = GitRepository.open(repositoryDirectory.toFile(), settings); + } else { + Files.createDirectories(repositoryDirectory); + if (settings.isLocalOnly()) { + repository = GitRepository.create(settings, repositoryDirectory.toFile()); + } else { + repository = GitRepository.clone(settings, repositoryDirectory.toFile()); + } } - Files.createDirectories(repositoryDirectory); - GitRepository repository = GitRepository.clone(settings, repositoryDirectory.toFile()); + repositories.put(tenantId, repository); log.debug("[{}] Init tenant repository completed.", tenantId); return repository; } + } diff --git a/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java b/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java index 7072819d1e..aaa4c22289 100644 --- a/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java +++ b/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java @@ -21,13 +21,13 @@ import com.google.common.collect.Streams; import lombok.Data; import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; import org.apache.sshd.common.util.security.SecurityUtils; import org.eclipse.jgit.api.CloneCommand; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.GitCommand; -import org.eclipse.jgit.api.ListBranchCommand; import org.eclipse.jgit.api.LogCommand; import org.eclipse.jgit.api.LsRemoteCommand; import org.eclipse.jgit.api.ResetCommand; @@ -87,6 +87,9 @@ import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; +import static org.eclipse.jgit.api.ListBranchCommand.ListMode; + +@Slf4j public class GitRepository { private final Git git; @@ -106,7 +109,16 @@ public class GitRepository { this.directory = directory; } + public static GitRepository create(RepositorySettings settings, File directory) throws GitAPIException { + log.debug("Executing create [{}]", directory); + Git git = Git.init() + .setDirectory(directory) + .call(); + return new GitRepository(git, settings, null, directory.getAbsolutePath()); + } + public static GitRepository clone(RepositorySettings settings, File directory) throws GitAPIException { + log.debug("Executing clone [{}]", settings.getRepositoryUri()); CloneCommand cloneCommand = Git.cloneRepository() .setURI(settings.getRepositoryUri()) .setDirectory(directory) @@ -118,12 +130,17 @@ public class GitRepository { } public static GitRepository open(File directory, RepositorySettings settings) throws IOException { + log.debug("Executing open [{}][{}]", settings.getRepositoryUri(), directory); Git git = Git.open(directory); AuthHandler authHandler = AuthHandler.createFor(settings, directory); return new GitRepository(git, settings, authHandler, directory.getAbsolutePath()); } public static void test(RepositorySettings settings, File directory) throws Exception { + if (settings.isLocalOnly()) { + return; + } + log.debug("Executing test [{}]", settings.getRepositoryUri()); AuthHandler authHandler = AuthHandler.createFor(settings, directory); if (settings.isReadOnly()) { LsRemoteCommand lsRemoteCommand = Git.lsRemoteRepository().setRemote(settings.getRepositoryUri()); @@ -147,6 +164,10 @@ public class GitRepository { } public void fetch() throws GitAPIException { + if (settings.isLocalOnly()) { + return; + } + log.debug("Executing fetch [{}]", settings.getRepositoryUri()); FetchResult result = execute(git.fetch() .setRemoveDeletedRefs(true)); Ref head = result.getAdvertisedRef(Constants.HEAD); @@ -156,12 +177,14 @@ public class GitRepository { } public void deleteLocalBranchIfExists(String branch) throws GitAPIException { + log.debug("Executing deleteLocalBranchIfExists [{}][{}]", settings.getRepositoryUri(), branch); execute(git.branchDelete() .setBranchNames(branch) .setForce(true)); } public void resetAndClean() throws GitAPIException { + log.debug("Executing resetAndClean [{}]", settings.getRepositoryUri()); execute(git.reset() .setMode(ResetCommand.ResetType.HARD)); execute(git.clean() @@ -170,6 +193,7 @@ public class GitRepository { } public void merge(String branch) throws IOException, GitAPIException { + log.debug("Executing merge [{}][{}]", settings.getRepositoryUri(), branch); ObjectId branchId = resolve("origin/" + branch); if (branchId == null) { throw new IllegalArgumentException("Branch not found"); @@ -178,9 +202,10 @@ public class GitRepository { .include(branchId)); } - public List listRemoteBranches() throws GitAPIException { + public List listBranches() throws GitAPIException { + log.debug("Executing listBranches [{}]", settings.getRepositoryUri()); return execute(git.branchList() - .setListMode(ListBranchCommand.ListMode.REMOTE)).stream() + .setListMode(settings.isLocalOnly() ? ListMode.ALL : ListMode.REMOTE)).stream() .filter(ref -> !ref.getName().equals(Constants.HEAD)) .map(this::toBranchInfo) .distinct().collect(Collectors.toList()); @@ -191,6 +216,7 @@ public class GitRepository { } public PageData listCommits(String branch, String path, PageLink pageLink) throws IOException, GitAPIException { + log.debug("Executing listCommits [{}][{}][{}]", settings.getRepositoryUri(), branch, path); ObjectId branchId = resolve("origin/" + branch); if (branchId == null) { return new PageData<>(); @@ -212,6 +238,7 @@ public class GitRepository { } public List listFilesAtCommit(String commitId, String path) throws IOException { + log.debug("Executing listFilesAtCommit [{}][{}][{}]", settings.getRepositoryUri(), commitId, path); List files = new ArrayList<>(); RevCommit revCommit = resolveCommit(commitId); try (TreeWalk treeWalk = new TreeWalk(git.getRepository())) { @@ -229,6 +256,7 @@ public class GitRepository { public String getFileContentAtCommit(String file, String commitId) throws IOException { + log.debug("Executing getFileContentAtCommit [{}][{}][{}]", settings.getRepositoryUri(), commitId, file); RevCommit revCommit = resolveCommit(commitId); try (TreeWalk treeWalk = TreeWalk.forPath(git.getRepository(), file, revCommit.getTree())) { if (treeWalk == null) { @@ -249,18 +277,29 @@ public class GitRepository { public void createAndCheckoutOrphanBranch(String name) throws GitAPIException { + log.debug("Executing createAndCheckoutOrphanBranch [{}][{}]", settings.getRepositoryUri(), name); execute(git.checkout() .setOrphan(true) .setForced(true) .setName(name)); } + public void checkoutBranch(String name) throws GitAPIException { + log.debug("Executing checkoutBranch [{}][{}]", settings.getRepositoryUri(), name); + git.checkout() + .setForced(true) + .setName(name) + .call(); + } + public void add(String filesPattern) throws GitAPIException { + log.debug("Executing add [{}][{}]", settings.getRepositoryUri(), filesPattern); execute(git.add().setUpdate(true).addFilepattern(filesPattern)); execute(git.add().addFilepattern(filesPattern)); } public Status status() throws GitAPIException { + log.debug("Executing status [{}]", settings.getRepositoryUri()); org.eclipse.jgit.api.Status status = execute(git.status()); Set modified = new HashSet<>(); modified.addAll(status.getModified()); @@ -269,6 +308,7 @@ public class GitRepository { } public Commit commit(String message, String authorName, String authorEmail) throws GitAPIException { + log.debug("Executing commit [{}][{}]", settings.getRepositoryUri(), message); RevCommit revCommit = execute(git.commit() .setAuthor(authorName, authorEmail) .setMessage(message)); @@ -277,6 +317,10 @@ public class GitRepository { public void push(String localBranch, String remoteBranch) throws GitAPIException { + if (settings.isLocalOnly()) { + return; + } + log.debug("Executing push [{}][{}]", settings.getRepositoryUri(), remoteBranch); execute(git.push() .setRefSpecs(new RefSpec(localBranch + ":" + remoteBranch))); } @@ -355,6 +399,9 @@ public class GitRepository { } private ObjectId resolve(String rev) throws IOException { + if (settings.isLocalOnly()) { + rev = StringUtils.removeStart(rev, "origin/"); + } ObjectId result = git.getRepository().resolve(rev); if (result == null) { throw new IllegalArgumentException("Failed to parse git revision string: \"" + rev + "\""); @@ -363,8 +410,8 @@ public class GitRepository { } private , T> T execute(C command) throws GitAPIException { - if (command instanceof TransportCommand) { - authHandler.configureCommand((TransportCommand) command); + if (command instanceof TransportCommand transportCommand && authHandler != null) { + authHandler.configureCommand(transportCommand); } return command.call(); } @@ -412,11 +459,17 @@ public class GitRepository { private final SshdSessionFactory sshSessionFactory; protected static AuthHandler createFor(RepositorySettings settings, File directory) { + if (settings.isLocalOnly()) { + return null; + } CredentialsProvider credentialsProvider = null; SshdSessionFactory sshSessionFactory = null; if (RepositoryAuthMethod.USERNAME_PASSWORD.equals(settings.getAuthMethod())) { credentialsProvider = newCredentialsProvider(settings.getUsername(), settings.getPassword()); } else if (RepositoryAuthMethod.PRIVATE_KEY.equals(settings.getAuthMethod())) { + if (StringUtils.startsWith(settings.getRepositoryUri(), "https://")) { + throw new IllegalArgumentException("Invalid URI format for private key authentication"); + } sshSessionFactory = newSshdSessionFactory(settings.getPrivateKey(), settings.getPrivateKeyPassword(), directory); } return new AuthHandler(credentialsProvider, sshSessionFactory); diff --git a/dao/pom.xml b/dao/pom.xml index 6aa47ec4bd..6f5ec41672 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -216,6 +216,11 @@ jdbc test + + org.testcontainers + junit-jupiter + test + org.springframework spring-context-support diff --git a/dao/src/main/java/org/thingsboard/server/dao/AbstractVersionedInsertRepository.java b/dao/src/main/java/org/thingsboard/server/dao/AbstractVersionedInsertRepository.java new file mode 100644 index 0000000000..43ed41e52a --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/AbstractVersionedInsertRepository.java @@ -0,0 +1,130 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao; + +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.PreparedStatementCreator; +import org.springframework.jdbc.core.SqlProvider; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.thingsboard.server.dao.sqlts.insert.AbstractInsertRepository; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.thingsboard.server.dao.model.ModelConstants.VERSION_COLUMN; + +public abstract class AbstractVersionedInsertRepository extends AbstractInsertRepository { + + public List saveOrUpdate(List entities) { + return transactionTemplate.execute(status -> { + List seqNumbers = new ArrayList<>(entities.size()); + + KeyHolder keyHolder = new GeneratedKeyHolder(); + + int[] updateResult = onBatchUpdate(entities, keyHolder); + + List> seqNumbersList = keyHolder.getKeyList(); + + int notUpdatedCount = entities.size() - seqNumbersList.size(); + + List toInsertIndexes = new ArrayList<>(notUpdatedCount); + List insertEntities = new ArrayList<>(notUpdatedCount); + int keyHolderIndex = 0; + for (int i = 0; i < updateResult.length; i++) { + if (updateResult[i] == 0) { + insertEntities.add(entities.get(i)); + seqNumbers.add(null); + toInsertIndexes.add(i); + } else { + seqNumbers.add((Long) seqNumbersList.get(keyHolderIndex).get(VERSION_COLUMN)); + keyHolderIndex++; + } + } + + if (insertEntities.isEmpty()) { + return seqNumbers; + } + + int[] insertResult = onInsertOrUpdate(insertEntities, keyHolder); + + seqNumbersList = keyHolder.getKeyList(); + + for (int i = 0; i < insertResult.length; i++) { + if (insertResult[i] != 0) { + seqNumbers.set(toInsertIndexes.get(i), (Long) seqNumbersList.get(i).get(VERSION_COLUMN)); + } + } + + return seqNumbers; + }); + } + + private int[] onBatchUpdate(List entities, KeyHolder keyHolder) { + return jdbcTemplate.batchUpdate(new SequencePreparedStatementCreator(getBatchUpdateQuery()), new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + setOnBatchUpdateValues(ps, i, entities); + } + + @Override + public int getBatchSize() { + return entities.size(); + } + }, keyHolder); + } + + private int[] onInsertOrUpdate(List insertEntities, KeyHolder keyHolder) { + return jdbcTemplate.batchUpdate(new SequencePreparedStatementCreator(getInsertOrUpdateQuery()), new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + setOnInsertOrUpdateValues(ps, i, insertEntities); + } + + @Override + public int getBatchSize() { + return insertEntities.size(); + } + }, keyHolder); + } + + protected abstract void setOnBatchUpdateValues(PreparedStatement ps, int i, List entities) throws SQLException; + + protected abstract void setOnInsertOrUpdateValues(PreparedStatement ps, int i, List entities) throws SQLException; + + protected abstract String getBatchUpdateQuery(); + + protected abstract String getInsertOrUpdateQuery(); + + private record SequencePreparedStatementCreator(String sql) implements PreparedStatementCreator, SqlProvider { + + private static final String[] COLUMNS = {VERSION_COLUMN}; + + @Override + public PreparedStatement createPreparedStatement(Connection con) throws SQLException { + return con.prepareStatement(sql, COLUMNS); + } + + @Override + public String getSql() { + return this.sql; + } + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/Dao.java b/dao/src/main/java/org/thingsboard/server/dao/Dao.java index e7290cdced..e912872496 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/Dao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/Dao.java @@ -39,7 +39,7 @@ public interface Dao { T saveAndFlush(TenantId tenantId, T t); - boolean removeById(TenantId tenantId, UUID id); + void removeById(TenantId tenantId, UUID id); void removeAllByIds(Collection ids); diff --git a/dao/src/main/java/org/thingsboard/server/dao/JpaDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/JpaDaoConfig.java deleted file mode 100644 index 4dd0633420..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/JpaDaoConfig.java +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Copyright © 2016-2024 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao; - -import org.springframework.boot.autoconfigure.domain.EntityScan; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; -import org.springframework.data.repository.config.BootstrapMode; -import org.springframework.transaction.annotation.EnableTransactionManagement; -import org.thingsboard.server.dao.util.TbAutoConfiguration; - -/** - * @author Valerii Sosliuk - */ -@Configuration -@TbAutoConfiguration -@ComponentScan({"org.thingsboard.server.dao.sql", "org.thingsboard.server.dao.attributes", "org.thingsboard.server.dao.cache", "org.thingsboard.server.cache"}) -@EnableJpaRepositories(value = "org.thingsboard.server.dao.sql", bootstrapMode = BootstrapMode.LAZY) -@EntityScan("org.thingsboard.server.dao.model.sql") -@EnableTransactionManagement -public class JpaDaoConfig { - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java b/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java index 2c111151b3..dd967953ad 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java @@ -65,7 +65,7 @@ public class BaseAlarmCommentService extends AbstractEntityService implements Al @Override public AlarmComment saveAlarmComment(TenantId tenantId, AlarmComment alarmComment) { - log.debug("Deleting Alarm Comment: {}", alarmComment); + log.debug("Saving Alarm Comment: {}", alarmComment); alarmCommentDataValidator.validate(alarmComment, c -> tenantId); AlarmComment result = alarmCommentDao.save(tenantId, alarmComment); eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entity(result) diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileCacheKey.java index 0283fdb961..18e45d08fb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileCacheKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileCacheKey.java @@ -38,15 +38,15 @@ public class AssetProfileCacheKey implements Serializable { this.defaultProfile = defaultProfile; } - public static AssetProfileCacheKey fromName(TenantId tenantId, String name) { + public static AssetProfileCacheKey forName(TenantId tenantId, String name) { return new AssetProfileCacheKey(tenantId, name, null, false); } - public static AssetProfileCacheKey fromId(AssetProfileId id) { + public static AssetProfileCacheKey forId(AssetProfileId id) { return new AssetProfileCacheKey(null, null, id, false); } - public static AssetProfileCacheKey defaultProfile(TenantId tenantId) { + public static AssetProfileCacheKey forDefaultProfile(TenantId tenantId) { return new AssetProfileCacheKey(tenantId, null, null, true); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileCaffeineCache.java b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileCaffeineCache.java index db812732e8..b37f84e649 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileCaffeineCache.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileCaffeineCache.java @@ -18,13 +18,13 @@ package org.thingsboard.server.dao.asset; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.cache.CacheManager; import org.springframework.stereotype.Service; -import org.thingsboard.server.cache.CaffeineTbTransactionalCache; +import org.thingsboard.server.cache.VersionedCaffeineTbCache; import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.asset.AssetProfile; @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) @Service("AssetProfileCache") -public class AssetProfileCaffeineCache extends CaffeineTbTransactionalCache { +public class AssetProfileCaffeineCache extends VersionedCaffeineTbCache { public AssetProfileCaffeineCache(CacheManager cacheManager) { super(cacheManager, CacheConstants.ASSET_PROFILE_CACHE); diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileEvictEvent.java index a08ad2ad32..0cb1d35bf5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileEvictEvent.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileEvictEvent.java @@ -15,11 +15,16 @@ */ package org.thingsboard.server.dao.asset; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.TenantId; @Data +@RequiredArgsConstructor +@AllArgsConstructor public class AssetProfileEvictEvent { private final TenantId tenantId; @@ -27,5 +32,6 @@ public class AssetProfileEvictEvent { private final String oldName; private final AssetProfileId assetProfileId; private final boolean defaultProfile; + private AssetProfile savedAssetProfile; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileRedisCache.java index 625bc16d43..cd2ee7a9a8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileRedisCache.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileRedisCache.java @@ -19,17 +19,18 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.stereotype.Service; import org.thingsboard.server.cache.CacheSpecsMap; -import org.thingsboard.server.cache.RedisTbTransactionalCache; import org.thingsboard.server.cache.TBRedisCacheConfiguration; import org.thingsboard.server.cache.TbJsonRedisSerializer; +import org.thingsboard.server.cache.VersionedRedisTbCache; import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.asset.AssetProfile; @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") @Service("AssetProfileCache") -public class AssetProfileRedisCache extends RedisTbTransactionalCache { +public class AssetProfileRedisCache extends VersionedRedisTbCache { public AssetProfileRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { super(CacheConstants.ASSET_PROFILE_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(AssetProfile.class)); } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java index 6848c58d5c..95786f0a6a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java @@ -33,7 +33,7 @@ import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.dao.entity.AbstractCachedEntityService; +import org.thingsboard.server.dao.entity.CachedVersionedEntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.exception.DataValidationException; @@ -53,7 +53,7 @@ import static org.thingsboard.server.dao.service.Validator.validateId; @Service("AssetProfileDaoService") @Slf4j -public class AssetProfileServiceImpl extends AbstractCachedEntityService implements AssetProfileService { +public class AssetProfileServiceImpl extends CachedVersionedEntityService implements AssetProfileService { private static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; @@ -81,18 +81,20 @@ public class AssetProfileServiceImpl extends AbstractCachedEntityService keys = new ArrayList<>(2); - keys.add(AssetProfileCacheKey.fromName(event.getTenantId(), event.getNewName())); - if (event.getAssetProfileId() != null) { - keys.add(AssetProfileCacheKey.fromId(event.getAssetProfileId())); + List toEvict = new ArrayList<>(2); + toEvict.add(AssetProfileCacheKey.forName(event.getTenantId(), event.getNewName())); + if (event.getSavedAssetProfile() != null) { + cache.put(AssetProfileCacheKey.forId(event.getSavedAssetProfile().getId()), event.getSavedAssetProfile()); + } else if (event.getAssetProfileId() != null) { + toEvict.add(AssetProfileCacheKey.forId(event.getAssetProfileId())); } if (event.isDefaultProfile()) { - keys.add(AssetProfileCacheKey.defaultProfile(event.getTenantId())); + toEvict.add(AssetProfileCacheKey.forDefaultProfile(event.getTenantId())); } if (StringUtils.isNotEmpty(event.getOldName()) && !event.getOldName().equals(event.getNewName())) { - keys.add(AssetProfileCacheKey.fromName(event.getTenantId(), event.getOldName())); + toEvict.add(AssetProfileCacheKey.forName(event.getTenantId(), event.getOldName())); } - cache.evict(keys); + cache.evict(toEvict); } @Override @@ -104,8 +106,8 @@ public class AssetProfileServiceImpl extends AbstractCachedEntityService INCORRECT_ASSET_PROFILE_ID + id); - return cache.getOrFetchFromDB(AssetProfileCacheKey.fromId(assetProfileId), - () -> assetProfileDao.findById(tenantId, assetProfileId.getId()), true, putInCache); + return cache.get(AssetProfileCacheKey.forId(assetProfileId), + () -> assetProfileDao.findById(tenantId, assetProfileId.getId()), putInCache); } @Override @@ -117,7 +119,7 @@ public class AssetProfileServiceImpl extends AbstractCachedEntityService INCORRECT_ASSET_PROFILE_NAME + s); - return cache.getOrFetchFromDB(AssetProfileCacheKey.fromName(tenantId, profileName), + return cache.getOrFetchFromDB(AssetProfileCacheKey.forName(tenantId, profileName), () -> assetProfileDao.findByName(tenantId, profileName), false, putInCache); } @@ -147,7 +149,7 @@ public class AssetProfileServiceImpl extends AbstractCachedEntityService INCORRECT_TENANT_ID + id); - return cache.getAndPutInTransaction(AssetProfileCacheKey.defaultProfile(tenantId), + return cache.getAndPutInTransaction(AssetProfileCacheKey.forDefaultProfile(tenantId), () -> assetProfileDao.findDefaultAssetProfile(tenantId), true); } @@ -353,4 +355,5 @@ public class AssetProfileServiceImpl extends AbstractCachedEntityService { +public class AttributeCaffeineCache extends VersionedCaffeineTbCache { public AttributeCaffeineCache(CacheManager cacheManager) { super(cacheManager, CacheConstants.ATTRIBUTES_CACHE); diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeRedisCache.java index 7932a8b8d4..2b182c2b92 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeRedisCache.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeRedisCache.java @@ -21,88 +21,29 @@ import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.serializer.SerializationException; import org.springframework.stereotype.Service; import org.thingsboard.server.cache.CacheSpecsMap; -import org.thingsboard.server.cache.RedisTbTransactionalCache; import org.thingsboard.server.cache.TBRedisCacheConfiguration; import org.thingsboard.server.cache.TbRedisSerializer; +import org.thingsboard.server.cache.VersionedRedisTbCache; import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; -import org.thingsboard.server.common.data.kv.BooleanDataEntry; -import org.thingsboard.server.common.data.kv.DoubleDataEntry; -import org.thingsboard.server.common.data.kv.JsonDataEntry; -import org.thingsboard.server.common.data.kv.KvEntry; -import org.thingsboard.server.common.data.kv.LongDataEntry; -import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.util.ProtoUtils; import org.thingsboard.server.gen.transport.TransportProtos.AttributeValueProto; -import org.thingsboard.server.gen.transport.TransportProtos.KeyValueType; @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") @Service("AttributeCache") -public class AttributeRedisCache extends RedisTbTransactionalCache { +public class AttributeRedisCache extends VersionedRedisTbCache { public AttributeRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { super(CacheConstants.ATTRIBUTES_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbRedisSerializer<>() { @Override public byte[] serialize(AttributeKvEntry attributeKvEntry) throws SerializationException { - AttributeValueProto.Builder builder = AttributeValueProto.newBuilder() - .setLastUpdateTs(attributeKvEntry.getLastUpdateTs()); - switch (attributeKvEntry.getDataType()) { - case BOOLEAN: - attributeKvEntry.getBooleanValue().ifPresent(builder::setBoolV); - builder.setHasV(attributeKvEntry.getBooleanValue().isPresent()); - builder.setType(KeyValueType.BOOLEAN_V); - break; - case STRING: - attributeKvEntry.getStrValue().ifPresent(builder::setStringV); - builder.setHasV(attributeKvEntry.getStrValue().isPresent()); - builder.setType(KeyValueType.STRING_V); - break; - case DOUBLE: - attributeKvEntry.getDoubleValue().ifPresent(builder::setDoubleV); - builder.setHasV(attributeKvEntry.getDoubleValue().isPresent()); - builder.setType(KeyValueType.DOUBLE_V); - break; - case LONG: - attributeKvEntry.getLongValue().ifPresent(builder::setLongV); - builder.setHasV(attributeKvEntry.getLongValue().isPresent()); - builder.setType(KeyValueType.LONG_V); - break; - case JSON: - attributeKvEntry.getJsonValue().ifPresent(builder::setJsonV); - builder.setHasV(attributeKvEntry.getJsonValue().isPresent()); - builder.setType(KeyValueType.JSON_V); - break; - - } - return builder.build().toByteArray(); + return ProtoUtils.toProto(attributeKvEntry).toByteArray(); } @Override public AttributeKvEntry deserialize(AttributeCacheKey key, byte[] bytes) throws SerializationException { try { - AttributeValueProto proto = AttributeValueProto.parseFrom(bytes); - boolean hasValue = proto.getHasV(); - KvEntry entry; - switch (proto.getType()) { - case BOOLEAN_V: - entry = new BooleanDataEntry(key.getKey(), hasValue ? proto.getBoolV() : null); - break; - case LONG_V: - entry = new LongDataEntry(key.getKey(), hasValue ? proto.getLongV() : null); - break; - case DOUBLE_V: - entry = new DoubleDataEntry(key.getKey(), hasValue ? proto.getDoubleV() : null); - break; - case STRING_V: - entry = new StringDataEntry(key.getKey(), hasValue ? proto.getStringV() : null); - break; - case JSON_V: - entry = new JsonDataEntry(key.getKey(), hasValue ? proto.getJsonV() : null); - break; - default: - throw new InvalidProtocolBufferException("Unrecognized type: " + proto.getType() + " !"); - } - return new BaseAttributeKvEntry(proto.getLastUpdateTs(), entry); + return ProtoUtils.fromProto(AttributeValueProto.parseFrom(bytes)); } catch (InvalidProtocolBufferException e) { throw new SerializationException(e.getMessage()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java index 64ca5d9dd9..1805f72a8f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.util.TbPair; import java.util.Collection; import java.util.List; @@ -38,10 +39,12 @@ public interface AttributesDao { List findAll(TenantId tenantId, EntityId entityId, AttributeScope attributeScope); - ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope attributeScope, AttributeKvEntry attribute); + ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope attributeScope, AttributeKvEntry attribute); List> removeAll(TenantId tenantId, EntityId entityId, AttributeScope attributeScope, List keys); + List>> removeAllWithVersions(TenantId tenantId, EntityId entityId, AttributeScope attributeScope, List keys); + List findAllKeysByDeviceProfileId(TenantId tenantId, DeviceProfileId deviceProfileId); List findAllKeysByEntityIds(TenantId tenantId, List entityIds); diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java index 1b1f2d005d..6f49b2a94e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java @@ -56,13 +56,6 @@ public class BaseAttributesService implements AttributesService { this.attributesDao = attributesDao; } - @Override - public ListenableFuture> find(TenantId tenantId, EntityId entityId, String scope, String attributeKey) { - validate(entityId, scope); - Validator.validateString(attributeKey, k -> "Incorrect attribute key " + k); - return Futures.immediateFuture(attributesDao.find(tenantId, entityId, AttributeScope.valueOf(scope), attributeKey)); - } - @Override public ListenableFuture> find(TenantId tenantId, EntityId entityId, AttributeScope scope, String attributeKey) { validate(entityId, scope); @@ -70,13 +63,6 @@ public class BaseAttributesService implements AttributesService { return Futures.immediateFuture(attributesDao.find(tenantId, entityId, scope, attributeKey)); } - @Override - public ListenableFuture> find(TenantId tenantId, EntityId entityId, String scope, Collection attributeKeys) { - validate(entityId, scope); - attributeKeys.forEach(attributeKey -> Validator.validateString(attributeKey, k -> "Incorrect attribute key " + k)); - return Futures.immediateFuture(attributesDao.find(tenantId, entityId, AttributeScope.valueOf(scope), attributeKeys)); - } - @Override public ListenableFuture> find(TenantId tenantId, EntityId entityId, AttributeScope scope, Collection attributeKeys) { validate(entityId, scope); @@ -84,12 +70,6 @@ public class BaseAttributesService implements AttributesService { return Futures.immediateFuture(attributesDao.find(tenantId, entityId, scope, attributeKeys)); } - @Override - public ListenableFuture> findAll(TenantId tenantId, EntityId entityId, String scope) { - validate(entityId, scope); - return Futures.immediateFuture(attributesDao.findAll(tenantId, entityId, AttributeScope.valueOf(scope))); - } - @Override public ListenableFuture> findAll(TenantId tenantId, EntityId entityId, AttributeScope scope) { validate(entityId, scope); @@ -101,11 +81,6 @@ public class BaseAttributesService implements AttributesService { return attributesDao.findAllKeysByDeviceProfileId(tenantId, deviceProfileId); } - @Override - public List findAllKeysByEntityIds(TenantId tenantId, EntityType entityType, List entityIds) { - return attributesDao.findAllKeysByEntityIds(tenantId, entityIds); - } - @Override public List findAllKeysByEntityIds(TenantId tenantId, List entityIds) { return attributesDao.findAllKeysByEntityIds(tenantId, entityIds); @@ -121,32 +96,25 @@ public class BaseAttributesService implements AttributesService { } @Override - public ListenableFuture save(TenantId tenantId, EntityId entityId, String scope, AttributeKvEntry attribute) { - validate(entityId, scope); - AttributeUtils.validate(attribute, valueNoXssValidation); - return attributesDao.save(tenantId, entityId, AttributeScope.valueOf(scope), attribute); - } - - @Override - public ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { + public ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { validate(entityId, scope); AttributeUtils.validate(attribute, valueNoXssValidation); return attributesDao.save(tenantId, entityId, scope, attribute); } @Override - public ListenableFuture> save(TenantId tenantId, EntityId entityId, String scope, List attributes) { + public ListenableFuture> save(TenantId tenantId, EntityId entityId, String scope, List attributes) { validate(entityId, scope); AttributeUtils.validate(attributes, valueNoXssValidation); - List> saveFutures = attributes.stream().map(attribute -> attributesDao.save(tenantId, entityId, AttributeScope.valueOf(scope), attribute)).collect(Collectors.toList()); + List> saveFutures = attributes.stream().map(attribute -> attributesDao.save(tenantId, entityId, AttributeScope.valueOf(scope), attribute)).collect(Collectors.toList()); return Futures.allAsList(saveFutures); } @Override - public ListenableFuture> save(TenantId tenantId, EntityId entityId, AttributeScope scope, List attributes) { + public ListenableFuture> save(TenantId tenantId, EntityId entityId, AttributeScope scope, List attributes) { validate(entityId, scope); AttributeUtils.validate(attributes, valueNoXssValidation); - List> saveFutures = attributes.stream().map(attribute -> attributesDao.save(tenantId, entityId, scope, attribute)).collect(Collectors.toList()); + List> saveFutures = attributes.stream().map(attribute -> attributesDao.save(tenantId, entityId, scope, attribute)).collect(Collectors.toList()); return Futures.allAsList(saveFutures); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java index f51bc6f0b8..3de90355ed 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java @@ -27,14 +27,15 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; import org.thingsboard.server.cache.TbCacheValueWrapper; -import org.thingsboard.server.cache.TbTransactionalCache; +import org.thingsboard.server.cache.VersionedTbCache; import org.thingsboard.server.common.data.AttributeScope; -import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.stats.DefaultCounter; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.dao.cache.CacheExecutorService; @@ -68,7 +69,7 @@ public class CachedAttributesService implements AttributesService { private final CacheExecutorService cacheExecutorService; private final DefaultCounter hitCounter; private final DefaultCounter missCounter; - private final TbTransactionalCache cache; + private final VersionedTbCache cache; private ListeningExecutorService cacheExecutor; @Value("${cache.type:caffeine}") @@ -80,7 +81,7 @@ public class CachedAttributesService implements AttributesService { JpaExecutorService jpaExecutorService, StatsFactory statsFactory, CacheExecutorService cacheExecutorService, - TbTransactionalCache cache) { + VersionedTbCache cache) { this.attributesDao = attributesDao; this.jpaExecutorService = jpaExecutorService; this.cacheExecutorService = cacheExecutorService; @@ -109,12 +110,6 @@ public class CachedAttributesService implements AttributesService { return cacheExecutorService.executor(); } - - @Override - public ListenableFuture> find(TenantId tenantId, EntityId entityId, String scope, String attributeKey) { - return find(tenantId, entityId, AttributeScope.valueOf(scope), attributeKey); - } - @Override public ListenableFuture> find(TenantId tenantId, EntityId entityId, AttributeScope scope, String attributeKey) { validate(entityId, scope); @@ -129,31 +124,18 @@ public class CachedAttributesService implements AttributesService { return Optional.ofNullable(cachedAttributeKvEntry); } else { missCounter.increment(); - var cacheTransaction = cache.newTransactionForKey(attributeCacheKey); - try { - Optional result = attributesDao.find(tenantId, entityId, scope, attributeKey); - cacheTransaction.putIfAbsent(attributeCacheKey, result.orElse(null)); - cacheTransaction.commit(); - return result; - } catch (Throwable e) { - cacheTransaction.rollback(); - log.debug("Could not find attribute from cache: [{}] [{}] [{}]", entityId, scope, attributeKey, e); - throw e; - } + Optional result = attributesDao.find(tenantId, entityId, scope, attributeKey); + cache.put(attributeCacheKey, result.orElse(null)); + return result; } }); } - @Override - public ListenableFuture> find(TenantId tenantId, EntityId entityId, String scope, final Collection attributeKeysNonUnique) { - return find(tenantId, entityId, AttributeScope.valueOf(scope), attributeKeysNonUnique); - } - @Override public ListenableFuture> find(TenantId tenantId, EntityId entityId, AttributeScope scope, final Collection attributeKeysNonUnique) { validate(entityId, scope); final var attributeKeys = new LinkedHashSet<>(attributeKeysNonUnique); // deduplicate the attributes - attributeKeys.forEach(attributeKey -> Validator.validateString(attributeKey, k ->"Incorrect attribute key " + k)); + attributeKeys.forEach(attributeKey -> Validator.validateString(attributeKey, k -> "Incorrect attribute key " + k)); //CacheExecutor for Redis or DirectExecutor for local Caffeine return Futures.transformAsync(cacheExecutor.submit(() -> findCachedAttributes(entityId, scope, attributeKeys)), @@ -175,28 +157,19 @@ public class CachedAttributesService implements AttributesService { // DB call should run in DB executor, not in cache-related executor return jpaExecutorService.submit(() -> { - var cacheTransaction = cache.newTransactionForKeys(notFoundKeys); - try { - log.trace("[{}][{}] Lookup attributes from db: {}", entityId, scope, notFoundAttributeKeys); - List result = attributesDao.find(tenantId, entityId, scope, notFoundAttributeKeys); - for (AttributeKvEntry foundInDbAttribute : result) { - AttributeCacheKey attributeCacheKey = new AttributeCacheKey(scope, entityId, foundInDbAttribute.getKey()); - cacheTransaction.putIfAbsent(attributeCacheKey, foundInDbAttribute); - notFoundAttributeKeys.remove(foundInDbAttribute.getKey()); - } - for (String key : notFoundAttributeKeys) { - cacheTransaction.putIfAbsent(new AttributeCacheKey(scope, entityId, key), null); - } - List mergedAttributes = new ArrayList<>(cachedAttributes); - mergedAttributes.addAll(result); - cacheTransaction.commit(); - log.trace("[{}][{}] Commit cache transaction: {}", entityId, scope, notFoundAttributeKeys); - return mergedAttributes; - } catch (Throwable e) { - cacheTransaction.rollback(); - log.debug("Could not find attributes from cache: [{}] [{}] [{}]", entityId, scope, notFoundAttributeKeys, e); - throw e; + log.trace("[{}][{}] Lookup attributes from db: {}", entityId, scope, notFoundAttributeKeys); + List result = attributesDao.find(tenantId, entityId, scope, notFoundAttributeKeys); + for (AttributeKvEntry foundInDbAttribute : result) { + put(entityId, scope, foundInDbAttribute); + notFoundAttributeKeys.remove(foundInDbAttribute.getKey()); + } + for (String key : notFoundAttributeKeys) { + cache.put(new AttributeCacheKey(scope, entityId, key), null); } + List mergedAttributes = new ArrayList<>(cachedAttributes); + mergedAttributes.addAll(result); + log.trace("[{}][{}] Commit cache transaction: {}", entityId, scope, notFoundAttributeKeys); + return mergedAttributes; }); }, MoreExecutors.directExecutor()); // cacheExecutor analyse and returns results or submit to DB executor @@ -216,11 +189,6 @@ public class CachedAttributesService implements AttributesService { return cachedAttributes; } - @Override - public ListenableFuture> findAll(TenantId tenantId, EntityId entityId, String scope) { - return findAll(tenantId, entityId, AttributeScope.valueOf(scope)); - } - @Override public ListenableFuture> findAll(TenantId tenantId, EntityId entityId, AttributeScope scope) { validate(entityId, scope); @@ -233,11 +201,6 @@ public class CachedAttributesService implements AttributesService { return attributesDao.findAllKeysByDeviceProfileId(tenantId, deviceProfileId); } - @Override - public List findAllKeysByEntityIds(TenantId tenantId, EntityType entityType, List entityIds) { - return findAllKeysByEntityIds(tenantId, entityIds); - } - @Override public List findAllKeysByEntityIds(TenantId tenantId, List entityIds) { return attributesDao.findAllKeysByEntityIds(tenantId, entityIds); @@ -253,42 +216,43 @@ public class CachedAttributesService implements AttributesService { } @Override - public ListenableFuture save(TenantId tenantId, EntityId entityId, String scope, AttributeKvEntry attribute) { - return save(tenantId, entityId, AttributeScope.valueOf(scope), attribute); - } - - @Override - public ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { + public ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { validate(entityId, scope); AttributeUtils.validate(attribute, valueNoXssValidation); - ListenableFuture future = attributesDao.save(tenantId, entityId, scope, attribute); - return Futures.transform(future, key -> evict(entityId, scope, attribute, key), cacheExecutor); + return doSave(tenantId, entityId, scope, attribute); } @Override - public ListenableFuture> save(TenantId tenantId, EntityId entityId, String scope, List attributes) { + public ListenableFuture> save(TenantId tenantId, EntityId entityId, String scope, List attributes) { return save(tenantId, entityId, AttributeScope.valueOf(scope), attributes); } @Override - public ListenableFuture> save(TenantId tenantId, EntityId entityId, AttributeScope scope, List attributes) { + public ListenableFuture> save(TenantId tenantId, EntityId entityId, AttributeScope scope, List attributes) { validate(entityId, scope); AttributeUtils.validate(attributes, valueNoXssValidation); - List> futures = new ArrayList<>(attributes.size()); + List> futures = new ArrayList<>(attributes.size()); for (var attribute : attributes) { - ListenableFuture future = attributesDao.save(tenantId, entityId, scope, attribute); - futures.add(Futures.transform(future, key -> evict(entityId, scope, attribute, key), cacheExecutor)); + futures.add(doSave(tenantId, entityId, scope, attribute)); } return Futures.allAsList(futures); } - private String evict(EntityId entityId, AttributeScope scope, AttributeKvEntry attribute, String key) { - log.trace("[{}][{}][{}] Before cache evict: {}", entityId, scope, key, attribute); - cache.evictOrPut(new AttributeCacheKey(scope, entityId, key), attribute); - log.trace("[{}][{}][{}] after cache evict.", entityId, scope, key); - return key; + private ListenableFuture doSave(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { + ListenableFuture future = attributesDao.save(tenantId, entityId, scope, attribute); + return Futures.transform(future, version -> { + put(entityId, scope, new BaseAttributeKvEntry(((BaseAttributeKvEntry)attribute).getKv(), attribute.getLastUpdateTs(), version)); + return version; + }, cacheExecutor); + } + + private void put(EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { + String key = attribute.getKey(); + log.trace("[{}][{}][{}] Before cache put: {}", entityId, scope, key, attribute); + cache.put(new AttributeCacheKey(scope, entityId, key), attribute); + log.trace("[{}][{}][{}] after cache put.", entityId, scope, key); } @Override @@ -299,9 +263,10 @@ public class CachedAttributesService implements AttributesService { @Override public ListenableFuture> removeAll(TenantId tenantId, EntityId entityId, AttributeScope scope, List attributeKeys) { validate(entityId, scope); - List> futures = attributesDao.removeAll(tenantId, entityId, scope, attributeKeys); - return Futures.allAsList(futures.stream().map(future -> Futures.transform(future, key -> { - cache.evict(new AttributeCacheKey(scope, entityId, key)); + List>> futures = attributesDao.removeAllWithVersions(tenantId, entityId, scope, attributeKeys); + return Futures.allAsList(futures.stream().map(future -> Futures.transform(future, keyVersionPair -> { + String key = keyVersionPair.getFirst(); + cache.evict(new AttributeCacheKey(scope, entityId, key), keyVersionPair.getSecond()); return key; }, cacheExecutor)).collect(Collectors.toList())); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogDao.java b/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogDao.java index ad5a4ba686..82712c8a2c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogDao.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.audit; -import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.audit.AuditLog; import org.thingsboard.server.common.data.id.CustomerId; diff --git a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java index 29b097bfab..c5ff7132cb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java @@ -408,8 +408,12 @@ public class AuditLogServiceImpl implements AuditLogService { } return executor.submit(() -> { - AuditLog auditLog = auditLogDao.save(tenantId, auditLogEntry); - auditLogSink.logAction(auditLog); + try { + AuditLog auditLog = auditLogDao.save(tenantId, auditLogEntry); + auditLogSink.logAction(auditLog); + } catch (Throwable e) { + log.error("[{}] Failed to save audit log: {}", tenantId, auditLogEntry, e); + } return null; }); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/audit/sink/ElasticsearchAuditLogSink.java b/dao/src/main/java/org/thingsboard/server/dao/audit/sink/ElasticsearchAuditLogSink.java index 5d143ef009..01ccbc17f1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/audit/sink/ElasticsearchAuditLogSink.java +++ b/dao/src/main/java/org/thingsboard/server/dao/audit/sink/ElasticsearchAuditLogSink.java @@ -16,6 +16,8 @@ package org.thingsboard.server.dao.audit.sink; import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.apache.http.HttpEntity; import org.apache.http.HttpHost; @@ -40,11 +42,8 @@ import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.AuditLog; import org.thingsboard.server.common.data.id.TenantId; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.Collections; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; diff --git a/dao/src/main/java/org/thingsboard/server/dao/config/DedicatedEventsDataSource.java b/dao/src/main/java/org/thingsboard/server/dao/config/DedicatedEventsDataSource.java new file mode 100644 index 0000000000..3ca6a20d44 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/config/DedicatedEventsDataSource.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@ConditionalOnProperty(value = "spring.datasource.events.enabled", havingValue = "true") +public @interface DedicatedEventsDataSource { +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/config/DedicatedEventsJpaDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/config/DedicatedEventsJpaDaoConfig.java new file mode 100644 index 0000000000..e4a0b36100 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/config/DedicatedEventsJpaDaoConfig.java @@ -0,0 +1,91 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.config; + +import com.zaxxer.hikari.HikariDataSource; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.config.BootstrapMode; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.transaction.support.TransactionTemplate; +import org.thingsboard.server.dao.model.sql.AuditLogEntity; +import org.thingsboard.server.dao.model.sql.ErrorEventEntity; +import org.thingsboard.server.dao.model.sql.LifecycleEventEntity; +import org.thingsboard.server.dao.model.sql.RuleChainDebugEventEntity; +import org.thingsboard.server.dao.model.sql.RuleNodeDebugEventEntity; +import org.thingsboard.server.dao.model.sql.StatisticsEventEntity; + +import javax.sql.DataSource; +import java.util.Objects; + +@DedicatedEventsDataSource +@Configuration +@EnableJpaRepositories(value = {"org.thingsboard.server.dao.sql.event", "org.thingsboard.server.dao.sql.audit"}, + bootstrapMode = BootstrapMode.LAZY, + entityManagerFactoryRef = "eventsEntityManagerFactory", transactionManagerRef = "eventsTransactionManager") +public class DedicatedEventsJpaDaoConfig { + + public static final String EVENTS_PERSISTENCE_UNIT = "events"; + public static final String EVENTS_DATA_SOURCE = EVENTS_PERSISTENCE_UNIT + "DataSource"; + public static final String EVENTS_TRANSACTION_MANAGER = EVENTS_PERSISTENCE_UNIT + "TransactionManager"; + public static final String EVENTS_TRANSACTION_TEMPLATE = EVENTS_PERSISTENCE_UNIT + "TransactionTemplate"; + public static final String EVENTS_JDBC_TEMPLATE = EVENTS_PERSISTENCE_UNIT + "JdbcTemplate"; + + @Bean + @ConfigurationProperties("spring.datasource.events") + public DataSourceProperties eventsDataSourceProperties() { + return new DataSourceProperties(); + } + + @ConfigurationProperties(prefix = "spring.datasource.events.hikari") + @Bean(EVENTS_DATA_SOURCE) + public DataSource eventsDataSource(@Qualifier("eventsDataSourceProperties") DataSourceProperties eventsDataSourceProperties) { + return eventsDataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build(); + } + + @Bean + public LocalContainerEntityManagerFactoryBean eventsEntityManagerFactory(@Qualifier(EVENTS_DATA_SOURCE) DataSource eventsDataSource, + EntityManagerFactoryBuilder builder) { + return builder + .dataSource(eventsDataSource) + .packages(LifecycleEventEntity.class, StatisticsEventEntity.class, ErrorEventEntity.class, RuleNodeDebugEventEntity.class, RuleChainDebugEventEntity.class, AuditLogEntity.class) + .persistenceUnit(EVENTS_PERSISTENCE_UNIT) + .build(); + } + + @Bean(EVENTS_TRANSACTION_MANAGER) + public JpaTransactionManager eventsTransactionManager(@Qualifier("eventsEntityManagerFactory") LocalContainerEntityManagerFactoryBean eventsEntityManagerFactory) { + return new JpaTransactionManager(Objects.requireNonNull(eventsEntityManagerFactory.getObject())); + } + + @Bean(EVENTS_TRANSACTION_TEMPLATE) + public TransactionTemplate eventsTransactionTemplate(@Qualifier(EVENTS_TRANSACTION_MANAGER) JpaTransactionManager eventsTransactionManager) { + return new TransactionTemplate(eventsTransactionManager); + } + + @Bean(EVENTS_JDBC_TEMPLATE) + public JdbcTemplate eventsJdbcTemplate(@Qualifier(EVENTS_DATA_SOURCE) DataSource eventsDataSource) { + return new JdbcTemplate(eventsDataSource); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/config/DefaultDataSource.java b/dao/src/main/java/org/thingsboard/server/dao/config/DefaultDataSource.java new file mode 100644 index 0000000000..ad6c398ea6 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/config/DefaultDataSource.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@ConditionalOnProperty(value = "spring.datasource.events.enabled", havingValue = "false", matchIfMissing = true) +public @interface DefaultDataSource { +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/SqlTimeseriesDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/config/DefaultDedicatedJpaDaoConfig.java similarity index 56% rename from dao/src/main/java/org/thingsboard/server/dao/SqlTimeseriesDaoConfig.java rename to dao/src/main/java/org/thingsboard/server/dao/config/DefaultDedicatedJpaDaoConfig.java index 09553fb973..fd28735b79 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/SqlTimeseriesDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/config/DefaultDedicatedJpaDaoConfig.java @@ -13,22 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao; +package org.thingsboard.server.dao.config; -import org.springframework.boot.autoconfigure.domain.EntityScan; -import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.repository.config.BootstrapMode; -import org.springframework.transaction.annotation.EnableTransactionManagement; -import org.thingsboard.server.dao.util.TbAutoConfiguration; +@DefaultDataSource @Configuration -@TbAutoConfiguration -@ComponentScan({"org.thingsboard.server.dao.sqlts.dictionary"}) -@EnableJpaRepositories(value = {"org.thingsboard.server.dao.sqlts.dictionary"}, bootstrapMode = BootstrapMode.LAZY) -@EntityScan({"org.thingsboard.server.dao.model.sqlts.dictionary"}) -@EnableTransactionManagement -public class SqlTimeseriesDaoConfig { +@EnableJpaRepositories(value = {"org.thingsboard.server.dao.sql.event", "org.thingsboard.server.dao.sql.audit"}, bootstrapMode = BootstrapMode.LAZY) +public class DefaultDedicatedJpaDaoConfig { } diff --git a/dao/src/main/java/org/thingsboard/server/dao/config/JpaDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/config/JpaDaoConfig.java new file mode 100644 index 0000000000..86c0ed4ea3 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/config/JpaDaoConfig.java @@ -0,0 +1,121 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.config; + +import com.zaxxer.hikari.HikariDataSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Primary; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.config.BootstrapMode; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.transaction.support.TransactionTemplate; +import org.thingsboard.server.dao.sql.audit.AuditLogRepository; +import org.thingsboard.server.dao.sql.event.EventRepository; +import org.thingsboard.server.dao.util.TbAutoConfiguration; + +import javax.sql.DataSource; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Configuration +@TbAutoConfiguration +@ComponentScan({"org.thingsboard.server.dao.sql", "org.thingsboard.server.dao.attributes", "org.thingsboard.server.dao.sqlts.dictionary", "org.thingsboard.server.dao.cache", "org.thingsboard.server.cache"}) +@EnableJpaRepositories(value = {"org.thingsboard.server.dao.sql", "org.thingsboard.server.dao.sqlts.dictionary"}, + excludeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = {EventRepository.class, AuditLogRepository.class}), + bootstrapMode = BootstrapMode.LAZY) +public class JpaDaoConfig { + + @Bean + @ConfigurationProperties("spring.datasource") + public DataSourceProperties dataSourceProperties() { + return new DataSourceProperties(); + } + + @Primary + @ConfigurationProperties(prefix = "spring.datasource.hikari") + @Bean + public DataSource dataSource(@Qualifier("dataSourceProperties") DataSourceProperties dataSourceProperties) { + return dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build(); + } + + @Primary + @Bean + public LocalContainerEntityManagerFactoryBean entityManagerFactory(@Qualifier("dataSource") DataSource dataSource, + EntityManagerFactoryBuilder builder, + @Autowired(required = false) SqlTsLatestDaoConfig tsLatestDaoConfig, + @Autowired(required = false) SqlTsDaoConfig tsDaoConfig, + @Autowired(required = false) TimescaleDaoConfig timescaleDaoConfig, + @Autowired(required = false) TimescaleTsLatestDaoConfig timescaleTsLatestDaoConfig) { + List packages = new ArrayList<>(); + packages.add("org.thingsboard.server.dao.model.sql"); + packages.add("org.thingsboard.server.dao.model.sqlts.dictionary"); + if (tsLatestDaoConfig != null) { + packages.add("org.thingsboard.server.dao.model.sqlts.latest"); + } + if (tsDaoConfig != null) { + packages.add("org.thingsboard.server.dao.model.sqlts.ts"); + } + if (timescaleDaoConfig != null) { + packages.add("org.thingsboard.server.dao.model.sqlts.timescale"); + } + if (timescaleTsLatestDaoConfig != null) { + packages.add("org.thingsboard.server.dao.model.sqlts.latest"); + } + return builder + .dataSource(dataSource) + .packages(packages.toArray(String[]::new)) + .persistenceUnit("default") + .build(); + } + + @Primary + @Bean + public JpaTransactionManager transactionManager(@Qualifier("entityManagerFactory") LocalContainerEntityManagerFactoryBean entityManagerFactory) { + return new JpaTransactionManager(Objects.requireNonNull(entityManagerFactory.getObject())); + } + + @Primary + @Bean + public TransactionTemplate transactionTemplate(@Qualifier("transactionManager") JpaTransactionManager transactionManager) { + return new TransactionTemplate(transactionManager); + } + + @Primary + @Bean + public JdbcTemplate jdbcTemplate(@Qualifier("dataSource") DataSource dataSource) { + return new JdbcTemplate(dataSource); + } + + @Primary + @Bean + public NamedParameterJdbcTemplate namedParameterJdbcTemplate(@Qualifier("dataSource") DataSource dataSource) { + return new NamedParameterJdbcTemplate(dataSource); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/SqlTsDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/config/SqlTsDaoConfig.java similarity index 89% rename from dao/src/main/java/org/thingsboard/server/dao/SqlTsDaoConfig.java rename to dao/src/main/java/org/thingsboard/server/dao/config/SqlTsDaoConfig.java index bbd31846db..478dbde5d1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/SqlTsDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/config/SqlTsDaoConfig.java @@ -13,9 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao; +package org.thingsboard.server.dao.config; -import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @@ -28,7 +27,6 @@ import org.thingsboard.server.dao.util.TbAutoConfiguration; @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sqlts.sql", "org.thingsboard.server.dao.sqlts.insert.sql"}) @EnableJpaRepositories(value = {"org.thingsboard.server.dao.sqlts.ts", "org.thingsboard.server.dao.sqlts.insert.sql"}, bootstrapMode = BootstrapMode.LAZY) -@EntityScan({"org.thingsboard.server.dao.model.sqlts.ts"}) @EnableTransactionManagement @SqlTsDao public class SqlTsDaoConfig { diff --git a/dao/src/main/java/org/thingsboard/server/dao/SqlTsLatestDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/config/SqlTsLatestDaoConfig.java similarity index 89% rename from dao/src/main/java/org/thingsboard/server/dao/SqlTsLatestDaoConfig.java rename to dao/src/main/java/org/thingsboard/server/dao/config/SqlTsLatestDaoConfig.java index 6fcf21a0a8..49ff2afdb4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/SqlTsLatestDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/config/SqlTsLatestDaoConfig.java @@ -13,9 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao; +package org.thingsboard.server.dao.config; -import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @@ -28,7 +27,6 @@ import org.thingsboard.server.dao.util.TbAutoConfiguration; @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sqlts.sql"}) @EnableJpaRepositories(value = {"org.thingsboard.server.dao.sqlts.insert.latest.sql", "org.thingsboard.server.dao.sqlts.latest"}, bootstrapMode = BootstrapMode.LAZY) -@EntityScan({"org.thingsboard.server.dao.model.sqlts.latest"}) @EnableTransactionManagement @SqlTsLatestDao public class SqlTsLatestDaoConfig { diff --git a/dao/src/main/java/org/thingsboard/server/dao/TimescaleDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/config/TimescaleDaoConfig.java similarity index 89% rename from dao/src/main/java/org/thingsboard/server/dao/TimescaleDaoConfig.java rename to dao/src/main/java/org/thingsboard/server/dao/config/TimescaleDaoConfig.java index c134b88897..3c910778a2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/TimescaleDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/config/TimescaleDaoConfig.java @@ -13,9 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao; +package org.thingsboard.server.dao.config; -import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @@ -28,7 +27,6 @@ import org.thingsboard.server.dao.util.TimescaleDBTsDao; @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sqlts.timescale"}) @EnableJpaRepositories(value = {"org.thingsboard.server.dao.sqlts.timescale", "org.thingsboard.server.dao.sqlts.insert.timescale"}, bootstrapMode = BootstrapMode.LAZY) -@EntityScan({"org.thingsboard.server.dao.model.sqlts.timescale"}) @EnableTransactionManagement @TimescaleDBTsDao public class TimescaleDaoConfig { diff --git a/dao/src/main/java/org/thingsboard/server/dao/TimescaleTsLatestDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/config/TimescaleTsLatestDaoConfig.java similarity index 89% rename from dao/src/main/java/org/thingsboard/server/dao/TimescaleTsLatestDaoConfig.java rename to dao/src/main/java/org/thingsboard/server/dao/config/TimescaleTsLatestDaoConfig.java index f0e74f1c3d..f6d1e49a6e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/TimescaleTsLatestDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/config/TimescaleTsLatestDaoConfig.java @@ -13,9 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao; +package org.thingsboard.server.dao.config; -import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @@ -28,7 +27,6 @@ import org.thingsboard.server.dao.util.TimescaleDBTsLatestDao; @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sqlts.timescale"}) @EnableJpaRepositories(value = {"org.thingsboard.server.dao.sqlts.insert.latest.sql", "org.thingsboard.server.dao.sqlts.latest"}, bootstrapMode = BootstrapMode.LAZY) -@EntityScan({"org.thingsboard.server.dao.model.sqlts.latest"}) @EnableTransactionManagement @TimescaleDBTsLatestDao public class TimescaleTsLatestDaoConfig { diff --git a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java index dc2fb4c4aa..2067ef4a5d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java @@ -201,9 +201,9 @@ public class CustomerServiceImpl extends AbstractCachedEntityService implements DeviceCredentialsService { - @Autowired - private DeviceCredentialsDao deviceCredentialsDao; - - @Autowired - private DataValidator credentialsValidator; + private final DeviceCredentialsDao deviceCredentialsDao; + private final DeviceCredentialsDataValidator credentialsValidator; @TransactionalEventListener(classes = DeviceCredentialsEvictEvent.class) @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java index eca228891d..6df8f9907e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java @@ -41,19 +41,19 @@ public class DeviceProfileCacheKey implements Serializable { this.provisionDeviceKey = provisionDeviceKey; } - public static DeviceProfileCacheKey fromName(TenantId tenantId, String name) { + public static DeviceProfileCacheKey forName(TenantId tenantId, String name) { return new DeviceProfileCacheKey(tenantId, name, null, false, null); } - public static DeviceProfileCacheKey fromId(DeviceProfileId id) { + public static DeviceProfileCacheKey forId(DeviceProfileId id) { return new DeviceProfileCacheKey(null, null, id, false, null); } - public static DeviceProfileCacheKey defaultProfile(TenantId tenantId) { + public static DeviceProfileCacheKey forDefaultProfile(TenantId tenantId) { return new DeviceProfileCacheKey(tenantId, null, null, true, null); } - public static DeviceProfileCacheKey fromProvisionDeviceKey(String provisionDeviceKey) { + public static DeviceProfileCacheKey forProvisionKey(String provisionDeviceKey) { return new DeviceProfileCacheKey(null, null, null, false, provisionDeviceKey); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCaffeineCache.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCaffeineCache.java index d9bb2fec33..8343c8ba40 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCaffeineCache.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCaffeineCache.java @@ -18,13 +18,13 @@ package org.thingsboard.server.dao.device; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.cache.CacheManager; import org.springframework.stereotype.Service; -import org.thingsboard.server.cache.CaffeineTbTransactionalCache; +import org.thingsboard.server.cache.VersionedCaffeineTbCache; import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.DeviceProfile; @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) @Service("DeviceProfileCache") -public class DeviceProfileCaffeineCache extends CaffeineTbTransactionalCache { +public class DeviceProfileCaffeineCache extends VersionedCaffeineTbCache { public DeviceProfileCaffeineCache(CacheManager cacheManager) { super(cacheManager, CacheConstants.DEVICE_PROFILE_CACHE); diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java index 2b5fc0a644..9de496566b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java @@ -15,11 +15,16 @@ */ package org.thingsboard.server.dao.device; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.TenantId; @Data +@RequiredArgsConstructor +@AllArgsConstructor public class DeviceProfileEvictEvent { private final TenantId tenantId; @@ -28,5 +33,6 @@ public class DeviceProfileEvictEvent { private final DeviceProfileId deviceProfileId; private final boolean defaultProfile; private final String provisionDeviceKey; + private DeviceProfile savedDeviceProfile; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileRedisCache.java index eafcc5d166..15d16d4012 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileRedisCache.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileRedisCache.java @@ -21,9 +21,9 @@ import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.serializer.SerializationException; import org.springframework.stereotype.Service; import org.thingsboard.server.cache.CacheSpecsMap; -import org.thingsboard.server.cache.RedisTbTransactionalCache; import org.thingsboard.server.cache.TBRedisCacheConfiguration; import org.thingsboard.server.cache.TbRedisSerializer; +import org.thingsboard.server.cache.VersionedRedisTbCache; import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.util.ProtoUtils; @@ -31,7 +31,7 @@ import org.thingsboard.server.gen.transport.TransportProtos; @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") @Service("DeviceProfileCache") -public class DeviceProfileRedisCache extends RedisTbTransactionalCache { +public class DeviceProfileRedisCache extends VersionedRedisTbCache { public DeviceProfileRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { super(CacheConstants.DEVICE_PROFILE_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbRedisSerializer() { @@ -50,4 +50,5 @@ public class DeviceProfileRedisCache extends RedisTbTransactionalCache implements DeviceProfileService { +@RequiredArgsConstructor +public class DeviceProfileServiceImpl extends CachedVersionedEntityService implements DeviceProfileService { private static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; private static final String INCORRECT_DEVICE_PROFILE_ID = "Incorrect deviceProfileId "; @@ -87,7 +88,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService deviceProfileValidator; + private DeviceProfileDataValidator deviceProfileValidator; @Autowired private ImageService imageService; @@ -95,21 +96,23 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService keys = new ArrayList<>(2); - keys.add(DeviceProfileCacheKey.fromName(event.getTenantId(), event.getNewName())); - if (event.getDeviceProfileId() != null) { - keys.add(DeviceProfileCacheKey.fromId(event.getDeviceProfileId())); + List toEvict = new ArrayList<>(2); + toEvict.add(DeviceProfileCacheKey.forName(event.getTenantId(), event.getNewName())); + if (event.getSavedDeviceProfile() != null) { + cache.put(DeviceProfileCacheKey.forId(event.getSavedDeviceProfile().getId()), event.getSavedDeviceProfile()); + } else if (event.getDeviceProfileId() != null) { + toEvict.add(DeviceProfileCacheKey.forId(event.getDeviceProfileId())); } if (event.isDefaultProfile()) { - keys.add(DeviceProfileCacheKey.defaultProfile(event.getTenantId())); + toEvict.add(DeviceProfileCacheKey.forDefaultProfile(event.getTenantId())); } if (StringUtils.isNotEmpty(event.getOldName()) && !event.getOldName().equals(event.getNewName())) { - keys.add(DeviceProfileCacheKey.fromName(event.getTenantId(), event.getOldName())); + toEvict.add(DeviceProfileCacheKey.forName(event.getTenantId(), event.getOldName())); } if (StringUtils.isNotEmpty(event.getProvisionDeviceKey())) { - keys.add(DeviceProfileCacheKey.fromProvisionDeviceKey(event.getProvisionDeviceKey())); + toEvict.add(DeviceProfileCacheKey.forProvisionKey(event.getProvisionDeviceKey())); } - cache.evict(keys); + cache.evict(toEvict); } @Override @@ -121,8 +124,8 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService INCORRECT_DEVICE_PROFILE_ID + id); - return cache.getOrFetchFromDB(DeviceProfileCacheKey.fromId(deviceProfileId), - () -> deviceProfileDao.findById(tenantId, deviceProfileId.getId()), true, putInCache); + return cache.get(DeviceProfileCacheKey.forId(deviceProfileId), + () -> deviceProfileDao.findById(tenantId, deviceProfileId.getId()), putInCache); } @Override @@ -134,7 +137,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService INCORRECT_DEVICE_PROFILE_NAME + pn); - return cache.getOrFetchFromDB(DeviceProfileCacheKey.fromName(tenantId, profileName), + return cache.getOrFetchFromDB(DeviceProfileCacheKey.forName(tenantId, profileName), () -> deviceProfileDao.findByName(tenantId, profileName), true, putInCache); } @@ -142,7 +145,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService INCORRECT_PROVISION_DEVICE_KEY + dk); - return cache.getAndPutInTransaction(DeviceProfileCacheKey.fromProvisionDeviceKey(provisionDeviceKey), + return cache.getAndPutInTransaction(DeviceProfileCacheKey.forProvisionKey(provisionDeviceKey), () -> deviceProfileDao.findByProvisionDeviceKey(provisionDeviceKey), false); } @@ -179,7 +182,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService INCORRECT_TENANT_ID + id); - return cache.getAndPutInTransaction(DeviceProfileCacheKey.defaultProfile(tenantId), + return cache.getAndPutInTransaction(DeviceProfileCacheKey.forDefaultProfile(tenantId), () -> deviceProfileDao.findDefaultDeviceProfile(tenantId), true); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index 5b770845c1..e6ba0e5e72 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -18,8 +18,8 @@ package org.thingsboard.server.dao.device; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; @@ -68,7 +68,7 @@ import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.dao.device.provision.ProvisionFailedException; import org.thingsboard.server.dao.device.provision.ProvisionRequest; import org.thingsboard.server.dao.device.provision.ProvisionResponseStatus; -import org.thingsboard.server.dao.entity.AbstractCachedEntityService; +import org.thingsboard.server.dao.entity.CachedVersionedEntityService; import org.thingsboard.server.dao.entity.EntityCountService; import org.thingsboard.server.dao.event.EventService; import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent; @@ -76,8 +76,8 @@ import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.IncorrectParameterException; -import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; +import org.thingsboard.server.dao.service.validator.DeviceDataValidator; import org.thingsboard.server.dao.sql.JpaExecutorService; import org.thingsboard.server.dao.tenant.TenantService; @@ -94,38 +94,23 @@ import static org.thingsboard.server.dao.service.Validator.validateString; @Service("DeviceDaoService") @Slf4j -public class DeviceServiceImpl extends AbstractCachedEntityService implements DeviceService { +@RequiredArgsConstructor +public class DeviceServiceImpl extends CachedVersionedEntityService implements DeviceService { public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; public static final String INCORRECT_DEVICE_PROFILE_ID = "Incorrect deviceProfileId "; - public static final String INCORRECT_PAGE_LINK = "Incorrect page link "; public static final String INCORRECT_CUSTOMER_ID = "Incorrect customerId "; public static final String INCORRECT_DEVICE_ID = "Incorrect deviceId "; public static final String INCORRECT_EDGE_ID = "Incorrect edgeId "; - @Autowired - private DeviceDao deviceDao; - - @Autowired - private DeviceCredentialsService deviceCredentialsService; - - @Autowired - private DeviceProfileService deviceProfileService; - - @Autowired - private EventService eventService; - - @Autowired - private TenantService tenantService; - - @Autowired - private DataValidator deviceValidator; - - @Autowired - private EntityCountService countService; - - @Autowired - private JpaExecutorService executor; + private final DeviceDao deviceDao; + private final DeviceCredentialsService deviceCredentialsService; + private final DeviceProfileService deviceProfileService; + private final EventService eventService; + private final TenantService tenantService; + private final DeviceDataValidator deviceValidator; + private final EntityCountService countService; + private final JpaExecutorService executor; @Override public DeviceInfo findDeviceInfoById(TenantId tenantId, DeviceId deviceId) { @@ -139,11 +124,11 @@ public class DeviceServiceImpl extends AbstractCachedEntityService INCORRECT_DEVICE_ID + id); if (TenantId.SYS_TENANT_ID.equals(tenantId)) { - return cache.getAndPutInTransaction(new DeviceCacheKey(deviceId), - () -> deviceDao.findById(tenantId, deviceId.getId()), true); + return cache.get(new DeviceCacheKey(deviceId), + () -> deviceDao.findById(tenantId, deviceId.getId())); } else { - return cache.getAndPutInTransaction(new DeviceCacheKey(tenantId, deviceId), - () -> deviceDao.findDeviceByTenantIdAndId(tenantId, deviceId.getId()), true); + return cache.get(new DeviceCacheKey(tenantId, deviceId), + () -> deviceDao.findDeviceByTenantIdAndId(tenantId, deviceId.getId())); } } @@ -251,6 +236,7 @@ public class DeviceServiceImpl extends AbstractCachedEntityService keys = new ArrayList<>(3); - keys.add(new DeviceCacheKey(event.getTenantId(), event.getNewName())); - if (event.getDeviceId() != null) { - keys.add(new DeviceCacheKey(event.getDeviceId())); - keys.add(new DeviceCacheKey(event.getTenantId(), event.getDeviceId())); - } + List toEvict = new ArrayList<>(3); + toEvict.add(new DeviceCacheKey(event.getTenantId(), event.getNewName())); if (StringUtils.isNotEmpty(event.getOldName()) && !event.getOldName().equals(event.getNewName())) { - keys.add(new DeviceCacheKey(event.getTenantId(), event.getOldName())); + toEvict.add(new DeviceCacheKey(event.getTenantId(), event.getOldName())); + } + Device savedDevice = event.getSavedDevice(); + if (savedDevice != null) { + cache.put(new DeviceCacheKey(event.getDeviceId()), savedDevice); + cache.put(new DeviceCacheKey(event.getTenantId(), event.getDeviceId()), savedDevice); + } else { + toEvict.add(new DeviceCacheKey(event.getDeviceId())); + toEvict.add(new DeviceCacheKey(event.getTenantId(), event.getDeviceId())); } - cache.evict(keys); + cache.evict(toEvict); } private DeviceData syncDeviceData(DeviceProfile deviceProfile, DeviceData deviceData) { @@ -417,7 +407,7 @@ public class DeviceServiceImpl extends AbstractCachedEntityService INCORRECT_TENANT_ID + id); validateId(deviceProfileId, id -> INCORRECT_DEVICE_PROFILE_ID + id); diff --git a/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java index d4d3d2fb37..c03bc0a238 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java @@ -246,8 +246,8 @@ public class EdgeServiceImpl extends AbstractCachedEntityService extends AbstractCachedEntityService { + + @Autowired + protected VersionedTbCache cache; + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewCacheValue.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewCacheValue.java index 271d604e31..e02182b498 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewCacheValue.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewCacheValue.java @@ -19,6 +19,7 @@ import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.HasVersion; import java.io.Serializable; import java.util.List; @@ -26,11 +27,16 @@ import java.util.List; @Getter @EqualsAndHashCode @Builder -public class EntityViewCacheValue implements Serializable { +public class EntityViewCacheValue implements Serializable, HasVersion { private static final long serialVersionUID = 1959004642076413174L; private final EntityView entityView; private final List entityViews; + @Override + public Long getVersion() { + return entityView != null ? entityView.getVersion() : 0; + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewCaffeineCache.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewCaffeineCache.java index 7ee5a1a725..0aec41493b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewCaffeineCache.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewCaffeineCache.java @@ -18,12 +18,12 @@ package org.thingsboard.server.dao.entityview; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.cache.CacheManager; import org.springframework.stereotype.Service; -import org.thingsboard.server.cache.CaffeineTbTransactionalCache; +import org.thingsboard.server.cache.VersionedCaffeineTbCache; import org.thingsboard.server.common.data.CacheConstants; @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) @Service("EntityViewCache") -public class EntityViewCaffeineCache extends CaffeineTbTransactionalCache { +public class EntityViewCaffeineCache extends VersionedCaffeineTbCache { public EntityViewCaffeineCache(CacheManager cacheManager) { super(cacheManager, CacheConstants.ENTITY_VIEW_CACHE); diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewEvictEvent.java index aac8167d50..2e81084a9e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewEvictEvent.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewEvictEvent.java @@ -15,21 +15,25 @@ */ package org.thingsboard.server.dao.entityview; +import lombok.AllArgsConstructor; import lombok.Data; import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; @Data @RequiredArgsConstructor +@AllArgsConstructor class EntityViewEvictEvent { private final TenantId tenantId; - private final EntityViewId id; + private final EntityViewId entityViewId; private final EntityId newEntityId; private final EntityId oldEntityId; private final String newName; private final String oldName; + private EntityView savedEntityView; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewRedisCache.java index 8ce7f84b75..480540a78b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewRedisCache.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewRedisCache.java @@ -19,14 +19,14 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.stereotype.Service; import org.thingsboard.server.cache.CacheSpecsMap; -import org.thingsboard.server.cache.RedisTbTransactionalCache; import org.thingsboard.server.cache.TBRedisCacheConfiguration; import org.thingsboard.server.cache.TbJsonRedisSerializer; +import org.thingsboard.server.cache.VersionedRedisTbCache; import org.thingsboard.server.common.data.CacheConstants; @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") @Service("EntityViewCache") -public class EntityViewRedisCache extends RedisTbTransactionalCache { +public class EntityViewRedisCache extends VersionedRedisTbCache { public EntityViewRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { super(CacheConstants.ENTITY_VIEW_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(EntityViewCacheValue.class)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 2004184cda..cbe6883b46 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -19,6 +19,7 @@ import com.google.common.base.Function; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.Nullable; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -43,16 +44,15 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationTypeGroup; -import org.thingsboard.server.dao.entity.AbstractCachedEntityService; +import org.thingsboard.server.dao.entity.CachedVersionedEntityService; import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.exception.DataValidationException; -import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; +import org.thingsboard.server.dao.service.validator.EntityViewDataValidator; import org.thingsboard.server.dao.sql.JpaExecutorService; -import jakarta.annotation.Nullable; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -69,7 +69,7 @@ import static org.thingsboard.server.dao.service.Validator.validateString; */ @Service("EntityViewDaoService") @Slf4j -public class EntityViewServiceImpl extends AbstractCachedEntityService implements EntityViewService { +public class EntityViewServiceImpl extends CachedVersionedEntityService implements EntityViewService { public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; public static final String INCORRECT_CUSTOMER_ID = "Incorrect customerId "; @@ -80,7 +80,7 @@ public class EntityViewServiceImpl extends AbstractCachedEntityService entityViewValidator; + private EntityViewDataValidator entityViewValidator; @Autowired protected JpaExecutorService service; @@ -88,17 +88,21 @@ public class EntityViewServiceImpl extends AbstractCachedEntityService keys = new ArrayList<>(5); - keys.add(EntityViewCacheKey.byName(event.getTenantId(), event.getNewName())); - keys.add(EntityViewCacheKey.byId(event.getId())); - keys.add(EntityViewCacheKey.byEntityId(event.getTenantId(), event.getNewEntityId())); + List toEvict = new ArrayList<>(5); + toEvict.add(EntityViewCacheKey.byName(event.getTenantId(), event.getNewName())); + if (event.getSavedEntityView() != null) { + cache.put(EntityViewCacheKey.byId(event.getSavedEntityView().getId()), new EntityViewCacheValue(event.getSavedEntityView(), null)); + } else if (event.getEntityViewId() != null) { + toEvict.add(EntityViewCacheKey.byId(event.getEntityViewId())); + } + toEvict.add(EntityViewCacheKey.byEntityId(event.getTenantId(), event.getNewEntityId())); if (event.getOldEntityId() != null && !event.getOldEntityId().equals(event.getNewEntityId())) { - keys.add(EntityViewCacheKey.byEntityId(event.getTenantId(), event.getOldEntityId())); + toEvict.add(EntityViewCacheKey.byEntityId(event.getTenantId(), event.getOldEntityId())); } if (StringUtils.isNotEmpty(event.getOldName()) && !event.getOldName().equals(event.getNewName())) { - keys.add(EntityViewCacheKey.byName(event.getTenantId(), event.getOldName())); + toEvict.add(EntityViewCacheKey.byName(event.getTenantId(), event.getOldName())); } - cache.evict(keys); + cache.evict(toEvict); } @Override @@ -113,11 +117,11 @@ public class EntityViewServiceImpl extends AbstractCachedEntityService INCORRECT_ENTITY_VIEW_ID + id); - return cache.getOrFetchFromDB(EntityViewCacheKey.byId(entityViewId), - () -> entityViewDao.findById(tenantId, entityViewId.getId()) - , EntityViewCacheValue::getEntityView, v -> new EntityViewCacheValue(v, null), true, putInCache); + EntityViewCacheValue value = cache.get(EntityViewCacheKey.byId(entityViewId), () -> { + EntityView entityView = entityViewDao.findById(tenantId, entityViewId.getId()); + return new EntityViewCacheValue(entityView, null); + }, putInCache); + return value != null ? value.getEntityView() : null; } @Override @@ -233,7 +239,7 @@ public class EntityViewServiceImpl extends AbstractCachedEntityServiceINCORRECT_TENANT_ID + id); + validateId(tenantId, id -> INCORRECT_TENANT_ID + id); validateId(customerId, id -> INCORRECT_CUSTOMER_ID + id); validatePageLink(pageLink); return entityViewDao.findEntityViewsByTenantIdAndCustomerId(tenantId.getId(), diff --git a/dao/src/main/java/org/thingsboard/server/dao/event/EventDao.java b/dao/src/main/java/org/thingsboard/server/dao/event/EventDao.java index 6b3febf502..52335d87ec 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/event/EventDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/event/EventDao.java @@ -101,5 +101,4 @@ public interface EventDao { */ void removeEvents(UUID tenantId, UUID entityId, EventFilter eventFilter, Long startTime, Long endTime); - void migrateEvents(long regularEventTs, long debugEventTs); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/BaseSqlEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/BaseSqlEntity.java index fd349828e2..9f363ace96 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/BaseSqlEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/BaseSqlEntity.java @@ -16,17 +16,18 @@ package org.thingsboard.server.dao.model; import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; import lombok.Data; import org.apache.commons.lang3.StringUtils; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.BaseData; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.dao.DaoUtil; -import jakarta.persistence.Column; -import jakarta.persistence.Id; -import jakarta.persistence.MappedSuperclass; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -48,6 +49,19 @@ public abstract class BaseSqlEntity implements BaseEntity { @Column(name = ModelConstants.CREATED_TIME_PROPERTY, updatable = false) protected long createdTime; + public BaseSqlEntity() { + } + + public BaseSqlEntity(BaseData domain) { + this.id = domain.getUuidId(); + this.createdTime = domain.getCreatedTime(); + } + + public BaseSqlEntity(BaseSqlEntity entity) { + this.id = entity.id; + this.createdTime = entity.createdTime; + } + @Override public UUID getUuid() { return id; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/BaseVersionedEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/BaseVersionedEntity.java new file mode 100644 index 0000000000..900fe2ed7f --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/BaseVersionedEntity.java @@ -0,0 +1,61 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.model; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.Version; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.HasVersion; + +@Data +@EqualsAndHashCode(callSuper = true) +@MappedSuperclass +public abstract class BaseVersionedEntity extends BaseSqlEntity implements HasVersion { + + @Getter @Setter + @Version + @Column(name = ModelConstants.VERSION_PROPERTY) + protected Long version; + + public BaseVersionedEntity() { + super(); + } + + public BaseVersionedEntity(D domain) { + super(domain); + this.version = domain.getVersion(); + } + + public BaseVersionedEntity(BaseVersionedEntity entity) { + super(entity); + this.version = entity.version; + } + + @Override + public String toString() { + return "BaseVersionedEntity{" + + "id=" + id + + ", createdTime=" + createdTime + + ", version=" + version + + '}'; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index fb77ad6987..27f1019a68 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -49,6 +49,7 @@ public class ModelConstants { public static final String SEARCH_TEXT_PROPERTY = "search_text"; public static final String ADDITIONAL_INFO_PROPERTY = "additional_info"; public static final String ENTITY_TYPE_PROPERTY = "entity_type"; + public static final String VERSION_PROPERTY = "version"; public static final String ENTITY_TYPE_COLUMN = ENTITY_TYPE_PROPERTY; public static final String TENANT_ID_COLUMN = "tenant_id"; @@ -56,6 +57,7 @@ public class ModelConstants { public static final String ATTRIBUTE_TYPE_COLUMN = "attribute_type"; public static final String ATTRIBUTE_KEY_COLUMN = "attribute_key"; public static final String LAST_UPDATE_TS_COLUMN = "last_update_ts"; + public static final String VERSION_COLUMN = "version"; /** * User constants. @@ -302,6 +304,7 @@ public class ModelConstants { public static final String WIDGETS_BUNDLE_ALIAS_PROPERTY = ALIAS_PROPERTY; public static final String WIDGETS_BUNDLE_TITLE_PROPERTY = TITLE_PROPERTY; public static final String WIDGETS_BUNDLE_IMAGE_PROPERTY = "image"; + public static final String WIDGETS_BUNDLE_SCADA_PROPERTY = "scada"; public static final String WIDGETS_BUNDLE_DESCRIPTION = "description"; public static final String WIDGETS_BUNDLE_ORDER = "widgets_bundle_order"; @@ -320,6 +323,8 @@ public class ModelConstants { public static final String WIDGET_TYPE_DEPRECATED_PROPERTY = "deprecated"; + public static final String WIDGET_TYPE_SCADA_PROPERTY = "scada"; + public static final String WIDGET_TYPE_WIDGET_TYPE_PROPERTY = "widget_type"; public static final String WIDGET_TYPE_INFO_VIEW_TABLE_NAME = "widget_type_info_view"; @@ -498,6 +503,7 @@ public class ModelConstants { public static final String RESOURCE_TABLE_NAME = "resource"; public static final String RESOURCE_TENANT_ID_COLUMN = TENANT_ID_COLUMN; public static final String RESOURCE_TYPE_COLUMN = "resource_type"; + public static final String RESOURCE_SUB_TYPE_COLUMN = "resource_sub_type"; public static final String RESOURCE_KEY_COLUMN = "resource_key"; public static final String RESOURCE_TITLE_COLUMN = TITLE_PROPERTY; public static final String RESOURCE_FILE_NAME_COLUMN = "file_name"; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractAlarmCommentEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractAlarmCommentEntity.java index 50fe1f7636..30c53c30e1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractAlarmCommentEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractAlarmCommentEntity.java @@ -26,7 +26,6 @@ import org.thingsboard.server.common.data.alarm.AlarmCommentType; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.UserId; -import org.thingsboard.server.dao.model.BaseEntity; import org.thingsboard.server.dao.model.BaseSqlEntity; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.util.mapping.JsonConverter; @@ -40,7 +39,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.ALARM_COMMENT_TYPE @Data @EqualsAndHashCode(callSuper = true) @MappedSuperclass -public abstract class AbstractAlarmCommentEntity extends BaseSqlEntity implements BaseEntity { +public abstract class AbstractAlarmCommentEntity extends BaseSqlEntity { @Column(name = ALARM_COMMENT_ALARM_ID, columnDefinition = "uuid") private UUID alarmId; @@ -94,4 +93,5 @@ public abstract class AbstractAlarmCommentEntity extends alarmComment.setComment(comment); return alarmComment; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractAssetEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractAssetEntity.java index 4c488b3917..716e794511 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractAssetEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractAssetEntity.java @@ -26,7 +26,7 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.util.mapping.JsonConverter; @@ -42,7 +42,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.EXTERNAL_ID_PROPER @Data @EqualsAndHashCode(callSuper = true) @MappedSuperclass -public abstract class AbstractAssetEntity extends BaseSqlEntity { +public abstract class AbstractAssetEntity extends BaseVersionedEntity { @Column(name = ASSET_TENANT_ID_PROPERTY) private UUID tenantId; @@ -73,11 +73,8 @@ public abstract class AbstractAssetEntity extends BaseSqlEntity super(); } - public AbstractAssetEntity(Asset asset) { - if (asset.getId() != null) { - this.setUuid(asset.getId().getId()); - } - this.setCreatedTime(asset.getCreatedTime()); + public AbstractAssetEntity(T asset) { + super(asset); if (asset.getTenantId() != null) { this.tenantId = asset.getTenantId().getId(); } @@ -97,8 +94,7 @@ public abstract class AbstractAssetEntity extends BaseSqlEntity } public AbstractAssetEntity(AssetEntity assetEntity) { - this.setId(assetEntity.getId()); - this.setCreatedTime(assetEntity.getCreatedTime()); + super(assetEntity); this.tenantId = assetEntity.getTenantId(); this.customerId = assetEntity.getCustomerId(); this.assetProfileId = assetEntity.getAssetProfileId(); @@ -112,6 +108,7 @@ public abstract class AbstractAssetEntity extends BaseSqlEntity protected Asset toAsset() { Asset asset = new Asset(new AssetId(id)); asset.setCreatedTime(createdTime); + asset.setVersion(version); if (tenantId != null) { asset.setTenantId(TenantId.fromUUID(tenantId)); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractDeviceEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractDeviceEntity.java index 1c0f47d062..3ce6d171dc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractDeviceEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractDeviceEntity.java @@ -32,7 +32,7 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.OtaPackageId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.util.mapping.JsonConverter; @@ -41,7 +41,7 @@ import java.util.UUID; @Data @EqualsAndHashCode(callSuper = true) @MappedSuperclass -public abstract class AbstractDeviceEntity extends BaseSqlEntity { +public abstract class AbstractDeviceEntity extends BaseVersionedEntity { @Column(name = ModelConstants.DEVICE_TENANT_ID_PROPERTY, columnDefinition = "uuid") private UUID tenantId; @@ -83,11 +83,8 @@ public abstract class AbstractDeviceEntity extends BaseSqlEnti super(); } - public AbstractDeviceEntity(Device device) { - if (device.getId() != null) { - this.setUuid(device.getUuidId()); - } - this.setCreatedTime(device.getCreatedTime()); + public AbstractDeviceEntity(T device) { + super(device); if (device.getTenantId() != null) { this.tenantId = device.getTenantId().getId(); } @@ -113,9 +110,8 @@ public abstract class AbstractDeviceEntity extends BaseSqlEnti } } - public AbstractDeviceEntity(DeviceEntity deviceEntity) { - this.setId(deviceEntity.getId()); - this.setCreatedTime(deviceEntity.getCreatedTime()); + public AbstractDeviceEntity(AbstractDeviceEntity deviceEntity) { + super(deviceEntity); this.tenantId = deviceEntity.getTenantId(); this.customerId = deviceEntity.getCustomerId(); this.deviceProfileId = deviceEntity.getDeviceProfileId(); @@ -132,6 +128,7 @@ public abstract class AbstractDeviceEntity extends BaseSqlEnti protected Device toDevice() { Device device = new Device(new DeviceId(getUuid())); device.setCreatedTime(createdTime); + device.setVersion(version); if (tenantId != null) { device.setTenantId(TenantId.fromUUID(tenantId)); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractEdgeEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractEdgeEntity.java index 19907c8182..f02b33b65f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractEdgeEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractEdgeEntity.java @@ -26,7 +26,7 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.util.mapping.JsonConverter; @@ -44,7 +44,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.EDGE_TYPE_PROPERTY @Data @EqualsAndHashCode(callSuper = true) @MappedSuperclass -public abstract class AbstractEdgeEntity extends BaseSqlEntity { +public abstract class AbstractEdgeEntity extends BaseVersionedEntity { @Column(name = EDGE_TENANT_ID_PROPERTY, columnDefinition = "uuid") private UUID tenantId; @@ -78,11 +78,8 @@ public abstract class AbstractEdgeEntity extends BaseSqlEntity extends BaseSqlEntity extends BaseSqlEntity extends BaseSqlEntity extends BaseSqlEntity { +public abstract class AbstractEntityViewEntity extends BaseVersionedEntity { @Column(name = ModelConstants.ENTITY_VIEW_ENTITY_ID_PROPERTY) private UUID entityId; @@ -89,11 +89,8 @@ public abstract class AbstractEntityViewEntity extends Bas super(); } - public AbstractEntityViewEntity(EntityView entityView) { - if (entityView.getId() != null) { - this.setUuid(entityView.getId().getId()); - } - this.setCreatedTime(entityView.getCreatedTime()); + public AbstractEntityViewEntity(T entityView) { + super(entityView); if (entityView.getEntityId() != null) { this.entityId = entityView.getEntityId().getId(); this.entityType = entityView.getEntityId().getEntityType(); @@ -120,8 +117,7 @@ public abstract class AbstractEntityViewEntity extends Bas } public AbstractEntityViewEntity(EntityViewEntity entityViewEntity) { - this.setId(entityViewEntity.getId()); - this.setCreatedTime(entityViewEntity.getCreatedTime()); + super(entityViewEntity); this.entityId = entityViewEntity.getEntityId(); this.entityType = entityViewEntity.getEntityType(); this.tenantId = entityViewEntity.getTenantId(); @@ -138,6 +134,7 @@ public abstract class AbstractEntityViewEntity extends Bas protected EntityView toEntityView() { EntityView entityView = new EntityView(new EntityViewId(getUuid())); entityView.setCreatedTime(createdTime); + entityView.setVersion(version); if (entityId != null) { entityView.setEntityId(EntityIdFactory.getByTypeAndUuid(entityType.name(), entityId)); @@ -163,4 +160,5 @@ public abstract class AbstractEntityViewEntity extends Bas } return entityView; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractTenantEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractTenantEntity.java index 845a196569..61fed6097e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractTenantEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractTenantEntity.java @@ -24,7 +24,7 @@ import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; -import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.util.mapping.JsonConverter; @@ -33,7 +33,7 @@ import java.util.UUID; @Data @EqualsAndHashCode(callSuper = true) @MappedSuperclass -public abstract class AbstractTenantEntity extends BaseSqlEntity { +public abstract class AbstractTenantEntity extends BaseVersionedEntity { @Column(name = ModelConstants.TENANT_TITLE_PROPERTY) private String title; @@ -76,11 +76,8 @@ public abstract class AbstractTenantEntity extends BaseSqlEnti super(); } - public AbstractTenantEntity(Tenant tenant) { - if (tenant.getId() != null) { - this.setUuid(tenant.getId().getId()); - } - this.setCreatedTime(tenant.getCreatedTime()); + public AbstractTenantEntity(T tenant) { + super(tenant); this.title = tenant.getTitle(); this.region = tenant.getRegion(); this.country = tenant.getCountry(); @@ -98,8 +95,7 @@ public abstract class AbstractTenantEntity extends BaseSqlEnti } public AbstractTenantEntity(TenantEntity tenantEntity) { - this.setId(tenantEntity.getId()); - this.setCreatedTime(tenantEntity.getCreatedTime()); + super(tenantEntity); this.title = tenantEntity.getTitle(); this.region = tenantEntity.getRegion(); this.country = tenantEntity.getCountry(); @@ -117,6 +113,7 @@ public abstract class AbstractTenantEntity extends BaseSqlEnti protected Tenant toTenant() { Tenant tenant = new Tenant(TenantId.fromUUID(this.getUuid())); tenant.setCreatedTime(createdTime); + tenant.setVersion(version); tenant.setTitle(title); tenant.setRegion(region); tenant.setCountry(country); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractTsKvEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractTsKvEntity.java index a0a4664829..ae4f47a37a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractTsKvEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractTsKvEntity.java @@ -119,10 +119,13 @@ public abstract class AbstractTsKvEntity implements ToData { } if (aggValuesCount == null) { - return new BasicTsKvEntry(ts, kvEntry); + return new BasicTsKvEntry(ts, kvEntry, getVersion()); } else { return new AggTsKvEntry(ts, kvEntry, aggValuesCount); } } -} \ No newline at end of file + public Long getVersion() { + return null; + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractWidgetTypeEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractWidgetTypeEntity.java index ea26b60cab..d649d1e31a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractWidgetTypeEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractWidgetTypeEntity.java @@ -15,23 +15,22 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.WidgetTypeId; import org.thingsboard.server.common.data.widget.BaseWidgetType; -import org.thingsboard.server.dao.model.BaseEntity; -import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; -import jakarta.persistence.Column; -import jakarta.persistence.MappedSuperclass; import java.util.UUID; @Data @EqualsAndHashCode(callSuper = true) @MappedSuperclass -public abstract class AbstractWidgetTypeEntity extends BaseSqlEntity { +public abstract class AbstractWidgetTypeEntity extends BaseVersionedEntity { @Column(name = ModelConstants.WIDGET_TYPE_TENANT_ID_PROPERTY) private UUID tenantId; @@ -45,41 +44,44 @@ public abstract class AbstractWidgetTypeEntity extends @Column(name = ModelConstants.WIDGET_TYPE_DEPRECATED_PROPERTY) private boolean deprecated; + @Column(name = ModelConstants.WIDGET_TYPE_SCADA_PROPERTY) + private boolean scada; + public AbstractWidgetTypeEntity() { super(); } - public AbstractWidgetTypeEntity(BaseWidgetType widgetType) { - if (widgetType.getId() != null) { - this.setUuid(widgetType.getId().getId()); - } - this.setCreatedTime(widgetType.getCreatedTime()); + public AbstractWidgetTypeEntity(T widgetType) { + super(widgetType); if (widgetType.getTenantId() != null) { this.tenantId = widgetType.getTenantId().getId(); } this.fqn = widgetType.getFqn(); this.name = widgetType.getName(); this.deprecated = widgetType.isDeprecated(); + this.scada = widgetType.isScada(); } public AbstractWidgetTypeEntity(AbstractWidgetTypeEntity widgetTypeEntity) { - this.setId(widgetTypeEntity.getId()); - this.setCreatedTime(widgetTypeEntity.getCreatedTime()); + super(widgetTypeEntity); this.tenantId = widgetTypeEntity.getTenantId(); this.fqn = widgetTypeEntity.getFqn(); this.name = widgetTypeEntity.getName(); this.deprecated = widgetTypeEntity.isDeprecated(); + this.scada = widgetTypeEntity.isScada(); } protected BaseWidgetType toBaseWidgetType() { BaseWidgetType widgetType = new BaseWidgetType(new WidgetTypeId(getUuid())); widgetType.setCreatedTime(createdTime); + widgetType.setVersion(version); if (tenantId != null) { widgetType.setTenantId(TenantId.fromUUID(tenantId)); } widgetType.setFqn(fqn); widgetType.setName(name); widgetType.setDeprecated(deprecated); + widgetType.setScada(scada); return widgetType; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AlarmInfoEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AlarmInfoEntity.java index 942d9cbe75..5494c8e516 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AlarmInfoEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AlarmInfoEntity.java @@ -15,16 +15,15 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.alarm.AlarmAssignee; import org.thingsboard.server.common.data.alarm.AlarmInfo; import org.thingsboard.server.common.data.id.UserId; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - import static org.thingsboard.server.dao.model.ModelConstants.ALARM_ASSIGNEE_EMAIL_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.ALARM_ASSIGNEE_FIRST_NAME_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.ALARM_ASSIGNEE_LAST_NAME_PROPERTY; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AssetInfoEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AssetInfoEntity.java index 264f41c226..73264a15df 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AssetInfoEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AssetInfoEntity.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.model.sql; import com.fasterxml.jackson.databind.JsonNode; import lombok.Data; import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; import java.util.HashMap; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AssetProfileEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AssetProfileEntity.java index d035db07a7..536da5b1ec 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AssetProfileEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AssetProfileEntity.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -22,19 +25,16 @@ import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; import java.util.UUID; @Data @EqualsAndHashCode(callSuper = true) @Entity @Table(name = ModelConstants.ASSET_PROFILE_TABLE_NAME) -public final class AssetProfileEntity extends BaseSqlEntity { +public final class AssetProfileEntity extends BaseVersionedEntity { @Column(name = ModelConstants.ASSET_PROFILE_TENANT_ID_PROPERTY) private UUID tenantId; @@ -71,13 +71,10 @@ public final class AssetProfileEntity extends BaseSqlEntity { } public AssetProfileEntity(AssetProfile assetProfile) { - if (assetProfile.getId() != null) { - this.setUuid(assetProfile.getId().getId()); - } + super(assetProfile); if (assetProfile.getTenantId() != null) { this.tenantId = assetProfile.getTenantId().getId(); } - this.setCreatedTime(assetProfile.getCreatedTime()); this.name = assetProfile.getName(); this.image = assetProfile.getImage(); this.description = assetProfile.getDescription(); @@ -101,6 +98,7 @@ public final class AssetProfileEntity extends BaseSqlEntity { public AssetProfile toData() { AssetProfile assetProfile = new AssetProfile(new AssetProfileId(this.getUuid())); assetProfile.setCreatedTime(createdTime); + assetProfile.setVersion(version); if (tenantId != null) { assetProfile.setTenantId(TenantId.fromUUID(tenantId)); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java index 03aa0f98ad..615932ddde 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java @@ -15,6 +15,11 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; import lombok.Data; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; @@ -26,11 +31,6 @@ import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.dao.model.ToData; -import jakarta.persistence.Column; -import jakarta.persistence.EmbeddedId; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import jakarta.persistence.Transient; import java.io.Serializable; import static org.thingsboard.server.dao.model.ModelConstants.BOOLEAN_VALUE_COLUMN; @@ -39,6 +39,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.JSON_VALUE_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.LAST_UPDATE_TS_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.LONG_VALUE_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.STRING_VALUE_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.VERSION_COLUMN; @Data @Entity @@ -66,6 +67,9 @@ public class AttributeKvEntity implements ToData, Serializable @Column(name = LAST_UPDATE_TS_COLUMN) private Long lastUpdateTs; + @Column(name = VERSION_COLUMN) + private Long version; + @Transient protected String strKey; @@ -84,6 +88,6 @@ public class AttributeKvEntity implements ToData, Serializable kvEntry = new JsonDataEntry(strKey, jsonValue); } - return new BaseAttributeKvEntry(kvEntry, lastUpdateTs); + return new BaseAttributeKvEntry(kvEntry, lastUpdateTs, version); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AuditLogEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AuditLogEntity.java index 1e878e9dc5..85e697bb2b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AuditLogEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AuditLogEntity.java @@ -149,4 +149,5 @@ public class AuditLogEntity extends BaseSqlEntity implements BaseEntit auditLog.setActionFailureDetails(this.actionFailureDetails); return auditLog; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/CustomerEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CustomerEntity.java index 569200e566..526f2a057a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/CustomerEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CustomerEntity.java @@ -25,7 +25,7 @@ import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.util.mapping.JsonConverter; @@ -35,7 +35,7 @@ import java.util.UUID; @EqualsAndHashCode(callSuper = true) @Entity @Table(name = ModelConstants.CUSTOMER_TABLE_NAME) -public final class CustomerEntity extends BaseSqlEntity { +public final class CustomerEntity extends BaseVersionedEntity { @Column(name = ModelConstants.CUSTOMER_TENANT_ID_PROPERTY) private UUID tenantId; @@ -82,10 +82,7 @@ public final class CustomerEntity extends BaseSqlEntity { } public CustomerEntity(Customer customer) { - if (customer.getId() != null) { - this.setUuid(customer.getId().getId()); - } - this.setCreatedTime(customer.getCreatedTime()); + super(customer); this.tenantId = customer.getTenantId().getId(); this.title = customer.getTitle(); this.country = customer.getCountry(); @@ -107,6 +104,7 @@ public final class CustomerEntity extends BaseSqlEntity { public Customer toData() { Customer customer = new Customer(new CustomerId(this.getUuid())); customer.setCreatedTime(createdTime); + customer.setVersion(version); customer.setTenantId(TenantId.fromUUID(tenantId)); customer.setTitle(title); customer.setCountry(country); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/DashboardEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/DashboardEntity.java index e5f4723699..c9e074ac7e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/DashboardEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/DashboardEntity.java @@ -30,7 +30,7 @@ import org.thingsboard.server.common.data.ShortCustomerInfo; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.util.mapping.JsonConverter; @@ -42,7 +42,7 @@ import java.util.UUID; @EqualsAndHashCode(callSuper = true) @Entity @Table(name = ModelConstants.DASHBOARD_TABLE_NAME) -public final class DashboardEntity extends BaseSqlEntity { +public final class DashboardEntity extends BaseVersionedEntity { private static final JavaType assignedCustomersType = JacksonUtil.constructCollectionType(HashSet.class, ShortCustomerInfo.class); @@ -77,10 +77,7 @@ public final class DashboardEntity extends BaseSqlEntity { } public DashboardEntity(Dashboard dashboard) { - if (dashboard.getId() != null) { - this.setUuid(dashboard.getId().getId()); - } - this.setCreatedTime(dashboard.getCreatedTime()); + super(dashboard); if (dashboard.getTenantId() != null) { this.tenantId = dashboard.getTenantId().getId(); } @@ -105,6 +102,7 @@ public final class DashboardEntity extends BaseSqlEntity { public Dashboard toData() { Dashboard dashboard = new Dashboard(new DashboardId(this.getUuid())); dashboard.setCreatedTime(this.getCreatedTime()); + dashboard.setVersion(version); if (tenantId != null) { dashboard.setTenantId(TenantId.fromUUID(tenantId)); } @@ -125,4 +123,5 @@ public final class DashboardEntity extends BaseSqlEntity { } return dashboard; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/DashboardInfoEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/DashboardInfoEntity.java index 067ad635df..93a1b1ad27 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/DashboardInfoEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/DashboardInfoEntity.java @@ -28,7 +28,7 @@ import org.thingsboard.server.common.data.ShortCustomerInfo; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; import java.util.HashSet; @@ -39,7 +39,7 @@ import java.util.UUID; @EqualsAndHashCode(callSuper = true) @Entity @Table(name = ModelConstants.DASHBOARD_TABLE_NAME) -public class DashboardInfoEntity extends BaseSqlEntity { +public class DashboardInfoEntity extends BaseVersionedEntity { private static final JavaType assignedCustomersType = JacksonUtil.constructCollectionType(HashSet.class, ShortCustomerInfo.class); @@ -67,10 +67,7 @@ public class DashboardInfoEntity extends BaseSqlEntity { } public DashboardInfoEntity(DashboardInfo dashboardInfo) { - if (dashboardInfo.getId() != null) { - this.setUuid(dashboardInfo.getId().getId()); - } - this.setCreatedTime(dashboardInfo.getCreatedTime()); + super(dashboardInfo); if (dashboardInfo.getTenantId() != null) { this.tenantId = dashboardInfo.getTenantId().getId(); } @@ -91,6 +88,7 @@ public class DashboardInfoEntity extends BaseSqlEntity { public DashboardInfo toData() { DashboardInfo dashboardInfo = new DashboardInfo(new DashboardId(this.getUuid())); dashboardInfo.setCreatedTime(createdTime); + dashboardInfo.setVersion(version); if (tenantId != null) { dashboardInfo.setTenantId(TenantId.fromUUID(tenantId)); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/DeviceCredentialsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/DeviceCredentialsEntity.java index 34a98f1c28..edcaa745c7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/DeviceCredentialsEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/DeviceCredentialsEntity.java @@ -15,28 +15,27 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.DeviceCredentialsId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; -import org.thingsboard.server.dao.model.BaseEntity; -import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.Table; import java.util.UUID; @Data @EqualsAndHashCode(callSuper = true) @Entity @Table(name = ModelConstants.DEVICE_CREDENTIALS_TABLE_NAME) -public final class DeviceCredentialsEntity extends BaseSqlEntity implements BaseEntity { +public final class DeviceCredentialsEntity extends BaseVersionedEntity { @Column(name = ModelConstants.DEVICE_CREDENTIALS_DEVICE_ID_PROPERTY) private UUID deviceId; @@ -56,10 +55,7 @@ public final class DeviceCredentialsEntity extends BaseSqlEntity { +@ToString(callSuper = true) +public final class DeviceProfileEntity extends BaseVersionedEntity { @Column(name = ModelConstants.DEVICE_PROFILE_TENANT_ID_PROPERTY) private UUID tenantId; @@ -111,13 +113,10 @@ public final class DeviceProfileEntity extends BaseSqlEntity { } public DeviceProfileEntity(DeviceProfile deviceProfile) { - if (deviceProfile.getId() != null) { - this.setUuid(deviceProfile.getId().getId()); - } + super(deviceProfile); if (deviceProfile.getTenantId() != null) { this.tenantId = deviceProfile.getTenantId().getId(); } - this.setCreatedTime(deviceProfile.getCreatedTime()); this.name = deviceProfile.getName(); this.type = deviceProfile.getType(); this.image = deviceProfile.getImage(); @@ -152,6 +151,7 @@ public final class DeviceProfileEntity extends BaseSqlEntity { public DeviceProfile toData() { DeviceProfile deviceProfile = new DeviceProfile(new DeviceProfileId(this.getUuid())); deviceProfile.setCreatedTime(createdTime); + deviceProfile.setVersion(version); if (tenantId != null) { deviceProfile.setTenantId(TenantId.fromUUID(tenantId)); } @@ -187,4 +187,5 @@ public final class DeviceProfileEntity extends BaseSqlEntity { return deviceProfile; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityAlarmCompositeKey.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityAlarmCompositeKey.java index e35655917c..575460032f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityAlarmCompositeKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityAlarmCompositeKey.java @@ -15,12 +15,12 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Transient; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.EntityAlarm; -import jakarta.persistence.Transient; import java.io.Serializable; import java.util.UUID; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityAlarmEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityAlarmEntity.java index c3b8ccbfe2..6b169be262 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityAlarmEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityAlarmEntity.java @@ -15,6 +15,11 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; import lombok.Data; import org.thingsboard.server.common.data.alarm.EntityAlarm; import org.thingsboard.server.common.data.id.AlarmId; @@ -23,11 +28,6 @@ import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.model.ToData; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.IdClass; -import jakarta.persistence.Table; import java.util.UUID; import static org.thingsboard.server.dao.model.ModelConstants.CREATED_TIME_PROPERTY; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/ErrorEventEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/ErrorEventEntity.java index ee63728be0..4af7e41ade 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/ErrorEventEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/ErrorEventEntity.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -22,10 +25,6 @@ import org.thingsboard.server.common.data.event.ErrorEvent; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.model.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - import static org.thingsboard.server.dao.model.ModelConstants.ERROR_EVENT_TABLE_NAME; import static org.thingsboard.server.dao.model.ModelConstants.EVENT_ERROR_COLUMN_NAME; import static org.thingsboard.server.dao.model.ModelConstants.EVENT_METHOD_COLUMN_NAME; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EventEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EventEntity.java index 5cd5f59eb2..bf28a52af6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EventEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EventEntity.java @@ -15,15 +15,15 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.event.Event; import org.thingsboard.server.dao.model.BaseEntity; import org.thingsboard.server.dao.model.ModelConstants; -import jakarta.persistence.Column; -import jakarta.persistence.Id; -import jakarta.persistence.MappedSuperclass; import java.util.HashMap; import java.util.Map; import java.util.UUID; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/LifecycleEventEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/LifecycleEventEntity.java index ef7c6a5d1a..e5698f25e6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/LifecycleEventEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/LifecycleEventEntity.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -22,10 +25,6 @@ import org.thingsboard.server.common.data.event.LifecycleEvent; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.model.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - import static org.thingsboard.server.dao.model.ModelConstants.EVENT_ERROR_COLUMN_NAME; import static org.thingsboard.server.dao.model.ModelConstants.EVENT_SUCCESS_COLUMN_NAME; import static org.thingsboard.server.dao.model.ModelConstants.EVENT_TYPE_COLUMN_NAME; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2DomainEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2DomainEntity.java index 7841a11378..884ee9cf1d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2DomainEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2DomainEntity.java @@ -15,6 +15,11 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.OAuth2DomainId; @@ -24,11 +29,6 @@ import org.thingsboard.server.common.data.oauth2.SchemeType; import org.thingsboard.server.dao.model.BaseSqlEntity; import org.thingsboard.server.dao.model.ModelConstants; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.Table; import java.util.UUID; @Data diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2MobileEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2MobileEntity.java index 78ec508ad0..d2dcb32a13 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2MobileEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2MobileEntity.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.OAuth2MobileId; @@ -23,9 +26,6 @@ import org.thingsboard.server.common.data.oauth2.OAuth2Mobile; import org.thingsboard.server.dao.model.BaseSqlEntity; import org.thingsboard.server.dao.model.ModelConstants; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; import java.util.UUID; @Data diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2ParamsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2ParamsEntity.java index 4983172d3e..e163d3f19c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2ParamsEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2ParamsEntity.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -24,9 +27,6 @@ import org.thingsboard.server.common.data.oauth2.OAuth2Params; import org.thingsboard.server.dao.model.BaseSqlEntity; import org.thingsboard.server.dao.model.ModelConstants; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; import java.util.UUID; @Data diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/QueueStatsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/QueueStatsEntity.java index 5b9ec291a2..96800a4d38 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/QueueStatsEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/QueueStatsEntity.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.QueueStatsId; @@ -24,9 +27,6 @@ import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.model.BaseSqlEntity; import org.thingsboard.server.dao.model.ModelConstants; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; import java.util.UUID; @Data diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/RelationCompositeKey.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/RelationCompositeKey.java index 79a52462c5..ca7ba87358 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/RelationCompositeKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/RelationCompositeKey.java @@ -15,12 +15,12 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Transient; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.relation.EntityRelation; -import jakarta.persistence.Transient; import java.io.Serializable; import java.util.UUID; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/RelationEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/RelationEntity.java index d7d74d4e8d..bc4e837314 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/RelationEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/RelationEntity.java @@ -39,6 +39,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TO_ID_PRO import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TO_TYPE_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TYPE_GROUP_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TYPE_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.VERSION_COLUMN; @Data @Entity @@ -70,6 +71,9 @@ public final class RelationEntity implements ToData { @Column(name = RELATION_TYPE_PROPERTY) private String relationType; + @Column(name = VERSION_COLUMN) + private Long version; + @Convert(converter = JsonConverter.class) @Column(name = ADDITIONAL_INFO_PROPERTY) private JsonNode additionalInfo; @@ -103,6 +107,7 @@ public final class RelationEntity implements ToData { } relation.setType(relationType); relation.setTypeGroup(RelationTypeGroup.valueOf(relationTypeGroup)); + relation.setVersion(version); relation.setAdditionalInfo(additionalInfo); return relation; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainDebugEventEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainDebugEventEntity.java index 107d35e179..23219f88e9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainDebugEventEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainDebugEventEntity.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -22,10 +25,6 @@ import org.thingsboard.server.common.data.event.RuleChainDebugEvent; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.model.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - import static org.thingsboard.server.dao.model.ModelConstants.EVENT_ERROR_COLUMN_NAME; import static org.thingsboard.server.dao.model.ModelConstants.EVENT_MESSAGE_COLUMN_NAME; import static org.thingsboard.server.dao.model.ModelConstants.RULE_CHAIN_DEBUG_EVENT_TABLE_NAME; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainEntity.java index 8e012ed0d1..6504eca4fb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainEntity.java @@ -30,7 +30,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.dao.DaoUtil; -import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.util.mapping.JsonConverter; @@ -40,7 +40,7 @@ import java.util.UUID; @EqualsAndHashCode(callSuper = true) @Entity @Table(name = ModelConstants.RULE_CHAIN_TABLE_NAME) -public class RuleChainEntity extends BaseSqlEntity { +public class RuleChainEntity extends BaseVersionedEntity { @Column(name = ModelConstants.RULE_CHAIN_TENANT_ID_PROPERTY) private UUID tenantId; @@ -76,10 +76,7 @@ public class RuleChainEntity extends BaseSqlEntity { } public RuleChainEntity(RuleChain ruleChain) { - if (ruleChain.getId() != null) { - this.setUuid(ruleChain.getUuidId()); - } - this.setCreatedTime(ruleChain.getCreatedTime()); + super(ruleChain); this.tenantId = DaoUtil.getId(ruleChain.getTenantId()); this.name = ruleChain.getName(); this.type = ruleChain.getType(); @@ -99,6 +96,7 @@ public class RuleChainEntity extends BaseSqlEntity { public RuleChain toData() { RuleChain ruleChain = new RuleChain(new RuleChainId(this.getUuid())); ruleChain.setCreatedTime(createdTime); + ruleChain.setVersion(version); ruleChain.setTenantId(TenantId.fromUUID(tenantId)); ruleChain.setName(name); ruleChain.setType(type); @@ -114,4 +112,5 @@ public class RuleChainEntity extends BaseSqlEntity { } return ruleChain; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleNodeDebugEventEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleNodeDebugEventEntity.java index 772727a226..44a5ee8370 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleNodeDebugEventEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleNodeDebugEventEntity.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -23,9 +26,6 @@ import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.model.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; import java.util.UUID; import static org.thingsboard.server.dao.model.ModelConstants.EVENT_DATA_COLUMN_NAME; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/StatisticsEventEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/StatisticsEventEntity.java index 23a8bbe796..b25563e584 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/StatisticsEventEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/StatisticsEventEntity.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -22,10 +25,6 @@ import org.thingsboard.server.common.data.event.StatisticsEvent; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.model.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - import static org.thingsboard.server.dao.model.ModelConstants.EVENT_ERRORS_OCCURRED_COLUMN_NAME; import static org.thingsboard.server.dao.model.ModelConstants.EVENT_MESSAGES_PROCESSED_COLUMN_NAME; import static org.thingsboard.server.dao.model.ModelConstants.STATS_EVENT_TABLE_NAME; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceEntity.java index f507a8661f..0fa74b4c9f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceEntity.java @@ -16,24 +16,24 @@ package org.thingsboard.server.dao.model.sql; import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.Column; import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; -import org.hibernate.annotations.Type; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.model.BaseSqlEntity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; import org.thingsboard.server.dao.util.mapping.JsonConverter; import java.util.UUID; import static org.thingsboard.server.dao.model.ModelConstants.EXTERNAL_ID_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.PUBLIC_RESOURCE_KEY_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_DATA_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_DESCRIPTOR_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_ETAG_COLUMN; @@ -41,7 +41,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_FILE_NAME import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_IS_PUBLIC_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_KEY_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_PREVIEW_COLUMN; -import static org.thingsboard.server.dao.model.ModelConstants.PUBLIC_RESOURCE_KEY_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_SUB_TYPE_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TABLE_NAME; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TENANT_ID_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TITLE_COLUMN; @@ -63,6 +63,9 @@ public class TbResourceEntity extends BaseSqlEntity { @Column(name = RESOURCE_TYPE_COLUMN) private String resourceType; + @Column(name = RESOURCE_SUB_TYPE_COLUMN) + private String resourceSubType; + @Column(name = RESOURCE_KEY_COLUMN) private String resourceKey; @@ -107,6 +110,9 @@ public class TbResourceEntity extends BaseSqlEntity { } this.title = resource.getTitle(); this.resourceType = resource.getResourceType().name(); + if (resource.getResourceSubType() != null) { + this.resourceSubType = resource.getResourceSubType().name(); + } this.resourceKey = resource.getResourceKey(); this.searchText = resource.getSearchText(); this.fileName = resource.getFileName(); @@ -126,6 +132,7 @@ public class TbResourceEntity extends BaseSqlEntity { resource.setTenantId(TenantId.fromUUID(tenantId)); resource.setTitle(title); resource.setResourceType(ResourceType.valueOf(resourceType)); + resource.setResourceSubType(resourceSubType != null ? ResourceSubType.valueOf(resourceSubType) : null); resource.setResourceKey(resourceKey); resource.setSearchText(searchText); resource.setFileName(fileName); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceInfoEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceInfoEntity.java index 8f525916bf..328bf38978 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceInfoEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceInfoEntity.java @@ -16,9 +16,13 @@ package org.thingsboard.server.dao.model.sql; import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.Column; import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.id.TbResourceId; @@ -27,18 +31,16 @@ import org.thingsboard.server.dao.model.BaseEntity; import org.thingsboard.server.dao.model.BaseSqlEntity; import org.thingsboard.server.dao.util.mapping.JsonConverter; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; import java.util.UUID; import static org.thingsboard.server.dao.model.ModelConstants.EXTERNAL_ID_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.PUBLIC_RESOURCE_KEY_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_DESCRIPTOR_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_ETAG_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_FILE_NAME_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_IS_PUBLIC_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_KEY_COLUMN; -import static org.thingsboard.server.dao.model.ModelConstants.PUBLIC_RESOURCE_KEY_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_SUB_TYPE_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TABLE_NAME; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TENANT_ID_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TITLE_COLUMN; @@ -60,6 +62,9 @@ public class TbResourceInfoEntity extends BaseSqlEntity implemen @Column(name = RESOURCE_TYPE_COLUMN) private String resourceType; + @Column(name = RESOURCE_SUB_TYPE_COLUMN) + private String resourceSubType; + @Column(name = RESOURCE_KEY_COLUMN) private String resourceKey; @@ -96,6 +101,9 @@ public class TbResourceInfoEntity extends BaseSqlEntity implemen this.tenantId = resource.getTenantId().getId(); this.title = resource.getTitle(); this.resourceType = resource.getResourceType().name(); + if (resource.getResourceSubType() != null) { + this.resourceSubType = resource.getResourceSubType().name(); + } this.resourceKey = resource.getResourceKey(); this.searchText = resource.getSearchText(); this.etag = resource.getEtag(); @@ -113,6 +121,7 @@ public class TbResourceInfoEntity extends BaseSqlEntity implemen resource.setTenantId(TenantId.fromUUID(tenantId)); resource.setTitle(title); resource.setResourceType(ResourceType.valueOf(resourceType)); + resource.setResourceSubType(resourceSubType != null ? ResourceSubType.valueOf(resourceSubType) : null); resource.setResourceKey(resourceKey); resource.setSearchText(searchText); resource.setEtag(etag); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java index e7907921bf..59ba5ac4dd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java @@ -16,7 +16,10 @@ package org.thingsboard.server.dao.model.sql; import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.Column; import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.UserCredentialsId; @@ -25,10 +28,6 @@ import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.model.BaseEntity; import org.thingsboard.server.dao.model.BaseSqlEntity; import org.thingsboard.server.dao.model.ModelConstants; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; import org.thingsboard.server.dao.util.mapping.JsonConverter; import java.util.UUID; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserEntity.java index abbf64fb08..87e901e930 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserEntity.java @@ -29,7 +29,7 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.util.mapping.JsonConverter; @@ -42,7 +42,7 @@ import java.util.UUID; @EqualsAndHashCode(callSuper = true) @Entity @Table(name = ModelConstants.USER_PG_HIBERNATE_TABLE_NAME) -public class UserEntity extends BaseSqlEntity { +public class UserEntity extends BaseVersionedEntity { @Column(name = ModelConstants.USER_TENANT_ID_PROPERTY) private UUID tenantId; @@ -74,10 +74,7 @@ public class UserEntity extends BaseSqlEntity { } public UserEntity(User user) { - if (user.getId() != null) { - this.setUuid(user.getId().getId()); - } - this.setCreatedTime(user.getCreatedTime()); + super(user); this.authority = user.getAuthority(); if (user.getTenantId() != null) { this.tenantId = user.getTenantId().getId(); @@ -96,6 +93,7 @@ public class UserEntity extends BaseSqlEntity { public User toData() { User user = new User(new UserId(this.getUuid())); user.setCreatedTime(createdTime); + user.setVersion(version); user.setAuthority(authority); if (tenantId != null) { user.setTenantId(TenantId.fromUUID(tenantId)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetTypeInfoEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetTypeInfoEntity.java index 415bf26ea0..7a9e7f35bc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetTypeInfoEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetTypeInfoEntity.java @@ -16,6 +16,9 @@ package org.thingsboard.server.dao.model.sql; import io.hypersistence.utils.hibernate.type.array.StringArrayType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import org.hibernate.annotations.Immutable; @@ -24,9 +27,6 @@ import org.thingsboard.server.common.data.widget.BaseWidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.dao.model.ModelConstants; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; import java.util.HashMap; import java.util.Map; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetsBundleEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetsBundleEntity.java index 1edf3bb614..43e2bfd457 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetsBundleEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetsBundleEntity.java @@ -16,24 +16,24 @@ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.WidgetsBundleId; import org.thingsboard.server.common.data.widget.WidgetsBundle; -import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; import java.util.UUID; @Data @EqualsAndHashCode(callSuper = true) @Entity @Table(name = ModelConstants.WIDGETS_BUNDLE_TABLE_NAME) -public final class WidgetsBundleEntity extends BaseSqlEntity { +public final class WidgetsBundleEntity extends BaseVersionedEntity { @Column(name = ModelConstants.WIDGETS_BUNDLE_TENANT_ID_PROPERTY) private UUID tenantId; @@ -47,6 +47,9 @@ public final class WidgetsBundleEntity extends BaseSqlEntity { @Column(name = ModelConstants.WIDGETS_BUNDLE_IMAGE_PROPERTY) private String image; + @Column(name = ModelConstants.WIDGETS_BUNDLE_SCADA_PROPERTY) + private boolean scada; + @Column(name = ModelConstants.WIDGETS_BUNDLE_DESCRIPTION) private String description; @@ -61,16 +64,14 @@ public final class WidgetsBundleEntity extends BaseSqlEntity { } public WidgetsBundleEntity(WidgetsBundle widgetsBundle) { - if (widgetsBundle.getId() != null) { - this.setUuid(widgetsBundle.getId().getId()); - } - this.setCreatedTime(widgetsBundle.getCreatedTime()); + super(widgetsBundle); if (widgetsBundle.getTenantId() != null) { this.tenantId = widgetsBundle.getTenantId().getId(); } this.alias = widgetsBundle.getAlias(); this.title = widgetsBundle.getTitle(); this.image = widgetsBundle.getImage(); + this.scada = widgetsBundle.isScada(); this.description = widgetsBundle.getDescription(); this.order = widgetsBundle.getOrder(); if (widgetsBundle.getExternalId() != null) { @@ -82,12 +83,14 @@ public final class WidgetsBundleEntity extends BaseSqlEntity { public WidgetsBundle toData() { WidgetsBundle widgetsBundle = new WidgetsBundle(new WidgetsBundleId(id)); widgetsBundle.setCreatedTime(createdTime); + widgetsBundle.setVersion(version); if (tenantId != null) { widgetsBundle.setTenantId(TenantId.fromUUID(tenantId)); } widgetsBundle.setAlias(alias); widgetsBundle.setTitle(title); widgetsBundle.setImage(image); + widgetsBundle.setScada(scada); widgetsBundle.setDescription(description); widgetsBundle.setOrder(order); if (externalId != null) { @@ -95,4 +98,5 @@ public final class WidgetsBundleEntity extends BaseSqlEntity { } return widgetsBundle; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetsBundleWidgetCompositeKey.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetsBundleWidgetCompositeKey.java index 7da229a79a..b8388c37ba 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetsBundleWidgetCompositeKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetsBundleWidgetCompositeKey.java @@ -15,11 +15,11 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Transient; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import jakarta.persistence.Transient; import java.io.Serializable; import java.util.UUID; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetsBundleWidgetEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetsBundleWidgetEntity.java index b1e988f255..fc7f46a04c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetsBundleWidgetEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetsBundleWidgetEntity.java @@ -15,17 +15,17 @@ */ package org.thingsboard.server.dao.model.sql; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; import lombok.Data; import org.thingsboard.server.common.data.id.WidgetTypeId; import org.thingsboard.server.common.data.id.WidgetsBundleId; import org.thingsboard.server.common.data.widget.WidgetsBundleWidget; import org.thingsboard.server.dao.model.ToData; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.IdClass; -import jakarta.persistence.Table; import java.util.UUID; import static org.thingsboard.server.dao.model.ModelConstants.WIDGETS_BUNDLE_WIDGET_TABLE_NAME; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/dictionary/KeyDictionaryCompositeKey.java b/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/dictionary/KeyDictionaryCompositeKey.java index ca38d65aae..deae2ac05a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/dictionary/KeyDictionaryCompositeKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/dictionary/KeyDictionaryCompositeKey.java @@ -15,11 +15,11 @@ */ package org.thingsboard.server.dao.model.sqlts.dictionary; +import jakarta.persistence.Transient; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import jakarta.persistence.Transient; import java.io.Serializable; @Data diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestCompositeKey.java b/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestCompositeKey.java index 58f8ee2867..08808e9356 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestCompositeKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestCompositeKey.java @@ -15,11 +15,11 @@ */ package org.thingsboard.server.dao.model.sqlts.latest; +import jakarta.persistence.Transient; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import jakarta.persistence.Transient; import java.io.Serializable; import java.util.UUID; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java index 0d34bb5195..23fe580fa3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.model.sqlts.latest; +import jakarta.persistence.Column; import jakarta.persistence.ColumnResult; import jakarta.persistence.ConstructorResult; import jakarta.persistence.Entity; @@ -30,6 +31,8 @@ import org.thingsboard.server.dao.sqlts.latest.SearchTsKvLatestRepository; import java.util.UUID; +import static org.thingsboard.server.dao.model.ModelConstants.VERSION_COLUMN; + @Data @Entity @Table(name = "ts_kv_latest") @@ -50,7 +53,7 @@ import java.util.UUID; @ColumnResult(name = "doubleValue", type = Double.class), @ColumnResult(name = "jsonValue", type = String.class), @ColumnResult(name = "ts", type = Long.class), - + @ColumnResult(name = "version", type = Long.class) } ), }) @@ -65,6 +68,9 @@ import java.util.UUID; }) public final class TsKvLatestEntity extends AbstractTsKvEntity { + @Column(name = VERSION_COLUMN) + private Long version; + @Override public boolean isNotEmpty() { return strValue != null || longValue != null || doubleValue != null || booleanValue != null || jsonValue != null; @@ -73,7 +79,7 @@ public final class TsKvLatestEntity extends AbstractTsKvEntity { public TsKvLatestEntity() { } - public TsKvLatestEntity(UUID entityId, Integer key, String strKey, String strValue, Boolean boolValue, Long longValue, Double doubleValue, String jsonValue, Long ts) { + public TsKvLatestEntity(UUID entityId, Integer key, String strKey, String strValue, Boolean boolValue, Long longValue, Double doubleValue, String jsonValue, Long ts, Long version) { this.entityId = entityId; this.key = key; this.ts = ts; @@ -83,5 +89,6 @@ public final class TsKvLatestEntity extends AbstractTsKvEntity { this.booleanValue = boolValue; this.jsonValue = jsonValue; this.strKey = strKey; + this.version = version; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/ts/TsKvCompositeKey.java b/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/ts/TsKvCompositeKey.java index 98eed0a603..4efba65860 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/ts/TsKvCompositeKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/ts/TsKvCompositeKey.java @@ -15,11 +15,11 @@ */ package org.thingsboard.server.dao.model.sqlts.ts; +import jakarta.persistence.Transient; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import jakarta.persistence.Transient; import java.io.Serializable; import java.util.UUID; diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractAsyncDao.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractAsyncDao.java index 32cc2e6d02..0d136286b8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractAsyncDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractAsyncDao.java @@ -19,12 +19,12 @@ import com.google.common.base.Function; import com.google.common.util.concurrent.AsyncFunction; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import org.springframework.beans.factory.annotation.Value; -import org.thingsboard.common.util.ThingsBoardExecutors; - import jakarta.annotation.Nullable; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import org.springframework.beans.factory.annotation.Value; +import org.thingsboard.common.util.ThingsBoardExecutors; + import java.util.concurrent.ExecutorService; /** diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateReadExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateReadExecutor.java index d3eb176e61..31bb0c8941 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateReadExecutor.java +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateReadExecutor.java @@ -17,19 +17,18 @@ package org.thingsboard.server.dao.nosql; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.util.AbstractBufferedRateExecutor; import org.thingsboard.server.dao.util.AsyncTaskContext; import org.thingsboard.server.dao.util.NoSqlAnyDao; -import org.thingsboard.server.cache.limits.RateLimitService; - -import jakarta.annotation.PreDestroy; /** * Created by ashvayka on 24.10.18. diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateWriteExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateWriteExecutor.java index 8bfdd36501..cf38cf922d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateWriteExecutor.java +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateWriteExecutor.java @@ -17,19 +17,18 @@ package org.thingsboard.server.dao.nosql; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.util.AbstractBufferedRateExecutor; import org.thingsboard.server.dao.util.AsyncTaskContext; import org.thingsboard.server.dao.util.NoSqlAnyDao; -import org.thingsboard.server.cache.limits.RateLimitService; - -import jakarta.annotation.PreDestroy; /** * Created by ashvayka on 24.10.18. diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationService.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationService.java index 38a8cb4178..910055fc66 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationService.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationStatus; +import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.SortOrder; @@ -34,6 +35,7 @@ import org.thingsboard.server.dao.entity.EntityDaoService; import org.thingsboard.server.dao.sql.query.EntityKeyMapping; import java.util.Optional; +import java.util.Set; @Service @Slf4j @@ -72,10 +74,10 @@ public class DefaultNotificationService implements NotificationService, EntityDa } @Override - public PageData findLatestUnreadNotificationsByRecipientId(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, int limit) { + public PageData findLatestUnreadNotificationsByRecipientIdAndNotificationTypes(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, Set types, int limit) { SortOrder sortOrder = new SortOrder(EntityKeyMapping.CREATED_TIME, SortOrder.Direction.DESC); PageLink pageLink = new PageLink(limit, 0, null, sortOrder); - return findNotificationsByRecipientIdAndReadStatus(tenantId, deliveryMethod, recipientId, true, pageLink); + return notificationDao.findUnreadByDeliveryMethodAndRecipientIdAndNotificationTypesAndPageLink(tenantId, deliveryMethod, recipientId, types, pageLink); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationDao.java b/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationDao.java index 2233840058..060ea0e97f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationDao.java @@ -22,14 +22,19 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationStatus; +import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.Dao; +import java.util.Set; + public interface NotificationDao extends Dao { PageData findUnreadByDeliveryMethodAndRecipientIdAndPageLink(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, PageLink pageLink); + PageData findUnreadByDeliveryMethodAndRecipientIdAndNotificationTypesAndPageLink(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, Set types, PageLink pageLink); + PageData findByDeliveryMethodAndRecipientIdAndPageLink(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, PageLink pageLink); boolean updateStatusByIdAndRecipientId(TenantId tenantId, UserId recipientId, NotificationId notificationId, NotificationStatus status); diff --git a/dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2ServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2ServiceImpl.java index 5bb7ae163e..5846a684a8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2ServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2ServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.oauth2; +import jakarta.transaction.Transactional; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -44,7 +45,6 @@ import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.IncorrectParameterException; -import jakarta.transaction.Transactional; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; diff --git a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java index 5ac1d7ad3b..0ee34f2b9c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java @@ -21,12 +21,13 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.SettableFuture; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Lazy; -import org.springframework.dao.ConcurrencyFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; @@ -52,8 +53,6 @@ import org.thingsboard.server.dao.service.ConstraintValidator; import org.thingsboard.server.dao.sql.JpaExecutorService; import org.thingsboard.server.dao.sql.relation.JpaRelationQueryExecutorService; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -149,16 +148,16 @@ public class BaseRelationService implements RelationService { return relationDao.getRelation(tenantId, from, to, relationType, typeGroup); }, RelationCacheValue::getRelation, - relations -> RelationCacheValue.builder().relation(relations).build(), false); + relation -> RelationCacheValue.builder().relation(relation).build(), false); } @Override - public boolean saveRelation(TenantId tenantId, EntityRelation relation) { + public EntityRelation saveRelation(TenantId tenantId, EntityRelation relation) { log.trace("Executing saveRelation [{}]", relation); validate(relation); var result = relationDao.saveRelation(tenantId, relation); - publishEvictEvent(EntityRelationEvent.from(relation)); - eventPublisher.publishEvent(new RelationActionEvent(tenantId, relation, ActionType.RELATION_ADD_OR_UPDATE)); + publishEvictEvent(EntityRelationEvent.from(result)); + eventPublisher.publishEvent(new RelationActionEvent(tenantId, result, ActionType.RELATION_ADD_OR_UPDATE)); return result; } @@ -168,10 +167,11 @@ public class BaseRelationService implements RelationService { for (EntityRelation relation : relations) { validate(relation); } + List savedRelations = new ArrayList<>(relations.size()); for (List partition : Lists.partition(relations, 1024)) { - relationDao.saveRelations(tenantId, partition); + savedRelations.addAll(relationDao.saveRelations(tenantId, partition)); } - for (EntityRelation relation : relations) { + for (EntityRelation relation : savedRelations) { publishEvictEvent(EntityRelationEvent.from(relation)); eventPublisher.publishEvent(new RelationActionEvent(tenantId, relation, ActionType.RELATION_ADD_OR_UPDATE)); } @@ -182,11 +182,13 @@ public class BaseRelationService implements RelationService { log.trace("Executing saveRelationAsync [{}]", relation); validate(relation); var future = relationDao.saveRelationAsync(tenantId, relation); - future.addListener(() -> { - handleEvictEvent(EntityRelationEvent.from(relation)); - eventPublisher.publishEvent(new RelationActionEvent(tenantId, relation, ActionType.RELATION_ADD_OR_UPDATE)); + return Futures.transform(future, savedRelation -> { + if (savedRelation != null) { + handleEvictEvent(EntityRelationEvent.from(savedRelation)); + eventPublisher.publishEvent(new RelationActionEvent(tenantId, savedRelation, ActionType.RELATION_ADD_OR_UPDATE)); + } + return savedRelation != null; }, MoreExecutors.directExecutor()); - return future; } @Override @@ -194,10 +196,11 @@ public class BaseRelationService implements RelationService { log.trace("Executing DeleteRelation [{}]", relation); validate(relation); var result = relationDao.deleteRelation(tenantId, relation); - //TODO: evict cache only if the relation was deleted. Note: relationDao.deleteRelation requires improvement. - publishEvictEvent(EntityRelationEvent.from(relation)); - eventPublisher.publishEvent(new RelationActionEvent(tenantId, relation, ActionType.RELATION_DELETED)); - return result; + if (result != null) { + publishEvictEvent(EntityRelationEvent.from(result)); + eventPublisher.publishEvent(new RelationActionEvent(tenantId, result, ActionType.RELATION_DELETED)); + } + return result != null; } @Override @@ -205,22 +208,24 @@ public class BaseRelationService implements RelationService { log.trace("Executing deleteRelationAsync [{}]", relation); validate(relation); var future = relationDao.deleteRelationAsync(tenantId, relation); - future.addListener(() -> { - handleEvictEvent(EntityRelationEvent.from(relation)); - eventPublisher.publishEvent(new RelationActionEvent(tenantId, relation, ActionType.RELATION_DELETED)); + return Futures.transform(future, deletedRelation -> { + if (deletedRelation != null) { + handleEvictEvent(EntityRelationEvent.from(deletedRelation)); + eventPublisher.publishEvent(new RelationActionEvent(tenantId, deletedRelation, ActionType.RELATION_DELETED)); + } + return deletedRelation != null; }, MoreExecutors.directExecutor()); - return future; } @Override - public boolean deleteRelation(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup) { + public EntityRelation deleteRelation(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup) { log.trace("Executing deleteRelation [{}][{}][{}][{}]", from, to, relationType, typeGroup); validate(from, to, relationType, typeGroup); var result = relationDao.deleteRelation(tenantId, from, to, relationType, typeGroup); - //TODO: evict cache only if the relation was deleted. Note: relationDao.deleteRelation requires improvement. - EntityRelation entityRelation = new EntityRelation(from, to, relationType, typeGroup); - publishEvictEvent(EntityRelationEvent.from(entityRelation)); - eventPublisher.publishEvent(new RelationActionEvent(tenantId, entityRelation, ActionType.RELATION_DELETED)); + if (result != null) { + publishEvictEvent(EntityRelationEvent.from(result)); + eventPublisher.publishEvent(new RelationActionEvent(tenantId, result, ActionType.RELATION_DELETED)); + } return result; } @@ -229,9 +234,12 @@ public class BaseRelationService implements RelationService { log.trace("Executing deleteRelationAsync [{}][{}][{}][{}]", from, to, relationType, typeGroup); validate(from, to, relationType, typeGroup); var future = relationDao.deleteRelationAsync(tenantId, from, to, relationType, typeGroup); - EntityRelationEvent event = new EntityRelationEvent(from, to, relationType, typeGroup); - future.addListener(() -> handleEvictEvent(event), MoreExecutors.directExecutor()); - return future; + return Futures.transform(future, deletedEvent -> { + if (deletedEvent != null) { + handleEvictEvent(EntityRelationEvent.from(deletedEvent)); + } + return deletedEvent != null; + }, MoreExecutors.directExecutor()); } @Transactional @@ -250,60 +258,27 @@ public class BaseRelationService implements RelationService { public void deleteEntityRelations(TenantId tenantId, EntityId entityId, RelationTypeGroup relationTypeGroup) { log.trace("Executing deleteEntityRelations [{}]", entityId); validate(entityId); - List inboundRelations = relationTypeGroup == null - ? relationDao.findAllByTo(tenantId, entityId) - : relationDao.findAllByTo(tenantId, entityId, relationTypeGroup); - List outboundRelations = relationTypeGroup == null - ? relationDao.findAllByFrom(tenantId, entityId) - : relationDao.findAllByFrom(tenantId, entityId, relationTypeGroup); - - if (!inboundRelations.isEmpty()) { - try { - if (relationTypeGroup == null) { - relationDao.deleteInboundRelations(tenantId, entityId); - } else { - relationDao.deleteInboundRelations(tenantId, entityId, relationTypeGroup); - } - } catch (ConcurrencyFailureException e) { - log.debug("Concurrency exception while deleting relations [{}]", inboundRelations, e); - } - for (EntityRelation relation : inboundRelations) { - eventPublisher.publishEvent(EntityRelationEvent.from(relation)); - } + List inboundRelations; + if (relationTypeGroup == null) { + inboundRelations = relationDao.deleteInboundRelations(tenantId, entityId); + } else { + inboundRelations = relationDao.deleteInboundRelations(tenantId, entityId, relationTypeGroup); } - if (!outboundRelations.isEmpty()) { - if (relationTypeGroup == null) { - relationDao.deleteOutboundRelations(tenantId, entityId); - } else { - relationDao.deleteOutboundRelations(tenantId, entityId, relationTypeGroup); - } - - for (EntityRelation relation : outboundRelations) { - eventPublisher.publishEvent(EntityRelationEvent.from(relation)); - } + for (EntityRelation relation : inboundRelations) { + eventPublisher.publishEvent(EntityRelationEvent.from(relation)); } - } - private List> deleteRelationGroupsAsync(TenantId tenantId, List> relations, boolean deleteFromDb) { - List> results = new ArrayList<>(); - for (List relationList : relations) { - relationList.forEach(relation -> results.add(deleteAsync(tenantId, relation, deleteFromDb))); + List outboundRelations; + if (relationTypeGroup == null) { + outboundRelations = relationDao.deleteOutboundRelations(tenantId, entityId); + } else { + outboundRelations = relationDao.deleteOutboundRelations(tenantId, entityId, relationTypeGroup); } - return results; - } - private ListenableFuture deleteAsync(TenantId tenantId, EntityRelation relation, boolean deleteFromDb) { - if (deleteFromDb) { - return Futures.transform(relationDao.deleteRelationAsync(tenantId, relation), - bool -> { - handleEvictEvent(EntityRelationEvent.from(relation)); - return bool; - }, MoreExecutors.directExecutor()); - } else { - handleEvictEvent(EntityRelationEvent.from(relation)); - return Futures.immediateFuture(false); + for (EntityRelation relation : outboundRelations) { + eventPublisher.publishEvent(EntityRelationEvent.from(relation)); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java b/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java index da7b17a62d..2302dd0abf 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java @@ -48,29 +48,27 @@ public interface RelationDao { EntityRelation getRelation(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup); - boolean saveRelation(TenantId tenantId, EntityRelation relation); + EntityRelation saveRelation(TenantId tenantId, EntityRelation relation); - void saveRelations(TenantId tenantId, Collection relations); + List saveRelations(TenantId tenantId, List relations); - ListenableFuture saveRelationAsync(TenantId tenantId, EntityRelation relation); + ListenableFuture saveRelationAsync(TenantId tenantId, EntityRelation relation); - boolean deleteRelation(TenantId tenantId, EntityRelation relation); + EntityRelation deleteRelation(TenantId tenantId, EntityRelation relation); - ListenableFuture deleteRelationAsync(TenantId tenantId, EntityRelation relation); + ListenableFuture deleteRelationAsync(TenantId tenantId, EntityRelation relation); - boolean deleteRelation(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup); + EntityRelation deleteRelation(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup); - ListenableFuture deleteRelationAsync(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup); + ListenableFuture deleteRelationAsync(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup); - void deleteOutboundRelations(TenantId tenantId, EntityId entity); + List deleteOutboundRelations(TenantId tenantId, EntityId entity); - void deleteOutboundRelations(TenantId tenantId, EntityId entity, RelationTypeGroup relationTypeGroup); + List deleteOutboundRelations(TenantId tenantId, EntityId entity, RelationTypeGroup relationTypeGroup); - void deleteInboundRelations(TenantId tenantId, EntityId entity); + List deleteInboundRelations(TenantId tenantId, EntityId entity); - void deleteInboundRelations(TenantId tenantId, EntityId entity, RelationTypeGroup relationTypeGroup); - - ListenableFuture deleteOutboundRelationsAsync(TenantId tenantId, EntityId entity); + List deleteInboundRelations(TenantId tenantId, EntityId entity, RelationTypeGroup relationTypeGroup); List findRuleNodeToRuleChainRelations(RuleChainType ruleChainType, int limit); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java index 953037a864..0a6c712729 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java @@ -36,6 +36,7 @@ import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.HasImage; import org.thingsboard.server.common.data.ImageDescriptor; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbImageDeleteResult; import org.thingsboard.server.common.data.TbResource; @@ -103,6 +104,7 @@ public class BaseImageService extends BaseResourceService implements ImageServic WIDGET_TYPE_BASE64_MAPPING.put("settings.markerImages", "Map marker image $index"); WIDGET_TYPE_BASE64_MAPPING.put("settings.background.imageUrl", "$prefix background"); WIDGET_TYPE_BASE64_MAPPING.put("settings.background.imageBase64", "$prefix background"); + WIDGET_TYPE_BASE64_MAPPING.put("settings.scadaSymbolUrl", "$prefix SCADA symbol"); WIDGET_TYPE_BASE64_MAPPING.put("datasources.*.dataKeys.*.settings.customIcon", "$prefix custom icon"); } @@ -140,6 +142,9 @@ public class BaseImageService extends BaseResourceService implements ImageServic if (image.getId() == null) { image.setResourceKey(getUniqueKey(image.getTenantId(), StringUtils.defaultIfEmpty(image.getResourceKey(), image.getFileName()))); } + if (image.getResourceSubType() == null) { + image.setResourceSubType(ResourceSubType.IMAGE); + } resourceValidator.validate(image, TbResourceInfo::getTenantId); ImageDescriptor descriptor = image.getDescriptor(ImageDescriptor.class); @@ -220,21 +225,23 @@ public class BaseImageService extends BaseResourceService implements ImageServic } @Override - public PageData getImagesByTenantId(TenantId tenantId, PageLink pageLink) { + public PageData getImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink) { log.trace("Executing getImagesByTenantId [{}]", tenantId); TbResourceInfoFilter filter = TbResourceInfoFilter.builder() .tenantId(tenantId) .resourceTypes(Set.of(ResourceType.IMAGE)) + .resourceSubTypes(Set.of(imageSubType)) .build(); return findTenantResourcesByTenantId(filter, pageLink); } @Override - public PageData getAllImagesByTenantId(TenantId tenantId, PageLink pageLink) { + public PageData getAllImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink) { log.trace("Executing getAllImagesByTenantId [{}]", tenantId); TbResourceInfoFilter filter = TbResourceInfoFilter.builder() .tenantId(tenantId) .resourceTypes(Set.of(ResourceType.IMAGE)) + .resourceSubTypes(Set.of(imageSubType)) .build(); return findAllTenantResourcesByTenantId(filter, pageLink); } @@ -280,6 +287,11 @@ public class BaseImageService extends BaseResourceService implements ImageServic return result.success(success).build(); } + @Override + public String calculateImageEtag(byte[] imageData) { + return calculateEtag(imageData); + } + @Override public TbResourceInfo findSystemOrTenantImageByEtag(TenantId tenantId, String etag) { log.trace("Executing findSystemOrTenantImageByEtag [{}] [{}]", tenantId, etag); @@ -419,7 +431,7 @@ public class BaseImageService extends BaseResourceService implements ImageServic return base64ToImageUrl(tenantId, name, data, false); } - private static final Pattern TB_IMAGE_METADATA_PATTERN = Pattern.compile("^tb-image:(.*):(.*);data:(.*);.*"); + private static final Pattern TB_IMAGE_METADATA_PATTERN = Pattern.compile("^tb-image:([^:]*):([^:]*):?([^:]*)?;data:(.*);.*"); private UpdateResult base64ToImageUrl(TenantId tenantId, String name, String data, boolean strict) { if (StringUtils.isBlank(data)) { @@ -429,11 +441,15 @@ public class BaseImageService extends BaseResourceService implements ImageServic boolean matches = matcher.matches(); String mdResourceKey = null; String mdResourceName = null; + String mdResourceSubType = null; String mdMediaType; if (matches) { mdResourceKey = new String(Base64.getDecoder().decode(matcher.group(1)), StandardCharsets.UTF_8); mdResourceName = new String(Base64.getDecoder().decode(matcher.group(2)), StandardCharsets.UTF_8); - mdMediaType = matcher.group(3); + if (StringUtils.isNotBlank(matcher.group(3))) { + mdResourceSubType = new String(Base64.getDecoder().decode(matcher.group(3)), StandardCharsets.UTF_8); + }; + mdMediaType = matcher.group(4); } else if (data.startsWith(DataConstants.TB_IMAGE_PREFIX + "data:image/") || (!strict && data.startsWith("data:image/"))) { mdMediaType = StringUtils.substringBetween(data, "data:", ";base64"); } else { @@ -462,6 +478,11 @@ public class BaseImageService extends BaseResourceService implements ImageServic } else { fileName = mdResourceKey; } + if (StringUtils.isBlank(mdResourceSubType)) { + image.setResourceSubType(ResourceSubType.IMAGE); + } else { + image.setResourceSubType(ResourceSubType.valueOf(mdResourceSubType)); + } image.setFileName(fileName); image.setDescriptor(JacksonUtil.newObjectNode().put("mediaType", mdMediaType)); image.setData(imageData); @@ -623,7 +644,8 @@ public class BaseImageService extends BaseResourceService implements ImageServic String tbImagePrefix = ""; if (addTbImagePrefix) { tbImagePrefix = "tb-image:" + Base64.getEncoder().encodeToString(imageInfo.getResourceKey().getBytes(StandardCharsets.UTF_8)) + ":" - + Base64.getEncoder().encodeToString(imageInfo.getName().getBytes(StandardCharsets.UTF_8)) + ";"; + + Base64.getEncoder().encodeToString(imageInfo.getName().getBytes(StandardCharsets.UTF_8)) + ":" + + Base64.getEncoder().encodeToString(imageInfo.getResourceSubType().name().getBytes(StandardCharsets.UTF_8)) + ";"; } return tbImagePrefix + "data:" + descriptor.getMediaType() + ";base64," + Base64.getEncoder().encodeToString(data); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java index 723d0e8861..fcace55c93 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java @@ -155,6 +155,9 @@ public class BaseResourceService extends AbstractCachedEntityService findTenantResourcesByResourceTypeAndObjectIds(TenantId tenantId, ResourceType resourceType, String[] objectIds) { log.trace("Executing findTenantResourcesByResourceTypeAndObjectIds [{}][{}][{}]", tenantId, resourceType, objectIds); validateId(tenantId, id -> INCORRECT_TENANT_ID + id); - return resourceDao.findResourcesByTenantIdAndResourceType(tenantId, resourceType, objectIds, null); + return resourceDao.findResourcesByTenantIdAndResourceType(tenantId, resourceType, null, objectIds, null); } @Override @@ -198,7 +201,7 @@ public class BaseResourceService extends AbstractCachedEntityService findTenantResourcesByResourceTypeAndPageLink(TenantId tenantId, ResourceType resourceType, PageLink pageLink) { log.trace("Executing findTenantResourcesByResourceTypeAndPageLink [{}][{}][{}]", tenantId, resourceType, pageLink); validateId(tenantId, id -> INCORRECT_TENANT_ID + id); - return resourceDao.findResourcesByTenantIdAndResourceType(tenantId, resourceType, pageLink); + return resourceDao.findResourcesByTenantIdAndResourceType(tenantId, resourceType, null, pageLink); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java index b776b49cc9..c236a4a5d5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.resource; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.id.TbResourceId; @@ -35,10 +36,12 @@ public interface TbResourceDao extends Dao, TenantEntityWithDataDao, PageData findResourcesByTenantIdAndResourceType(TenantId tenantId, ResourceType resourceType, + ResourceSubType resourceSubType, PageLink pageLink); List findResourcesByTenantIdAndResourceType(TenantId tenantId, ResourceType resourceType, + ResourceSubType resourceSubType, String[] objectIds, String searchText); diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java index 095cee42bf..fea3980446 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java @@ -433,19 +433,20 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC public void deleteRuleChainById(TenantId tenantId, RuleChainId ruleChainId) { Validator.validateId(ruleChainId, "Incorrect rule chain id for delete request."); RuleChain ruleChain = ruleChainDao.findById(tenantId, ruleChainId.getId()); + if (ruleChain == null) { + return; + } List referencingRuleNodes = getReferencingRuleChainNodes(tenantId, ruleChainId); Set referencingRuleChainIds = referencingRuleNodes.stream().map(RuleNode::getRuleChainId).collect(Collectors.toSet()); - if (ruleChain != null) { - if (ruleChain.isRoot()) { - throw new DataValidationException("Deletion of Root Tenant Rule Chain is prohibited!"); - } - if (RuleChainType.EDGE.equals(ruleChain.getType())) { - for (Edge edge : new PageDataIterable<>(link -> edgeService.findEdgesByTenantIdAndEntityId(tenantId, ruleChainId, link), DEFAULT_PAGE_SIZE)) { - if (edge.getRootRuleChainId() != null && edge.getRootRuleChainId().equals(ruleChainId)) { - throw new DataValidationException("Can't delete rule chain that is root for edge [" + edge.getName() + "]. Please assign another root rule chain first to the edge!"); - } + if (ruleChain.isRoot()) { + throw new DataValidationException("Deletion of Root Tenant Rule Chain is prohibited!"); + } + if (RuleChainType.EDGE.equals(ruleChain.getType())) { + for (Edge edge : new PageDataIterable<>(link -> edgeService.findEdgesByTenantIdAndEntityId(tenantId, ruleChainId, link), DEFAULT_PAGE_SIZE)) { + if (edge.getRootRuleChainId() != null && edge.getRootRuleChainId().equals(ruleChainId)) { + throw new DataValidationException("Can't delete rule chain that is root for edge [" + edge.getName() + "]. Please assign another root rule chain first to the edge!"); } } } @@ -457,6 +458,9 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { if (force) { RuleChain ruleChain = findRuleChainById(tenantId, (RuleChainId) id); + if (ruleChain == null) { + return; + } checkRuleNodesAndDelete(tenantId, ruleChain, null); } else { deleteRuleChainById(tenantId, (RuleChainId) id); diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java index 40be2fbd54..a58a381bb1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java @@ -16,6 +16,11 @@ package org.thingsboard.server.dao.service; import com.google.common.collect.Iterators; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.metadata.ConstraintDescriptor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.hibernate.validator.HibernateValidator; @@ -30,11 +35,6 @@ import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; import org.thingsboard.server.dao.exception.DataValidationException; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.Validation; -import jakarta.validation.Validator; -import jakarta.validation.constraints.AssertTrue; -import jakarta.validation.metadata.ConstraintDescriptor; import java.util.Collection; import java.util.Set; import java.util.stream.Collectors; diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/NoXssValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/NoXssValidator.java index fd8a19a859..8b1ddfaec0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/NoXssValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/NoXssValidator.java @@ -16,6 +16,8 @@ package org.thingsboard.server.dao.service; import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; import lombok.extern.slf4j.Slf4j; import org.owasp.validator.html.AntiSamy; import org.owasp.validator.html.Policy; @@ -23,8 +25,6 @@ import org.owasp.validator.html.PolicyException; import org.owasp.validator.html.ScanException; import org.thingsboard.server.common.data.validation.NoXss; -import jakarta.validation.ConstraintValidator; -import jakarta.validation.ConstraintValidatorContext; import java.util.Optional; @Slf4j diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/StringLengthValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/StringLengthValidator.java index 252af5458a..d13f2e6ad8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/StringLengthValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/StringLengthValidator.java @@ -16,13 +16,12 @@ package org.thingsboard.server.dao.service; import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.validation.Length; -import jakarta.validation.ConstraintValidator; -import jakarta.validation.ConstraintValidatorContext; - @Slf4j public class StringLengthValidator implements ConstraintValidator { private int max; diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AssetDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AssetDataValidator.java index 2d0cdbd248..51d943ee44 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AssetDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AssetDataValidator.java @@ -24,7 +24,6 @@ import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.asset.AssetDao; -import org.thingsboard.server.dao.asset.BaseAssetService; import org.thingsboard.server.dao.customer.CustomerDao; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java index 5109a4e93f..8cf4a6fe42 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.service.validator; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.StringUtils; @@ -32,7 +33,7 @@ public class DeviceCredentialsDataValidator extends DataValidator, D> @Autowired protected JdbcTemplate jdbcTemplate; - protected abstract Class getEntityClass(); - - protected abstract JpaRepository getRepository(); + @PersistenceContext + private EntityManager entityManager; @Override @Transactional public D save(TenantId tenantId, D domain) { + return save(tenantId, domain, false); + } + + private D save(TenantId tenantId, D domain, boolean flush) { E entity; try { entity = getEntityClass().getConstructor(domain.getClass()).newInstance(domain); @@ -68,20 +76,55 @@ public abstract class JpaAbstractDao, D> entity.setUuid(uuid); entity.setCreatedTime(Uuids.unixTimestamp(uuid)); } - entity = doSave(entity, isNew); + try { + entity = doSave(entity, isNew, flush); + } catch (OptimisticLockException e) { + throw new EntityVersionMismatchException((getEntityType() != null ? getEntityType().getNormalName() : "Entity") + " was already changed by someone else", e); + } return DaoUtil.getData(entity); } - protected E doSave(E entity, boolean isNew) { - return getRepository().save(entity); + protected E doSave(E entity, boolean isNew, boolean flush) { + boolean flushed = false; + EntityManager entityManager = getEntityManager(); + if (isNew) { + if (entity instanceof HasVersion versionedEntity) { + versionedEntity.setVersion(1L); + } + entityManager.persist(entity); + } else { + if (entity instanceof HasVersion versionedEntity) { + if (versionedEntity.getVersion() == null) { + HasVersion existingEntity = entityManager.find(versionedEntity.getClass(), entity.getUuid()); + if (existingEntity != null) { + versionedEntity.setVersion(existingEntity.getVersion()); // manually resetting the version to latest to allow force overwrite of the entity + } else { + return doSave(entity, true, flush); + } + } + entity = entityManager.merge(entity); + /* + * flushing so that the query is executed right away and the version is incremented, + * then removing the entity from the persistence context so that it is not affected + * by next flushes (e.g. when a transaction is committed) to avoid double version increment + * */ + entityManager.flush(); + entityManager.detach(entity); + flushed = true; + } else { + entity = entityManager.merge(entity); + } + } + if (flush && !flushed) { + entityManager.flush(); + } + return entity; } @Override @Transactional public D saveAndFlush(TenantId tenantId, D domain) { - D d = save(tenantId, domain); - getRepository().flush(); - return d; + return save(tenantId, domain, true); } @Override @@ -111,16 +154,18 @@ public abstract class JpaAbstractDao, D> @Override @Transactional - public boolean removeById(TenantId tenantId, UUID id) { - getRepository().deleteById(id); + public void removeById(TenantId tenantId, UUID id) { + JpaRepository repository = getRepository(); + repository.deleteById(id); + repository.flush(); log.debug("Remove request: {}", id); - return !getRepository().existsById(id); } @Transactional public void removeAllByIds(Collection ids) { JpaRepository repository = getRepository(); ids.forEach(repository::deleteById); + repository.flush(); } @Override @@ -141,11 +186,23 @@ public abstract class JpaAbstractDao, D> } query += " ORDER BY id LIMIT ?"; - return jdbcTemplate.queryForList(query, UUID.class, params); + return getJdbcTemplate().queryForList(query, UUID.class, params); } protected String getTenantIdColumn() { return ModelConstants.TENANT_ID_COLUMN; } + protected EntityManager getEntityManager() { + return entityManager; + } + + protected JdbcTemplate getJdbcTemplate() { + return jdbcTemplate; + } + + protected abstract Class getEntityClass(); + + protected abstract JpaRepository getRepository(); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDaoListeningExecutorService.java b/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDaoListeningExecutorService.java index b85e1bc724..3ac31bf831 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDaoListeningExecutorService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDaoListeningExecutorService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.sql; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.TransactionTemplate; import javax.sql.DataSource; import java.sql.SQLException; @@ -36,6 +37,9 @@ public abstract class JpaAbstractDaoListeningExecutorService { @Autowired protected JdbcTemplate jdbcTemplate; + @Autowired + protected TransactionTemplate transactionTemplate; + protected void printWarnings(Statement statement) throws SQLException { SQLWarning warnings = statement.getWarnings(); if (warnings != null) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/JpaPartitionedAbstractDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/JpaPartitionedAbstractDao.java index 45438a9b51..a74f32d8c4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/JpaPartitionedAbstractDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/JpaPartitionedAbstractDao.java @@ -18,24 +18,13 @@ package org.thingsboard.server.dao.sql; import org.thingsboard.server.dao.model.BaseEntity; import org.thingsboard.server.dao.util.SqlDao; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; - @SqlDao public abstract class JpaPartitionedAbstractDao, D> extends JpaAbstractDao { - @PersistenceContext - private EntityManager entityManager; - @Override - protected E doSave(E entity, boolean isNew) { + protected E doSave(E entity, boolean isNew, boolean flush) { createPartition(entity); - if (isNew) { - entityManager.persist(entity); - } else { - entity = entityManager.merge(entity); - } - return entity; + return super.doSave(entity, isNew, flush); } public abstract void createPartition(E entity); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ScheduledLogExecutorComponent.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ScheduledLogExecutorComponent.java index 5226b5def7..484abd6a59 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ScheduledLogExecutorComponent.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ScheduledLogExecutorComponent.java @@ -15,11 +15,11 @@ */ package org.thingsboard.server.dao.sql; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import org.springframework.stereotype.Component; import org.thingsboard.common.util.ThingsBoardThreadFactory; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlBlockingQueue.java b/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlBlockingQueue.java index 8f580811a1..68221ad0ae 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlBlockingQueue.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlBlockingQueue.java @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.util.CollectionsUtil; import org.thingsboard.server.common.stats.MessagesStats; import java.util.ArrayList; @@ -29,14 +30,13 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; +import java.util.function.Function; import java.util.stream.Collectors; -import java.util.stream.Stream; @Slf4j -public class TbSqlBlockingQueue implements TbSqlQueue { +public class TbSqlBlockingQueue implements TbSqlQueue { - private final BlockingQueue> queue = new LinkedBlockingQueue<>(); + private final BlockingQueue> queue = new LinkedBlockingQueue<>(); private final TbSqlBlockingQueueParams params; private ExecutorService executor; @@ -48,17 +48,17 @@ public class TbSqlBlockingQueue implements TbSqlQueue { } @Override - public void init(ScheduledLogExecutorComponent logExecutor, Consumer> saveFunction, Comparator batchUpdateComparator, int index) { + public void init(ScheduledLogExecutorComponent logExecutor, Function, List> saveFunction, Comparator batchUpdateComparator, Function>, List>> filter, int index) { executor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("sql-queue-" + index + "-" + params.getLogName().toLowerCase())); executor.submit(() -> { String logName = params.getLogName(); int batchSize = params.getBatchSize(); long maxDelay = params.getMaxDelay(); - final List> entities = new ArrayList<>(batchSize); + final List> entities = new ArrayList<>(batchSize); while (!Thread.interrupted()) { try { long currentTs = System.currentTimeMillis(); - TbSqlQueueElement attr = queue.poll(maxDelay, TimeUnit.MILLISECONDS); + TbSqlQueueElement attr = queue.poll(maxDelay, TimeUnit.MILLISECONDS); if (attr == null) { continue; } else { @@ -70,12 +70,27 @@ public class TbSqlBlockingQueue implements TbSqlQueue { log.debug("[{}] Going to save {} entities", logName, entities.size()); log.trace("[{}] Going to save entities: {}", logName, entities); } - Stream entitiesStream = entities.stream().map(TbSqlQueueElement::getEntity); - saveFunction.accept( - (params.isBatchSortEnabled() ? entitiesStream.sorted(batchUpdateComparator) : entitiesStream) - .collect(Collectors.toList()) - ); - entities.forEach(v -> v.getFuture().set(null)); + + List> entitiesToSave = filter.apply(entities); + + if (params.isBatchSortEnabled()) { + entitiesToSave = entitiesToSave.stream().sorted((o1, o2) -> batchUpdateComparator.compare(o1.getEntity(), o2.getEntity())).toList(); + } + + List result = saveFunction.apply(entitiesToSave.stream().map(TbSqlQueueElement::getEntity).collect(Collectors.toList())); + + if (params.isWithResponse()) { + for (int i = 0; i < entitiesToSave.size(); i++) { + entitiesToSave.get(i).getFuture().set(result.get(i)); + } + + if (entities.size() > entitiesToSave.size()) { + CollectionsUtil.diffLists(entitiesToSave, entities).forEach(v -> v.getFuture().set(null)); + } + } else { + entities.forEach(v -> v.getFuture().set(null)); + } + stats.incrementSuccessful(entities.size()); if (!fullPack) { long remainingDelay = maxDelay - (System.currentTimeMillis() - currentTs); @@ -104,7 +119,7 @@ public class TbSqlBlockingQueue implements TbSqlQueue { }); logExecutor.scheduleAtFixedRate(() -> { - if (queue.size() > 0 || stats.getTotal() > 0 || stats.getSuccessful() > 0 || stats.getFailed() > 0) { + if (!queue.isEmpty() || stats.getTotal() > 0 || stats.getSuccessful() > 0 || stats.getFailed() > 0) { log.info("Queue-{} [{}] queueSize [{}] totalAdded [{}] totalSaved [{}] totalFailed [{}]", index, params.getLogName(), queue.size(), stats.getTotal(), stats.getSuccessful(), stats.getFailed()); stats.reset(); @@ -120,8 +135,8 @@ public class TbSqlBlockingQueue implements TbSqlQueue { } @Override - public ListenableFuture add(E element) { - SettableFuture future = SettableFuture.create(); + public ListenableFuture add(E element) { + SettableFuture future = SettableFuture.create(); queue.add(new TbSqlQueueElement<>(future, element)); stats.incrementTotal(); return future; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlBlockingQueueParams.java b/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlBlockingQueueParams.java index 56b7ad3af7..42ab6be049 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlBlockingQueueParams.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlBlockingQueueParams.java @@ -30,4 +30,5 @@ public class TbSqlBlockingQueueParams { private final long statsPrintIntervalMs; private final String statsNamePrefix; private final boolean batchSortEnabled; + private final boolean withResponse; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlBlockingQueueWrapper.java b/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlBlockingQueueWrapper.java index 9c5bbbc855..3e9620de76 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlBlockingQueueWrapper.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlBlockingQueueWrapper.java @@ -29,10 +29,9 @@ import java.util.function.Function; @Slf4j @Data -public class TbSqlBlockingQueueWrapper { - private final CopyOnWriteArrayList> queues = new CopyOnWriteArrayList<>(); +public class TbSqlBlockingQueueWrapper { + private final CopyOnWriteArrayList> queues = new CopyOnWriteArrayList<>(); private final TbSqlBlockingQueueParams params; - private ScheduledLogExecutorComponent logExecutor; private final Function hashCodeFunction; private final int maxThreads; private final StatsFactory statsFactory; @@ -46,15 +45,19 @@ public class TbSqlBlockingQueueWrapper { * NOTE: you must use all of primary key parts in your comparator */ public void init(ScheduledLogExecutorComponent logExecutor, Consumer> saveFunction, Comparator batchUpdateComparator) { + init(logExecutor, l -> { saveFunction.accept(l); return null; }, batchUpdateComparator, l -> l); + } + + public void init(ScheduledLogExecutorComponent logExecutor, Function, List> saveFunction, Comparator batchUpdateComparator, Function>, List>> filter) { for (int i = 0; i < maxThreads; i++) { MessagesStats stats = statsFactory.createMessagesStats(params.getStatsNamePrefix() + ".queue." + i); - TbSqlBlockingQueue queue = new TbSqlBlockingQueue<>(params, stats); + TbSqlBlockingQueue queue = new TbSqlBlockingQueue<>(params, stats); queues.add(queue); - queue.init(logExecutor, saveFunction, batchUpdateComparator, i); + queue.init(logExecutor, saveFunction, batchUpdateComparator, filter, i); } } - public ListenableFuture add(E element) { + public ListenableFuture add(E element) { int queueIndex = element != null ? (hashCodeFunction.apply(element) & 0x7FFFFFFF) % maxThreads : 0; return queues.get(queueIndex).add(element); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlQueue.java b/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlQueue.java index 90b4e0fe67..e1ed8c299c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlQueue.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlQueue.java @@ -19,13 +19,13 @@ import com.google.common.util.concurrent.ListenableFuture; import java.util.Comparator; import java.util.List; -import java.util.function.Consumer; +import java.util.function.Function; -public interface TbSqlQueue { +public interface TbSqlQueue { - void init(ScheduledLogExecutorComponent logExecutor, Consumer> saveFunction, Comparator batchUpdateComparator, int queueIndex); + void init(ScheduledLogExecutorComponent logExecutor, Function, List> saveFunction, Comparator batchUpdateComparator, Function>, List>> filter, int queueIndex); void destroy(); - ListenableFuture add(E element); + ListenableFuture add(E element); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlQueueElement.java b/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlQueueElement.java index 15031be244..016c5c9527 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlQueueElement.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlQueueElement.java @@ -20,13 +20,13 @@ import lombok.Getter; import lombok.ToString; @ToString(exclude = "future") -public final class TbSqlQueueElement { +public final class TbSqlQueueElement { @Getter - private final SettableFuture future; + private final SettableFuture future; @Getter private final E entity; - public TbSqlQueueElement(SettableFuture future, E entity) { + public TbSqlQueueElement(SettableFuture future, E entity) { this.future = future; this.entity = entity; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDao.java index b0bf2cc666..133eb70521 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDao.java @@ -342,9 +342,12 @@ public class JpaAlarmDao extends JpaAbstractDao implements A @Override public AlarmApiCallResult createOrUpdateActiveAlarm(AlarmCreateOrUpdateActiveRequest request, boolean alarmCreationEnabled) { + UUID tenantUUID = request.getTenantId().getId(); + log.debug("[{}] createOrUpdateActiveAlarm [{}] {}", tenantUUID, alarmCreationEnabled, request); + AlarmPropagationInfo ap = getSafePropagationInfo(request.getPropagation()); return toAlarmApiResult(alarmRepository.createOrUpdateActiveAlarm( - request.getTenantId().getId(), + tenantUUID, request.getCustomerId() != null ? request.getCustomerId().getId() : CustomerId.NULL_UUID, request.getEdgeAlarmId() != null ? request.getEdgeAlarmId().getId() : UUID.randomUUID(), System.currentTimeMillis(), @@ -364,10 +367,14 @@ public class JpaAlarmDao extends JpaAbstractDao implements A @Override public AlarmApiCallResult updateAlarm(AlarmUpdateRequest request) { + UUID tenantUUID = request.getTenantId().getId(); + UUID alarmUUID = request.getAlarmId().getId(); + log.debug("[{}][{}] updateAlarm {}", tenantUUID, alarmUUID, request); + AlarmPropagationInfo ap = getSafePropagationInfo(request.getPropagation()); return toAlarmApiResult(alarmRepository.updateAlarm( - request.getTenantId().getId(), - request.getAlarmId().getId(), + tenantUUID, + alarmUUID, request.getSeverity().name(), request.getStartTs(), request.getEndTs(), getDetailsAsString(request.getDetails()), @@ -380,11 +387,13 @@ public class JpaAlarmDao extends JpaAbstractDao implements A @Override public AlarmApiCallResult acknowledgeAlarm(TenantId tenantId, AlarmId id, long ackTs) { + log.debug("[{}][{}] acknowledgeAlarm [{}]", tenantId, id, ackTs); return toAlarmApiResult(alarmRepository.acknowledgeAlarm(tenantId.getId(), id.getId(), ackTs)); } @Override public AlarmApiCallResult clearAlarm(TenantId tenantId, AlarmId id, long clearTs, JsonNode details) { + log.debug("[{}][{}] clearAlarm [{}]", tenantId, id, clearTs); return toAlarmApiResult(alarmRepository.clearAlarm(tenantId.getId(), id.getId(), clearTs, details != null ? getDetailsAsString(details) : null)); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetProfileDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetProfileDao.java index d6025fc07b..6ed74a5aa9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetProfileDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetProfileDao.java @@ -19,7 +19,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -58,14 +57,6 @@ public class JpaAssetProfileDao extends JpaAbstractDao findAssetProfiles(TenantId tenantId, PageLink pageLink) { return DaoUtil.toPageData( diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvInsertRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvInsertRepository.java index 856b2be381..7050e61144 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvInsertRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvInsertRepository.java @@ -15,162 +15,110 @@ */ package org.thingsboard.server.dao.sql.attributes; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.jdbc.core.BatchPreparedStatementSetter; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; -import org.springframework.transaction.TransactionStatus; -import org.springframework.transaction.support.TransactionCallbackWithoutResult; -import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.dao.AbstractVersionedInsertRepository; import org.thingsboard.server.dao.model.sql.AttributeKvEntity; import org.thingsboard.server.dao.util.SqlDao; import java.sql.PreparedStatement; import java.sql.SQLException; import java.sql.Types; -import java.util.ArrayList; import java.util.List; -import java.util.regex.Pattern; @Repository -@Slf4j +@Transactional @SqlDao -public abstract class AttributeKvInsertRepository { +public class AttributeKvInsertRepository extends AbstractVersionedInsertRepository { - private static final ThreadLocal PATTERN_THREAD_LOCAL = ThreadLocal.withInitial(() -> Pattern.compile(String.valueOf(Character.MIN_VALUE))); - private static final String EMPTY_STR = ""; - - private static final String BATCH_UPDATE = "UPDATE attribute_kv SET str_v = ?, long_v = ?, dbl_v = ?, bool_v = ?, json_v = cast(? AS json), last_update_ts = ? " + - "WHERE entity_id = ? and attribute_type =? and attribute_key = ?;"; + private static final String BATCH_UPDATE = "UPDATE attribute_kv SET str_v = ?, long_v = ?, dbl_v = ?, bool_v = ?, json_v = cast(? AS json), last_update_ts = ?, version = nextval('attribute_kv_version_seq') " + + "WHERE entity_id = ? and attribute_type =? and attribute_key = ? RETURNING version;"; private static final String INSERT_OR_UPDATE = - "INSERT INTO attribute_kv (entity_id, attribute_type, attribute_key, str_v, long_v, dbl_v, bool_v, json_v, last_update_ts) " + - "VALUES(?, ?, ?, ?, ?, ?, ?, cast(? AS json), ?) " + + "INSERT INTO attribute_kv (entity_id, attribute_type, attribute_key, str_v, long_v, dbl_v, bool_v, json_v, last_update_ts, version) " + + "VALUES(?, ?, ?, ?, ?, ?, ?, cast(? AS json), ?, nextval('attribute_kv_version_seq')) " + "ON CONFLICT (entity_id, attribute_type, attribute_key) " + - "DO UPDATE SET str_v = ?, long_v = ?, dbl_v = ?, bool_v = ?, json_v = cast(? AS json), last_update_ts = ?;"; - - @Autowired - protected JdbcTemplate jdbcTemplate; - - @Autowired - private TransactionTemplate transactionTemplate; - - @Value("${sql.remove_null_chars:true}") - private boolean removeNullChars; - - public void saveOrUpdate(List entities) { - transactionTemplate.execute(new TransactionCallbackWithoutResult() { - @Override - protected void doInTransactionWithoutResult(TransactionStatus status) { - int[] result = jdbcTemplate.batchUpdate(BATCH_UPDATE, new BatchPreparedStatementSetter() { - @Override - public void setValues(PreparedStatement ps, int i) throws SQLException { - AttributeKvEntity kvEntity = entities.get(i); - ps.setString(1, replaceNullChars(kvEntity.getStrValue())); - - if (kvEntity.getLongValue() != null) { - ps.setLong(2, kvEntity.getLongValue()); - } else { - ps.setNull(2, Types.BIGINT); - } - - if (kvEntity.getDoubleValue() != null) { - ps.setDouble(3, kvEntity.getDoubleValue()); - } else { - ps.setNull(3, Types.DOUBLE); - } - - if (kvEntity.getBooleanValue() != null) { - ps.setBoolean(4, kvEntity.getBooleanValue()); - } else { - ps.setNull(4, Types.BOOLEAN); - } - - ps.setString(5, replaceNullChars(kvEntity.getJsonValue())); - - ps.setLong(6, kvEntity.getLastUpdateTs()); - ps.setObject(7, kvEntity.getId().getEntityId()); - ps.setInt(8, kvEntity.getId().getAttributeType()); - ps.setInt(9, kvEntity.getId().getAttributeKey()); - } - - @Override - public int getBatchSize() { - return entities.size(); - } - }); - - int updatedCount = 0; - for (int i = 0; i < result.length; i++) { - if (result[i] == 0) { - updatedCount++; - } - } - - List insertEntities = new ArrayList<>(updatedCount); - for (int i = 0; i < result.length; i++) { - if (result[i] == 0) { - insertEntities.add(entities.get(i)); - } - } - - jdbcTemplate.batchUpdate(INSERT_OR_UPDATE, new BatchPreparedStatementSetter() { - @Override - public void setValues(PreparedStatement ps, int i) throws SQLException { - AttributeKvEntity kvEntity = insertEntities.get(i); - ps.setObject(1, kvEntity.getId().getEntityId()); - ps.setInt(2, kvEntity.getId().getAttributeType()); - ps.setInt(3, kvEntity.getId().getAttributeKey()); - - ps.setString(4, replaceNullChars(kvEntity.getStrValue())); - ps.setString(10, replaceNullChars(kvEntity.getStrValue())); - - if (kvEntity.getLongValue() != null) { - ps.setLong(5, kvEntity.getLongValue()); - ps.setLong(11, kvEntity.getLongValue()); - } else { - ps.setNull(5, Types.BIGINT); - ps.setNull(11, Types.BIGINT); - } - - if (kvEntity.getDoubleValue() != null) { - ps.setDouble(6, kvEntity.getDoubleValue()); - ps.setDouble(12, kvEntity.getDoubleValue()); - } else { - ps.setNull(6, Types.DOUBLE); - ps.setNull(12, Types.DOUBLE); - } - - if (kvEntity.getBooleanValue() != null) { - ps.setBoolean(7, kvEntity.getBooleanValue()); - ps.setBoolean(13, kvEntity.getBooleanValue()); - } else { - ps.setNull(7, Types.BOOLEAN); - ps.setNull(13, Types.BOOLEAN); - } - - ps.setString(8, replaceNullChars(kvEntity.getJsonValue())); - ps.setString(14, replaceNullChars(kvEntity.getJsonValue())); - - ps.setLong(9, kvEntity.getLastUpdateTs()); - ps.setLong(15, kvEntity.getLastUpdateTs()); - } - - @Override - public int getBatchSize() { - return insertEntities.size(); - } - }); - } - }); + "DO UPDATE SET str_v = ?, long_v = ?, dbl_v = ?, bool_v = ?, json_v = cast(? AS json), last_update_ts = ?, version = nextval('attribute_kv_version_seq') RETURNING version;"; + + @Override + protected void setOnBatchUpdateValues(PreparedStatement ps, int i, List entities) throws SQLException { + AttributeKvEntity kvEntity = entities.get(i); + ps.setString(1, replaceNullChars(kvEntity.getStrValue())); + + if (kvEntity.getLongValue() != null) { + ps.setLong(2, kvEntity.getLongValue()); + } else { + ps.setNull(2, Types.BIGINT); + } + + if (kvEntity.getDoubleValue() != null) { + ps.setDouble(3, kvEntity.getDoubleValue()); + } else { + ps.setNull(3, Types.DOUBLE); + } + + if (kvEntity.getBooleanValue() != null) { + ps.setBoolean(4, kvEntity.getBooleanValue()); + } else { + ps.setNull(4, Types.BOOLEAN); + } + + ps.setString(5, replaceNullChars(kvEntity.getJsonValue())); + + ps.setLong(6, kvEntity.getLastUpdateTs()); + ps.setObject(7, kvEntity.getId().getEntityId()); + ps.setInt(8, kvEntity.getId().getAttributeType()); + ps.setInt(9, kvEntity.getId().getAttributeKey()); } - private String replaceNullChars(String strValue) { - if (removeNullChars && strValue != null) { - return PATTERN_THREAD_LOCAL.get().matcher(strValue).replaceAll(EMPTY_STR); + @Override + protected void setOnInsertOrUpdateValues(PreparedStatement ps, int i, List insertEntities) throws SQLException { + AttributeKvEntity kvEntity = insertEntities.get(i); + ps.setObject(1, kvEntity.getId().getEntityId()); + ps.setInt(2, kvEntity.getId().getAttributeType()); + ps.setInt(3, kvEntity.getId().getAttributeKey()); + + ps.setString(4, replaceNullChars(kvEntity.getStrValue())); + ps.setString(10, replaceNullChars(kvEntity.getStrValue())); + + if (kvEntity.getLongValue() != null) { + ps.setLong(5, kvEntity.getLongValue()); + ps.setLong(11, kvEntity.getLongValue()); + } else { + ps.setNull(5, Types.BIGINT); + ps.setNull(11, Types.BIGINT); } - return strValue; + + if (kvEntity.getDoubleValue() != null) { + ps.setDouble(6, kvEntity.getDoubleValue()); + ps.setDouble(12, kvEntity.getDoubleValue()); + } else { + ps.setNull(6, Types.DOUBLE); + ps.setNull(12, Types.DOUBLE); + } + + if (kvEntity.getBooleanValue() != null) { + ps.setBoolean(7, kvEntity.getBooleanValue()); + ps.setBoolean(13, kvEntity.getBooleanValue()); + } else { + ps.setNull(7, Types.BOOLEAN); + ps.setNull(13, Types.BOOLEAN); + } + + ps.setString(8, replaceNullChars(kvEntity.getJsonValue())); + ps.setString(14, replaceNullChars(kvEntity.getJsonValue())); + + ps.setLong(9, kvEntity.getLastUpdateTs()); + ps.setLong(15, kvEntity.getLastUpdateTs()); + } + + @Override + protected String getBatchUpdateQuery() { + return BATCH_UPDATE; + } + + @Override + protected String getInsertOrUpdateQuery() { + return INSERT_OR_UPDATE; } -} \ No newline at end of file +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java index ddd6c3c881..90f49c57fd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java @@ -30,8 +30,8 @@ public interface AttributeKvRepository extends JpaRepository findAllEntityIdAndAttributeType(@Param("entityId") UUID entityId, - @Param("attributeType") int attributeType); + List findAllByEntityIdAndAttributeType(@Param("entityId") UUID entityId, + @Param("attributeType") int attributeType); @Transactional @Modifying @@ -60,4 +60,3 @@ public interface AttributeKvRepository extends JpaRepository findAllKeysByEntityIdsAndAttributeType(@Param("entityIds") List entityIds, @Param("attributeType") int attributeType); } - diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java index 320e0e67fb..a0e1f1447d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java @@ -16,9 +16,7 @@ package org.thingsboard.server.dao.sql.attributes; import com.google.common.collect.Lists; -import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.MoreExecutors; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; @@ -32,6 +30,7 @@ import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.attributes.AttributesDao; @@ -88,7 +87,7 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl @Value("${sql.batch_sort:true}") private boolean batchSortEnabled; - private TbSqlBlockingQueueWrapper queue; + private TbSqlBlockingQueueWrapper queue; @PostConstruct private void init() { @@ -99,6 +98,7 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl .statsPrintIntervalMs(statsPrintIntervalMs) .statsNamePrefix("attributes") .batchSortEnabled(batchSortEnabled) + .withResponse(true) .build(); Function hashcodeFunction = entity -> entity.getId().getEntityId().hashCode(); @@ -106,7 +106,7 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl queue.init(logExecutor, v -> attributeKvInsertRepository.saveOrUpdate(v), Comparator.comparing((AttributeKvEntity attributeKvEntity) -> attributeKvEntity.getId().getEntityId()) .thenComparing(attributeKvEntity -> attributeKvEntity.getId().getAttributeType()) - .thenComparing(attributeKvEntity -> attributeKvEntity.getId().getAttributeKey()) + .thenComparing(attributeKvEntity -> attributeKvEntity.getId().getAttributeKey()), l -> l ); } @@ -145,7 +145,7 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl @Override public List findAll(TenantId tenantId, EntityId entityId, AttributeScope attributeScope) { - List attributes = attributeKvRepository.findAllEntityIdAndAttributeType( + List attributes = attributeKvRepository.findAllByEntityIdAndAttributeType( entityId.getId(), attributeScope.getId()); attributes.forEach(attributeKvEntity -> attributeKvEntity.setStrKey(keyDictionaryDao.getKey(attributeKvEntity.getId().getAttributeKey()))); @@ -178,7 +178,7 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl } @Override - public ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope attributeScope, AttributeKvEntry attribute) { + public ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope attributeScope, AttributeKvEntry attribute) { AttributeKvEntity entity = new AttributeKvEntity(); entity.setId(new AttributeKvCompositeKey(entityId.getId(), attributeScope.getId(), keyDictionaryDao.getOrSaveKeyId(attribute.getKey()))); entity.setLastUpdateTs(attribute.getLastUpdateTs()); @@ -187,11 +187,11 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl entity.setLongValue(attribute.getLongValue().orElse(null)); entity.setBooleanValue(attribute.getBooleanValue().orElse(null)); entity.setJsonValue(attribute.getJsonValue().orElse(null)); - return addToQueue(entity, attribute.getKey()); + return addToQueue(entity); } - private ListenableFuture addToQueue(AttributeKvEntity entity, String key) { - return Futures.transform(queue.add(entity), v -> key, MoreExecutors.directExecutor()); + private ListenableFuture addToQueue(AttributeKvEntity entity) { + return queue.add(entity); } @Override @@ -206,6 +206,20 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl return futuresList; } + @Override + public List>> removeAllWithVersions(TenantId tenantId, EntityId entityId, AttributeScope attributeScope, List keys) { + List>> futuresList = new ArrayList<>(keys.size()); + for (String key : keys) { + futuresList.add(service.submit(() -> { + Long version = transactionTemplate.execute(status -> jdbcTemplate.query("DELETE FROM attribute_kv WHERE entity_id = ? AND attribute_type = ? " + + "AND attribute_key = ? RETURNING nextval('attribute_kv_version_seq')", + rs -> rs.next() ? rs.getLong(1) : null, entityId.getId(), attributeScope.getId(), keyDictionaryDao.getOrSaveKeyId(key))); + return TbPair.of(key, version); + })); + } + return futuresList; + } + @Transactional @Override public List> removeAllByEntityId(TenantId tenantId, EntityId entityId) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/audit/DedicatedJpaAuditLogDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/audit/DedicatedJpaAuditLogDao.java new file mode 100644 index 0000000000..d40e1e5474 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/audit/DedicatedJpaAuditLogDao.java @@ -0,0 +1,87 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.sql.audit; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.common.data.audit.AuditLog; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.config.DedicatedEventsDataSource; +import org.thingsboard.server.dao.sqlts.insert.sql.DedicatedEventsSqlPartitioningRepository; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.Collection; +import java.util.UUID; + +import static org.thingsboard.server.dao.config.DedicatedEventsJpaDaoConfig.EVENTS_JDBC_TEMPLATE; +import static org.thingsboard.server.dao.config.DedicatedEventsJpaDaoConfig.EVENTS_PERSISTENCE_UNIT; +import static org.thingsboard.server.dao.config.DedicatedEventsJpaDaoConfig.EVENTS_TRANSACTION_MANAGER; + +@DedicatedEventsDataSource +@Component +@SqlDao +public class DedicatedJpaAuditLogDao extends JpaAuditLogDao { + + @Autowired + @Qualifier(EVENTS_JDBC_TEMPLATE) + private JdbcTemplate jdbcTemplate; + @PersistenceContext(unitName = EVENTS_PERSISTENCE_UNIT) + private EntityManager entityManager; + + public DedicatedJpaAuditLogDao(AuditLogRepository auditLogRepository, DedicatedEventsSqlPartitioningRepository partitioningRepository) { + super(auditLogRepository, partitioningRepository); + } + + @Transactional(transactionManager = EVENTS_TRANSACTION_MANAGER) + @Override + public AuditLog save(TenantId tenantId, AuditLog domain) { + return super.save(tenantId, domain); + } + + @Transactional(transactionManager = EVENTS_TRANSACTION_MANAGER) + @Override + public AuditLog saveAndFlush(TenantId tenantId, AuditLog domain) { + return super.saveAndFlush(tenantId, domain); + } + + @Transactional(transactionManager = EVENTS_TRANSACTION_MANAGER) + @Override + public void removeById(TenantId tenantId, UUID id) { + super.removeById(tenantId, id); + } + + @Transactional(transactionManager = EVENTS_TRANSACTION_MANAGER) + @Override + public void removeAllByIds(Collection ids) { + super.removeAllByIds(ids); + } + + @Override + protected EntityManager getEntityManager() { + return entityManager; + } + + @Override + protected JdbcTemplate getJdbcTemplate() { + return jdbcTemplate; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/audit/JpaAuditLogDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/audit/JpaAuditLogDao.java index f73cb95c27..7e859d1e8c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/audit/JpaAuditLogDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/audit/JpaAuditLogDao.java @@ -19,7 +19,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.audit.AuditLog; @@ -30,7 +29,7 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.audit.AuditLogDao; -import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.config.DefaultDataSource; import org.thingsboard.server.dao.model.sql.AuditLogEntity; import org.thingsboard.server.dao.sql.JpaPartitionedAbstractDao; import org.thingsboard.server.dao.sqlts.insert.sql.SqlPartitioningRepository; @@ -40,6 +39,9 @@ import java.util.List; import java.util.UUID; import java.util.concurrent.TimeUnit; +import static org.thingsboard.server.dao.model.ModelConstants.AUDIT_LOG_TABLE_NAME; + +@DefaultDataSource @Component @SqlDao @RequiredArgsConstructor @@ -48,24 +50,9 @@ public class JpaAuditLogDao extends JpaPartitionedAbstractDao getEntityClass() { - return AuditLogEntity.class; - } - - @Override - protected JpaRepository getRepository() { - return auditLogRepository; - } @Override public PageData findAuditLogsByTenantIdAndEntityId(UUID tenantId, EntityId entityId, List actionTypes, TimePageLink pageLink) { @@ -124,12 +111,22 @@ public class JpaAuditLogDao extends JpaPartitionedAbstractDao getEntityClass() { + return AuditLogEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return auditLogRepository; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/component/AbstractComponentDescriptorInsertRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/component/AbstractComponentDescriptorInsertRepository.java index eca2c769b9..bd23d1d9fe 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/component/AbstractComponentDescriptorInsertRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/component/AbstractComponentDescriptorInsertRepository.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.dao.sql.component; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.ConstraintViolationException; import org.springframework.beans.factory.annotation.Autowired; @@ -25,10 +28,6 @@ import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.DefaultTransactionDefinition; import org.thingsboard.server.dao.model.sql.ComponentDescriptorEntity; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.persistence.Query; - @Slf4j public abstract class AbstractComponentDescriptorInsertRepository implements ComponentDescriptorInsertRepository { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/component/JpaBaseComponentDescriptorDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/component/JpaBaseComponentDescriptorDao.java index c1a1c3dca0..79d47c9efe 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/component/JpaBaseComponentDescriptorDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/component/JpaBaseComponentDescriptorDao.java @@ -32,7 +32,6 @@ import org.thingsboard.server.dao.component.ComponentDescriptorDao; import org.thingsboard.server.dao.model.sql.ComponentDescriptorEntity; import org.thingsboard.server.dao.sql.JpaAbstractDao; -import java.util.Objects; import java.util.Optional; import java.util.UUID; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java index 289da44626..c55210b606 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java @@ -83,33 +83,27 @@ public interface DeviceRepository extends JpaRepository, Exp @Query("SELECT d FROM DeviceEntity d WHERE d.tenantId = :tenantId " + "AND d.deviceProfileId = :deviceProfileId " + - "AND d.firmwareId = null " + - "AND (:textSearch IS NULL OR ilike(d.name, CONCAT('%', :textSearch, '%')) = true " + - "OR ilike(d.label, CONCAT('%', :textSearch, '%')) = true)") + "AND d.firmwareId IS NULL") Page findByTenantIdAndTypeAndFirmwareIdIsNull(@Param("tenantId") UUID tenantId, @Param("deviceProfileId") UUID deviceProfileId, - @Param("textSearch") String textSearch, Pageable pageable); @Query("SELECT d FROM DeviceEntity d WHERE d.tenantId = :tenantId " + "AND d.deviceProfileId = :deviceProfileId " + - "AND d.softwareId = null " + - "AND (:textSearch IS NULL OR ilike(d.name, CONCAT('%', :textSearch, '%')) = true " + - "OR ilike(d.label, CONCAT('%', :textSearch, '%')) = true)") + "AND d.softwareId IS NULL") Page findByTenantIdAndTypeAndSoftwareIdIsNull(@Param("tenantId") UUID tenantId, @Param("deviceProfileId") UUID deviceProfileId, - @Param("textSearch") String textSearch, Pageable pageable); @Query("SELECT count(*) FROM DeviceEntity d WHERE d.tenantId = :tenantId " + "AND d.deviceProfileId = :deviceProfileId " + - "AND d.firmwareId = null") + "AND d.firmwareId IS NULL") Long countByTenantIdAndDeviceProfileIdAndFirmwareIdIsNull(@Param("tenantId") UUID tenantId, @Param("deviceProfileId") UUID deviceProfileId); @Query("SELECT count(*) FROM DeviceEntity d WHERE d.tenantId = :tenantId " + "AND d.deviceProfileId = :deviceProfileId " + - "AND d.softwareId = null") + "AND d.softwareId IS NULL") Long countByTenantIdAndDeviceProfileIdAndSoftwareIdIsNull(@Param("tenantId") UUID tenantId, @Param("deviceProfileId") UUID deviceProfileId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDao.java index 6510d3c415..7571c00b6a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDao.java @@ -19,7 +19,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.DeviceCredentials; @@ -52,14 +51,6 @@ public class JpaDeviceCredentialsDao extends JpaAbstractDao implement return DaoUtil.getData(deviceRepository.findDeviceInfoById(deviceId)); } - @Override - @Transactional - public Device saveAndFlush(TenantId tenantId, Device device) { - Device result = this.save(tenantId, device); - deviceRepository.flush(); - return result; - } - @Override public PageData findDevicesByTenantId(UUID tenantId, PageLink pageLink) { if (StringUtils.isEmpty(pageLink.getTextSearch())) { @@ -188,10 +179,9 @@ public class JpaDeviceDao extends JpaAbstractDao implement OtaPackageType type, PageLink pageLink) { Pageable pageable = DaoUtil.toPageable(pageLink); - String searchText = pageLink.getTextSearch(); Page page = OtaPackageUtil.getByOtaPackageType( - () -> deviceRepository.findByTenantIdAndTypeAndFirmwareIdIsNull(tenantId, deviceProfileId, searchText, pageable), - () -> deviceRepository.findByTenantIdAndTypeAndSoftwareIdIsNull(tenantId, deviceProfileId, searchText, pageable), + () -> deviceRepository.findByTenantIdAndTypeAndFirmwareIdIsNull(tenantId, deviceProfileId, pageable), + () -> deviceRepository.findByTenantIdAndTypeAndSoftwareIdIsNull(tenantId, deviceProfileId, pageable), type ); return DaoUtil.toPageData(page); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceProfileDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceProfileDao.java index 0f597756eb..4e1594eeff 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceProfileDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceProfileDao.java @@ -19,7 +19,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileInfo; import org.thingsboard.server.common.data.DeviceTransportType; @@ -62,14 +61,6 @@ public class JpaDeviceProfileDao extends JpaAbstractDao findDeviceProfiles(TenantId tenantId, PageLink pageLink) { return DaoUtil.toPageData( diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaBaseEdgeEventDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaBaseEdgeEventDao.java index 1c64973d94..d9d2282fca 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaBaseEdgeEventDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaBaseEdgeEventDao.java @@ -90,7 +90,7 @@ public class JpaBaseEdgeEventDao extends JpaPartitionedAbstractDao queue; + private TbSqlBlockingQueueWrapper queue; @Override protected Class getEntityClass() { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaEdgeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaEdgeDao.java index a1cfc52550..e76dfe9764 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaEdgeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaEdgeDao.java @@ -35,7 +35,6 @@ import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.UUID; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java index 63c527edfc..98c5ad7036 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -36,7 +36,6 @@ import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.UUID; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/DedicatedEventInsertRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/DedicatedEventInsertRepository.java new file mode 100644 index 0000000000..65169dcbc7 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/DedicatedEventInsertRepository.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.sql.event; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.support.TransactionTemplate; +import org.thingsboard.server.dao.config.DedicatedEventsDataSource; + +import static org.thingsboard.server.dao.config.DedicatedEventsJpaDaoConfig.EVENTS_JDBC_TEMPLATE; +import static org.thingsboard.server.dao.config.DedicatedEventsJpaDaoConfig.EVENTS_TRANSACTION_TEMPLATE; + +@DedicatedEventsDataSource +@Repository +public class DedicatedEventInsertRepository extends EventInsertRepository { + + public DedicatedEventInsertRepository(@Qualifier(EVENTS_JDBC_TEMPLATE) JdbcTemplate jdbcTemplate, + @Qualifier(EVENTS_TRANSACTION_TEMPLATE) TransactionTemplate transactionTemplate) { + super(jdbcTemplate, transactionTemplate); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/DedicatedJpaEventDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/DedicatedJpaEventDao.java new file mode 100644 index 0000000000..9b7af5e7f7 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/DedicatedJpaEventDao.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.sql.event; + +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.dao.config.DedicatedEventsDataSource; +import org.thingsboard.server.dao.sql.ScheduledLogExecutorComponent; +import org.thingsboard.server.dao.sqlts.insert.sql.DedicatedEventsSqlPartitioningRepository; +import org.thingsboard.server.dao.util.SqlDao; + +@DedicatedEventsDataSource +@Component +@SqlDao +public class DedicatedJpaEventDao extends JpaBaseEventDao { + + public DedicatedJpaEventDao(EventPartitionConfiguration partitionConfiguration, + DedicatedEventsSqlPartitioningRepository partitioningRepository, + LifecycleEventRepository lcEventRepository, + StatisticsEventRepository statsEventRepository, + ErrorEventRepository errorEventRepository, + DedicatedEventInsertRepository eventInsertRepository, + RuleNodeDebugEventRepository ruleNodeDebugEventRepository, + RuleChainDebugEventRepository ruleChainDebugEventRepository, + ScheduledLogExecutorComponent logExecutor, + StatsFactory statsFactory) { + super(partitionConfiguration, partitioningRepository, lcEventRepository, statsEventRepository, + errorEventRepository, eventInsertRepository, ruleNodeDebugEventRepository, + ruleChainDebugEventRepository, logExecutor, statsFactory); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventInsertRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventInsertRepository.java index 5c6969fce9..962be57892 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventInsertRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventInsertRepository.java @@ -15,7 +15,8 @@ */ package org.thingsboard.server.dao.sql.event; -import org.springframework.beans.factory.annotation.Autowired; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.BatchPreparedStatementSetter; import org.springframework.jdbc.core.JdbcTemplate; @@ -31,9 +32,9 @@ import org.thingsboard.server.common.data.event.LifecycleEvent; import org.thingsboard.server.common.data.event.RuleChainDebugEvent; import org.thingsboard.server.common.data.event.RuleNodeDebugEvent; import org.thingsboard.server.common.data.event.StatisticsEvent; +import org.thingsboard.server.dao.config.DefaultDataSource; import org.thingsboard.server.dao.util.SqlDao; -import jakarta.annotation.PostConstruct; import java.sql.PreparedStatement; import java.sql.SQLException; import java.sql.Types; @@ -44,9 +45,11 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import java.util.stream.Collectors; +@DefaultDataSource @Repository @Transactional @SqlDao +@RequiredArgsConstructor public class EventInsertRepository { private static final ThreadLocal PATTERN_THREAD_LOCAL = ThreadLocal.withInitial(() -> Pattern.compile(String.valueOf(Character.MIN_VALUE))); @@ -55,11 +58,8 @@ public class EventInsertRepository { private final Map insertStmtMap = new ConcurrentHashMap<>(); - @Autowired - protected JdbcTemplate jdbcTemplate; - - @Autowired - private TransactionTemplate transactionTemplate; + private final JdbcTemplate jdbcTemplate; + private final TransactionTemplate transactionTemplate; @Value("${sql.remove_null_chars:true}") private boolean removeNullChars; @@ -236,4 +236,5 @@ public class EventInsertRepository { } return strValue; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventPartitionConfiguration.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventPartitionConfiguration.java index b053e2ebc2..4be8775145 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventPartitionConfiguration.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventPartitionConfiguration.java @@ -15,12 +15,12 @@ */ package org.thingsboard.server.dao.sql.event; +import jakarta.annotation.PostConstruct; import lombok.Getter; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.event.EventType; -import jakarta.annotation.PostConstruct; import java.util.concurrent.TimeUnit; @Component diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java index afd6608774..b8d5083402 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java @@ -19,8 +19,8 @@ import com.datastax.oss.driver.api.core.uuid.Uuids; import com.google.common.util.concurrent.ListenableFuture; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; @@ -38,6 +38,7 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.config.DefaultDataSource; import org.thingsboard.server.dao.event.EventDao; import org.thingsboard.server.dao.model.sql.EventEntity; import org.thingsboard.server.dao.sql.ScheduledLogExecutorComponent; @@ -54,46 +55,23 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; -/** - * Created by Valerii Sosliuk on 5/3/2017. - */ -@Slf4j +@DefaultDataSource @Component @SqlDao +@RequiredArgsConstructor +@Slf4j public class JpaBaseEventDao implements EventDao { - @Autowired - private EventPartitionConfiguration partitionConfiguration; - - @Autowired - private SqlPartitioningRepository partitioningRepository; - - @Autowired - private LifecycleEventRepository lcEventRepository; - - @Autowired - private StatisticsEventRepository statsEventRepository; - - @Autowired - private ErrorEventRepository errorEventRepository; - - @Autowired - private EventInsertRepository eventInsertRepository; - - @Autowired - private EventCleanupRepository eventCleanupRepository; - - @Autowired - private RuleNodeDebugEventRepository ruleNodeDebugEventRepository; - - @Autowired - private RuleChainDebugEventRepository ruleChainDebugEventRepository; - - @Autowired - ScheduledLogExecutorComponent logExecutor; - - @Autowired - private StatsFactory statsFactory; + private final EventPartitionConfiguration partitionConfiguration; + private final SqlPartitioningRepository partitioningRepository; + private final LifecycleEventRepository lcEventRepository; + private final StatisticsEventRepository statsEventRepository; + private final ErrorEventRepository errorEventRepository; + private final EventInsertRepository eventInsertRepository; + private final RuleNodeDebugEventRepository ruleNodeDebugEventRepository; + private final RuleChainDebugEventRepository ruleChainDebugEventRepository; + private final ScheduledLogExecutorComponent logExecutor; + private final StatsFactory statsFactory; @Value("${sql.events.batch_size:10000}") private int batchSize; @@ -110,7 +88,7 @@ public class JpaBaseEventDao implements EventDao { @Value("${sql.batch_sort:true}") private boolean batchSortEnabled; - private TbSqlBlockingQueueWrapper queue; + private TbSqlBlockingQueueWrapper queue; private final Map> repositories = new ConcurrentHashMap<>(); @@ -223,11 +201,6 @@ public class JpaBaseEventDao implements EventDao { } } - @Override - public void migrateEvents(long regularEventTs, long debugEventTs) { - eventCleanupRepository.migrateEvents(regularEventTs, debugEventTs); - } - private PageData findEventByFilter(UUID tenantId, UUID entityId, RuleChainDebugEventFilter eventFilter, TimePageLink pageLink) { return DaoUtil.toPageData( ruleChainDebugEventRepository.findEvents( @@ -402,7 +375,7 @@ public class JpaBaseEventDao implements EventDao { if (regularEventExpTs > 0) { log.info("Going to cleanup regular events with exp time: {}", regularEventExpTs); if (cleanupDb) { - eventCleanupRepository.cleanupEvents(regularEventExpTs, false); + cleanupEvents(regularEventExpTs, false); } else { cleanupPartitionsCache(regularEventExpTs, false); } @@ -410,13 +383,25 @@ public class JpaBaseEventDao implements EventDao { if (debugEventExpTs > 0) { log.info("Going to cleanup debug events with exp time: {}", debugEventExpTs); if (cleanupDb) { - eventCleanupRepository.cleanupEvents(debugEventExpTs, true); + cleanupEvents(debugEventExpTs, true); } else { cleanupPartitionsCache(debugEventExpTs, true); } } } + private void cleanupEvents(long eventExpTime, boolean debug) { + for (EventType eventType : EventType.values()) { + if (eventType.isDebug() == debug) { + cleanupPartitions(eventType, eventExpTime); + } + } + } + + private void cleanupPartitions(EventType eventType, long eventExpTime) { + partitioningRepository.dropPartitionsBefore(eventType.getTable(), eventExpTime, partitionConfiguration.getPartitionSizeInMs(eventType)); + } + private void cleanupPartitionsCache(long expTime, boolean isDebug) { for (EventType eventType : EventType.values()) { if (eventType.isDebug() == isDebug) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/SqlEventCleanupRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/SqlEventCleanupRepository.java deleted file mode 100644 index d1e8aa380f..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/event/SqlEventCleanupRepository.java +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Copyright © 2016-2024 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao.sql.event; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.dao.DataAccessException; -import org.springframework.stereotype.Repository; -import org.thingsboard.server.common.data.event.EventType; -import org.thingsboard.server.dao.sql.JpaAbstractDaoListeningExecutorService; -import org.thingsboard.server.dao.sqlts.insert.sql.SqlPartitioningRepository; - -import java.util.concurrent.TimeUnit; - - -@Slf4j -@Repository -public class SqlEventCleanupRepository extends JpaAbstractDaoListeningExecutorService implements EventCleanupRepository { - - @Autowired - private EventPartitionConfiguration partitionConfiguration; - @Autowired - private SqlPartitioningRepository partitioningRepository; - - @Override - public void cleanupEvents(long eventExpTime, boolean debug) { - for (EventType eventType : EventType.values()) { - if (eventType.isDebug() == debug) { - cleanupEvents(eventType, eventExpTime); - } - } - } - - @Override - public void migrateEvents(long regularEventTs, long debugEventTs) { - regularEventTs = Math.max(regularEventTs, 1480982400000L); - debugEventTs = Math.max(debugEventTs, 1480982400000L); - - callMigrateFunctionByPartitions("regular", "migrate_regular_events", regularEventTs, partitionConfiguration.getRegularPartitionSizeInHours()); - callMigrateFunctionByPartitions("debug", "migrate_debug_events", debugEventTs, partitionConfiguration.getDebugPartitionSizeInHours()); - - try { - jdbcTemplate.execute("DROP PROCEDURE IF EXISTS migrate_regular_events(bigint, bigint, int)"); - jdbcTemplate.execute("DROP PROCEDURE IF EXISTS migrate_debug_events(bigint, bigint, int)"); - jdbcTemplate.execute("DROP TABLE IF EXISTS event"); - } catch (DataAccessException e) { - log.error("Error occurred during drop of the `events` table", e); - throw e; - } - } - - private void callMigrateFunctionByPartitions(String logTag, String functionName, long startTs, int partitionSizeInHours) { - long currentTs = System.currentTimeMillis(); - var regularPartitionStepInMs = TimeUnit.HOURS.toMillis(partitionSizeInHours); - long numberOfPartitions = (currentTs - startTs) / regularPartitionStepInMs; - if (numberOfPartitions > 1000) { - log.error("Please adjust your {} events partitioning configuration. " + - "Configuration with partition size of {} hours and corresponding TTL will use {} (>1000) partitions which is not recommended!", - logTag, partitionSizeInHours, numberOfPartitions); - throw new RuntimeException("Please adjust your " + logTag + " events partitioning configuration. " + - "Configuration with partition size of " + partitionSizeInHours + " hours and corresponding TTL will use " + - +numberOfPartitions + " (>1000) partitions which is not recommended!"); - } - while (startTs < currentTs) { - var endTs = startTs + regularPartitionStepInMs; - log.info("Migrate {} events for time period: [{},{}]", logTag, startTs, endTs); - callMigrateFunction(functionName, startTs, startTs + regularPartitionStepInMs, partitionSizeInHours); - startTs = endTs; - } - log.info("Migrate {} events done.", logTag); - } - - private void callMigrateFunction(String functionName, long startTs, long endTs, int partitionSizeInHours) { - try { - jdbcTemplate.update("CALL " + functionName + "(?, ?, ?)", startTs, endTs, partitionSizeInHours); - } catch (DataAccessException e) { - if (e.getMessage() == null || !e.getMessage().contains("relation \"event\" does not exist")) { - log.error("[{}] SQLException occurred during execution of {} with parameters {} and {}", functionName, startTs, partitionSizeInHours, e); - throw new RuntimeException(e); - } - } - } - - private void cleanupEvents(EventType eventType, long eventExpTime) { - partitioningRepository.dropPartitionsBefore(eventType.getTable(), eventExpTime, partitionConfiguration.getPartitionSizeInMs(eventType)); - } - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationDao.java index 64d663b397..22d2747e3b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationDao.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.sql.notification; import lombok.RequiredArgsConstructor; +import org.apache.commons.collections4.CollectionUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; @@ -27,6 +28,7 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationStatus; +import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.DaoUtil; @@ -37,6 +39,7 @@ import org.thingsboard.server.dao.sql.JpaPartitionedAbstractDao; import org.thingsboard.server.dao.sqlts.insert.sql.SqlPartitioningRepository; import org.thingsboard.server.dao.util.SqlDao; +import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -58,6 +61,14 @@ public class JpaNotificationDao extends JpaPartitionedAbstractDao findUnreadByDeliveryMethodAndRecipientIdAndNotificationTypesAndPageLink(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, Set types, PageLink pageLink) { + if (CollectionUtils.isEmpty(types)) { + return findUnreadByDeliveryMethodAndRecipientIdAndPageLink(tenantId, deliveryMethod, recipientId, pageLink); + } + return DaoUtil.toPageData(notificationRepository.findByDeliveryMethodAndRecipientIdAndTypeInAndStatusNot(deliveryMethod, + recipientId.getId(), types, NotificationStatus.READ, pageLink.getTextSearch(), DaoUtil.toPageable(pageLink))); + } + public PageData findByDeliveryMethodAndRecipientIdAndPageLink(TenantId tenantId, NotificationDeliveryMethod deliveryMethod, UserId recipientId, PageLink pageLink) { return DaoUtil.toPageData(notificationRepository.findByDeliveryMethodAndRecipientId(deliveryMethod, recipientId.getId(), pageLink.getTextSearch(), DaoUtil.toPageable(pageLink))); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRequestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRequestDao.java index df90e6430e..3d2d80e15e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRequestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRequestDao.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.sql.notification; -import com.google.common.base.Strings; import lombok.RequiredArgsConstructor; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRuleDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRuleDao.java index ac649c2db4..48a7df0f3b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRuleDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRuleDao.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.sql.notification; -import com.google.common.base.Strings; import lombok.RequiredArgsConstructor; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTargetDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTargetDao.java index ce98a46f52..cb2a577441 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTargetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTargetDao.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.sql.notification; -import com.google.common.base.Strings; import lombok.RequiredArgsConstructor; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTemplateDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTemplateDao.java index 8950ab1e35..f0ca725951 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTemplateDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTemplateDao.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.sql.notification; -import com.google.common.base.Strings; import lombok.RequiredArgsConstructor; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/NotificationRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/NotificationRepository.java index dc432a566e..38bcff442c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/NotificationRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/NotificationRepository.java @@ -25,8 +25,10 @@ import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationStatus; +import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.dao.model.sql.NotificationEntity; +import java.util.Set; import java.util.UUID; @Repository @@ -42,7 +44,20 @@ public interface NotificationRepository extends JpaRepository :status " + + "AND (n.type IN :types) " + + "AND (:searchText is NULL OR ilike(n.subject, concat('%', :searchText, '%')) = true " + + "OR ilike(n.text, concat('%', :searchText, '%')) = true)") + Page findByDeliveryMethodAndRecipientIdAndTypeInAndStatusNot(@Param("deliveryMethod") NotificationDeliveryMethod deliveryMethod, + @Param("recipientId") UUID recipientId, + @Param("types") Set types, + @Param("status") NotificationStatus status, + @Param("searchText") String searchText, + Pageable pageable); + + @Query("SELECT n FROM NotificationEntity n WHERE n.deliveryMethod = :deliveryMethod " + + "AND n.recipientId = :recipientId " + "AND (:searchText is NULL OR ilike(n.subject, concat('%', :searchText, '%')) = true " + "OR ilike(n.text, concat('%', :searchText, '%')) = true)") Page findByDeliveryMethodAndRecipientId(@Param("deliveryMethod") NotificationDeliveryMethod deliveryMethod, diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageInfoDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageInfoDao.java index 10833b11df..707a6e7062 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageInfoDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageInfoDao.java @@ -33,7 +33,6 @@ import org.thingsboard.server.dao.ota.OtaPackageInfoDao; import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; -import java.util.Objects; import java.util.UUID; @Slf4j diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmDataAdapter.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmDataAdapter.java index 05afe8b6d3..ed3bf14971 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmDataAdapter.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmDataAdapter.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.sql.query; -import com.fasterxml.jackson.core.JsonProcessingException; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.EntityType; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/queue/JpaQueueDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/queue/JpaQueueDao.java index 799d1ff521..e4563b7d35 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/queue/JpaQueueDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/queue/JpaQueueDao.java @@ -33,7 +33,6 @@ import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; import java.util.List; -import java.util.Objects; import java.util.UUID; @Slf4j diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java index 96cef3e909..0e726f8917 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java @@ -18,11 +18,11 @@ package org.thingsboard.server.dao.sql.relation; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.dao.ConcurrencyFailureException; -import org.springframework.dao.DataAccessException; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.RelationTypeGroup; @@ -36,11 +36,19 @@ import org.thingsboard.server.dao.util.SqlDao; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; +import static org.thingsboard.server.dao.model.ModelConstants.RELATION_FROM_ID_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.RELATION_FROM_TYPE_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TO_ID_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TO_TYPE_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TYPE_GROUP_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TYPE_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.VERSION_COLUMN; + /** * Created by Valerii Sosliuk on 5/29/2017. */ @@ -50,6 +58,8 @@ import java.util.stream.Collectors; public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService implements RelationDao { private static final List ALL_TYPE_GROUP_NAMES = new ArrayList<>(); + private static final String RETURNING = "RETURNING from_id, from_type, to_id, to_type, relation_type, relation_type_group, nextval('relation_version_seq') as version"; + private static final String DELETE_QUERY = "DELETE FROM relation WHERE from_id = ? AND from_type = ? AND to_id = ? AND to_type = ? AND relation_type = ? AND relation_type_group = ? " + RETURNING; static { Arrays.stream(RelationTypeGroup.values()).map(RelationTypeGroup::name).forEach(ALL_TYPE_GROUP_NAMES::add); @@ -144,107 +154,138 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple } @Override - public boolean saveRelation(TenantId tenantId, EntityRelation relation) { - return relationInsertRepository.saveOrUpdate(new RelationEntity(relation)) != null; + public EntityRelation saveRelation(TenantId tenantId, EntityRelation relation) { + return DaoUtil.getData(relationInsertRepository.saveOrUpdate(new RelationEntity(relation))); } @Override - public void saveRelations(TenantId tenantId, Collection relations) { + public List saveRelations(TenantId tenantId, List relations) { List entities = relations.stream().map(RelationEntity::new).collect(Collectors.toList()); - relationInsertRepository.saveOrUpdate(entities); + return DaoUtil.convertDataList(relationInsertRepository.saveOrUpdate(entities)); } @Override - public ListenableFuture saveRelationAsync(TenantId tenantId, EntityRelation relation) { - return service.submit(() -> relationInsertRepository.saveOrUpdate(new RelationEntity(relation)) != null); + public ListenableFuture saveRelationAsync(TenantId tenantId, EntityRelation relation) { + return service.submit(() -> DaoUtil.getData(relationInsertRepository.saveOrUpdate(new RelationEntity(relation)))); } @Override - public boolean deleteRelation(TenantId tenantId, EntityRelation relation) { + public EntityRelation deleteRelation(TenantId tenantId, EntityRelation relation) { RelationCompositeKey key = new RelationCompositeKey(relation); return deleteRelationIfExists(key); } @Override - public ListenableFuture deleteRelationAsync(TenantId tenantId, EntityRelation relation) { + public ListenableFuture deleteRelationAsync(TenantId tenantId, EntityRelation relation) { RelationCompositeKey key = new RelationCompositeKey(relation); return service.submit( () -> deleteRelationIfExists(key)); } @Override - public boolean deleteRelation(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup) { + public EntityRelation deleteRelation(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup) { RelationCompositeKey key = getRelationCompositeKey(from, to, relationType, typeGroup); return deleteRelationIfExists(key); } @Override - public ListenableFuture deleteRelationAsync(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup) { + public ListenableFuture deleteRelationAsync(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup) { RelationCompositeKey key = getRelationCompositeKey(from, to, relationType, typeGroup); return service.submit( () -> deleteRelationIfExists(key)); } - private boolean deleteRelationIfExists(RelationCompositeKey key) { - boolean relationExistsBeforeDelete = relationRepository.existsById(key); - if (relationExistsBeforeDelete) { - try { - relationRepository.deleteById(key); - } catch (DataAccessException e) { - log.debug("[{}] Concurrency exception while deleting relation", key, e); + private EntityRelation deleteRelationIfExists(RelationCompositeKey key) { + return jdbcTemplate.query(DELETE_QUERY, rs -> { + if (!rs.next()) { + return null; } - } - return relationExistsBeforeDelete; + EntityRelation relation = new EntityRelation(); + + var fromId = rs.getObject(RELATION_FROM_ID_PROPERTY, UUID.class); + var fromType = rs.getString(RELATION_FROM_TYPE_PROPERTY); + var toId = rs.getObject(RELATION_TO_ID_PROPERTY, UUID.class); + var toType = rs.getString(RELATION_TO_TYPE_PROPERTY); + var relationTypeGroup = rs.getString(RELATION_TYPE_GROUP_PROPERTY); + var relationType = rs.getString(RELATION_TYPE_PROPERTY); + var version = rs.getLong(VERSION_COLUMN); + + //additionalInfo ignored (no need to send extra data for delete events) + + relation.setTo(EntityIdFactory.getByTypeAndUuid(toType, toId)); + relation.setFrom(EntityIdFactory.getByTypeAndUuid(fromType, fromId)); + relation.setType(relationType); + relation.setTypeGroup(RelationTypeGroup.valueOf(relationTypeGroup)); + relation.setVersion(version); + return relation; + }, key.getFromId(), key.getFromType(), key.getToId(), key.getToType(), key.getRelationType(), key.getRelationTypeGroup()); } @Override - public void deleteOutboundRelations(TenantId tenantId, EntityId entity) { - try { - relationRepository.deleteByFromIdAndFromType(entity.getId(), entity.getEntityType().name()); - } catch (ConcurrencyFailureException e) { - log.debug("Concurrency exception while deleting relations [{}]", entity, e); - } + public List deleteOutboundRelations(TenantId tenantId, EntityId entity) { + return deleteRelations(entity, null, false); } @Override - public void deleteOutboundRelations(TenantId tenantId, EntityId entity, RelationTypeGroup relationTypeGroup) { - try { - relationRepository.deleteByFromIdAndFromTypeAndRelationTypeGroupIn(entity.getId(), entity.getEntityType().name(), Collections.singletonList(relationTypeGroup.name())); - } catch (ConcurrencyFailureException e) { - log.debug("Concurrency exception while deleting relations [{}]", entity, e); - } + public List deleteOutboundRelations(TenantId tenantId, EntityId entity, RelationTypeGroup relationTypeGroup) { + return deleteRelations(entity, Collections.singletonList(relationTypeGroup.name()), false); } @Override - public void deleteInboundRelations(TenantId tenantId, EntityId entity) { - try { - relationRepository.deleteByToIdAndToTypeAndRelationTypeGroupIn(entity.getId(), entity.getEntityType().name(), ALL_TYPE_GROUP_NAMES); - } catch (ConcurrencyFailureException e) { - log.debug("Concurrency exception while deleting relations [{}]", entity, e); - } + public List deleteInboundRelations(TenantId tenantId, EntityId entity) { + return deleteRelations(entity, ALL_TYPE_GROUP_NAMES, true); } @Override - public void deleteInboundRelations(TenantId tenantId, EntityId entity, RelationTypeGroup relationTypeGroup) { - try { - relationRepository.deleteByToIdAndToTypeAndRelationTypeGroupIn(entity.getId(), entity.getEntityType().name(), Collections.singletonList(relationTypeGroup.name())); - } catch (ConcurrencyFailureException e) { - log.debug("Concurrency exception while deleting relations [{}]", entity, e); - } + public List deleteInboundRelations(TenantId tenantId, EntityId entity, RelationTypeGroup relationTypeGroup) { + return deleteRelations(entity, Collections.singletonList(relationTypeGroup.name()), true); } - @Override - public ListenableFuture deleteOutboundRelationsAsync(TenantId tenantId, EntityId entity) { - return service.submit( - () -> { - boolean relationExistsBeforeDelete = relationRepository - .findAllByFromIdAndFromType(entity.getId(), entity.getEntityType().name()) - .size() > 0; - if (relationExistsBeforeDelete) { - relationRepository.deleteByFromIdAndFromType(entity.getId(), entity.getEntityType().name()); - } - return relationExistsBeforeDelete; - }); + private List deleteRelations(EntityId entityId, List relationTypeGroups, boolean inbound) { + List params = new ArrayList<>(); + params.add(entityId.getId()); + params.add(entityId.getEntityType().name()); + + StringBuilder sqlBuilder = new StringBuilder("DELETE FROM relation WHERE "); + if (inbound) { + sqlBuilder.append("to_id = ? AND to_type = ? "); + } else { + sqlBuilder.append("from_id = ? AND from_type = ? "); + } + + if (!CollectionUtils.isEmpty(relationTypeGroups)) { + sqlBuilder.append("AND relation_type_group IN (?"); + for (int i = 1; i < relationTypeGroups.size(); i++) { + sqlBuilder.append(", ?"); + } + sqlBuilder.append(")"); + params.addAll(relationTypeGroups); + } + + sqlBuilder.append(RETURNING); + + return jdbcTemplate.queryForList(sqlBuilder.toString(), params.toArray()).stream() + .map(row -> { + EntityRelation relation = new EntityRelation(); + + var fromId = row.get(RELATION_FROM_ID_PROPERTY); + var fromType = row.get(RELATION_FROM_TYPE_PROPERTY); + var toId = row.get(RELATION_TO_ID_PROPERTY); + var toType = row.get(RELATION_TO_TYPE_PROPERTY); + var relationTypeGroup = row.get(RELATION_TYPE_GROUP_PROPERTY); + var relationType = row.get(RELATION_TYPE_PROPERTY); + var version = row.get(VERSION_COLUMN); + + //additionalInfo ignored (no need to send extra data for delete events) + + relation.setTo(EntityIdFactory.getByTypeAndUuid((String) toType, (UUID) toId)); + relation.setFrom(EntityIdFactory.getByTypeAndUuid((String) fromType, (UUID) fromId)); + relation.setType((String) relationType); + relation.setTypeGroup(RelationTypeGroup.valueOf((String) relationTypeGroup)); + relation.setVersion((Long) version); + return relation; + }) + .collect(Collectors.toList()); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationInsertRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationInsertRepository.java index 188d8afa0f..56cb5718d5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationInsertRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationInsertRepository.java @@ -23,6 +23,6 @@ public interface RelationInsertRepository { RelationEntity saveOrUpdate(RelationEntity entity); - void saveOrUpdate(List entities); + List saveOrUpdate(List entities); } \ No newline at end of file diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java index c812788ed0..0f56a413df 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java @@ -58,9 +58,6 @@ public interface RelationRepository String relationType, String relationTypeGroup); - List findAllByFromIdAndFromType(UUID fromId, - String fromType); - @Query("SELECT r FROM RelationEntity r WHERE " + "r.relationTypeGroup = 'RULE_NODE' AND r.toType = 'RULE_CHAIN' " + "AND r.toId in (SELECT id from RuleChainEntity where type = :ruleChainType )") diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/SqlRelationInsertRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/SqlRelationInsertRepository.java index 7655ff5539..f2d374641e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/SqlRelationInsertRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/SqlRelationInsertRepository.java @@ -15,33 +15,39 @@ */ package org.thingsboard.server.dao.sql.relation; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.BatchPreparedStatementSetter; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.PreparedStatementCreator; +import org.springframework.jdbc.core.SqlProvider; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.dao.model.sql.RelationEntity; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.persistence.Query; +import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; import java.util.List; +import static org.thingsboard.server.dao.model.ModelConstants.VERSION_COLUMN; + @Repository @Transactional public class SqlRelationInsertRepository implements RelationInsertRepository { - private static final String INSERT_ON_CONFLICT_DO_UPDATE_JPA = "INSERT INTO relation (from_id, from_type, to_id, to_type, relation_type_group, relation_type, additional_info)" + - " VALUES (:fromId, :fromType, :toId, :toType, :relationTypeGroup, :relationType, :additionalInfo) " + - "ON CONFLICT (from_id, from_type, relation_type_group, relation_type, to_id, to_type) DO UPDATE SET additional_info = :additionalInfo returning *"; - - private static final String INSERT_ON_CONFLICT_DO_UPDATE_JDBC = "INSERT INTO relation (from_id, from_type, to_id, to_type, relation_type_group, relation_type, additional_info)" + - " VALUES (?, ?, ?, ?, ?, ?, ?) " + - "ON CONFLICT (from_id, from_type, relation_type_group, relation_type, to_id, to_type) DO UPDATE SET additional_info = ?"; + private static final String INSERT_ON_CONFLICT_DO_UPDATE_JPA = "INSERT INTO relation (from_id, from_type, to_id, to_type, relation_type_group, relation_type, version, additional_info)" + + " VALUES (:fromId, :fromType, :toId, :toType, :relationTypeGroup, :relationType, nextval('relation_version_seq'), :additionalInfo) " + + "ON CONFLICT (from_id, from_type, relation_type_group, relation_type, to_id, to_type) DO UPDATE SET additional_info = :additionalInfo, version = nextval('relation_version_seq') returning *"; + private static final String INSERT_ON_CONFLICT_DO_UPDATE_JDBC = "INSERT INTO relation (from_id, from_type, to_id, to_type, relation_type_group, relation_type, version, additional_info)" + + " VALUES (?, ?, ?, ?, ?, ?, nextval('relation_version_seq'), ?) " + + "ON CONFLICT (from_id, from_type, relation_type_group, relation_type, to_id, to_type) DO UPDATE SET additional_info = ?, version = nextval('relation_version_seq')"; @PersistenceContext protected EntityManager entityManager; @@ -71,8 +77,9 @@ public class SqlRelationInsertRepository implements RelationInsertRepository { } @Override - public void saveOrUpdate(List entities) { - jdbcTemplate.batchUpdate(INSERT_ON_CONFLICT_DO_UPDATE_JDBC, new BatchPreparedStatementSetter() { + public List saveOrUpdate(List entities) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.batchUpdate(new SequencePreparedStatementCreator(INSERT_ON_CONFLICT_DO_UPDATE_JDBC), new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement ps, int i) throws SQLException { RelationEntity relation = entities.get(i); @@ -98,7 +105,30 @@ public class SqlRelationInsertRepository implements RelationInsertRepository { public int getBatchSize() { return entities.size(); } - }); + }, keyHolder); + + var seqNumbers = keyHolder.getKeyList(); + + for (int i = 0; i < entities.size(); i++) { + entities.get(i).setVersion((Long) seqNumbers.get(i).get(VERSION_COLUMN)); + } + + return entities; + } + + private record SequencePreparedStatementCreator(String sql) implements PreparedStatementCreator, SqlProvider { + + private static final String[] COLUMNS = {VERSION_COLUMN}; + + @Override + public PreparedStatement createPreparedStatement(Connection con) throws SQLException { + return con.prepareStatement(sql, COLUMNS); + } + + @Override + public String getSql() { + return this.sql; + } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java index cb2ab5aa42..f8927aaa81 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java @@ -19,6 +19,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.id.TbResourceId; @@ -68,11 +69,13 @@ public class JpaTbResourceDao extends JpaAbstractDao findResourcesByTenantIdAndResourceType(TenantId tenantId, ResourceType resourceType, + ResourceSubType resourceSubType, PageLink pageLink) { return DaoUtil.toPageData(resourceRepository.findResourcesPage( tenantId.getId(), TenantId.SYS_TENANT_ID.getId(), resourceType.name(), + resourceSubType != null ? resourceSubType.name() : null, pageLink.getTextSearch(), DaoUtil.toPageable(pageLink) )); @@ -80,6 +83,7 @@ public class JpaTbResourceDao extends JpaAbstractDao findResourcesByTenantIdAndResourceType(TenantId tenantId, ResourceType resourceType, + ResourceSubType resourceSubType, String[] objectIds, String searchText) { return objectIds == null ? @@ -87,6 +91,7 @@ public class JpaTbResourceDao extends JpaAbstractDao resourceSubTypes = filter.getResourceSubTypes(); return DaoUtil.toPageData(resourceInfoRepository .findAllTenantResourcesByTenantId( filter.getTenantId().getId(), TenantId.NULL_UUID, resourceTypes.stream().map(Enum::name).collect(Collectors.toList()), + CollectionsUtil.isEmpty(resourceSubTypes) ? null : + resourceSubTypes.stream().map(Enum::name).collect(Collectors.toList()), Objects.toString(pageLink.getTextSearch(), ""), DaoUtil.toPageable(pageLink))); } @@ -77,10 +81,13 @@ public class JpaTbResourceInfoDao extends JpaAbstractDao resourceSubTypes = filter.getResourceSubTypes(); return DaoUtil.toPageData(resourceInfoRepository .findTenantResourcesByTenantId( filter.getTenantId().getId(), resourceTypes.stream().map(Enum::name).collect(Collectors.toList()), + CollectionsUtil.isEmpty(resourceSubTypes) ? null : + resourceSubTypes.stream().map(Enum::name).collect(Collectors.toList()), pageLink.getTextSearch(), DaoUtil.toPageable(pageLink))); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java index c092ea3292..00c51b50a2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java @@ -37,19 +37,23 @@ public interface TbResourceInfoRepository extends JpaRepository findAllTenantResourcesByTenantId(@Param("tenantId") UUID tenantId, @Param("systemTenantId") UUID systemTenantId, @Param("resourceTypes") List resourceTypes, + @Param("resourceSubTypes") List resourceSubTypes, @Param("searchText") String searchText, Pageable pageable); @Query("SELECT ri FROM TbResourceInfoEntity ri WHERE " + "ri.tenantId = :tenantId " + "AND ri.resourceType IN :resourceTypes " + + "AND (:resourceSubTypes IS NULL OR ri.resourceSubType IN :resourceSubTypes) " + "AND (:searchText IS NULL OR ilike(ri.title, CONCAT('%', :searchText, '%')) = true)") Page findTenantResourcesByTenantId(@Param("tenantId") UUID tenantId, @Param("resourceTypes") List resourceTypes, + @Param("resourceSubTypes") List resourceSubTypes, @Param("searchText") String searchText, Pageable pageable); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java index f5a831ff64..ce2e6b890b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java @@ -34,6 +34,7 @@ public interface TbResourceRepository extends JpaRepository findResources(@Param("tenantId") UUID tenantId, @Param("systemAdminId") UUID sysAdminId, @Param("resourceType") String resourceType, + @Param("resourceSubType") String resourceSubType, @Param("searchText") String searchText); @Query("SELECT tr FROM TbResourceEntity tr " + diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java index 46232c5bbb..eaf151f256 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java @@ -33,7 +33,6 @@ import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; import java.util.Collection; -import java.util.Objects; import java.util.Optional; import java.util.UUID; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/JpaTenantDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/JpaTenantDao.java index 882c72d430..cea414a42f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/JpaTenantDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/JpaTenantDao.java @@ -33,7 +33,6 @@ import org.thingsboard.server.dao.tenant.TenantDao; import org.thingsboard.server.dao.util.SqlDao; import java.util.List; -import java.util.Objects; import java.util.UUID; import java.util.stream.Collectors; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java index 0adfe1f2be..6fb4bcb85d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.DeprecatedFilter; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetTypeFilter; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.common.data.widget.WidgetsBundleWidget; import org.thingsboard.server.dao.DaoUtil; @@ -84,57 +85,60 @@ public class JpaWidgetTypeDao extends JpaAbstractDao findSystemWidgetTypes(TenantId tenantId, boolean fullSearch, DeprecatedFilter deprecatedFilter, List widgetTypes, PageLink pageLink) { - boolean deprecatedFilterEnabled = !DeprecatedFilter.ALL.equals(deprecatedFilter); - boolean deprecatedFilterBool = DeprecatedFilter.DEPRECATED.equals(deprecatedFilter); - boolean widgetTypesEmpty = widgetTypes == null || widgetTypes.isEmpty(); + public PageData findSystemWidgetTypes(WidgetTypeFilter widgetTypeFilter, PageLink pageLink) { + boolean deprecatedFilterEnabled = !DeprecatedFilter.ALL.equals(widgetTypeFilter.getDeprecatedFilter()); + boolean deprecatedFilterBool = DeprecatedFilter.DEPRECATED.equals(widgetTypeFilter.getDeprecatedFilter()); + boolean widgetTypesEmpty = widgetTypeFilter.getWidgetTypes() == null || widgetTypeFilter.getWidgetTypes().isEmpty(); return DaoUtil.toPageData( widgetTypeInfoRepository .findSystemWidgetTypes( NULL_UUID, pageLink.getTextSearch(), - fullSearch, + widgetTypeFilter.isFullSearch(), deprecatedFilterEnabled, deprecatedFilterBool, widgetTypesEmpty, - widgetTypes == null ? Collections.emptyList() : widgetTypes, + widgetTypeFilter.getWidgetTypes() == null ? Collections.emptyList() : widgetTypeFilter.getWidgetTypes(), + widgetTypeFilter.isScadaFirst(), DaoUtil.toPageable(pageLink, WidgetTypeInfoEntity.SEARCH_COLUMNS_MAP))); } @Override - public PageData findAllTenantWidgetTypesByTenantId(UUID tenantId, boolean fullSearch, DeprecatedFilter deprecatedFilter, List widgetTypes, PageLink pageLink) { - boolean deprecatedFilterEnabled = !DeprecatedFilter.ALL.equals(deprecatedFilter); - boolean deprecatedFilterBool = DeprecatedFilter.DEPRECATED.equals(deprecatedFilter); - boolean widgetTypesEmpty = widgetTypes == null || widgetTypes.isEmpty(); + public PageData findAllTenantWidgetTypesByTenantId(WidgetTypeFilter widgetTypeFilter, PageLink pageLink) { + boolean deprecatedFilterEnabled = !DeprecatedFilter.ALL.equals(widgetTypeFilter.getDeprecatedFilter()); + boolean deprecatedFilterBool = DeprecatedFilter.DEPRECATED.equals(widgetTypeFilter.getDeprecatedFilter()); + boolean widgetTypesEmpty = widgetTypeFilter.getWidgetTypes() == null || widgetTypeFilter.getWidgetTypes().isEmpty(); return DaoUtil.toPageData( widgetTypeInfoRepository .findAllTenantWidgetTypesByTenantId( - tenantId, + widgetTypeFilter.getTenantId().getId(), NULL_UUID, pageLink.getTextSearch(), - fullSearch, + widgetTypeFilter.isFullSearch(), deprecatedFilterEnabled, deprecatedFilterBool, widgetTypesEmpty, - widgetTypes == null ? Collections.emptyList() : widgetTypes, + widgetTypeFilter.getWidgetTypes() == null ? Collections.emptyList() : widgetTypeFilter.getWidgetTypes(), + widgetTypeFilter.isScadaFirst(), DaoUtil.toPageable(pageLink, WidgetTypeInfoEntity.SEARCH_COLUMNS_MAP))); } @Override - public PageData findTenantWidgetTypesByTenantId(UUID tenantId, boolean fullSearch, DeprecatedFilter deprecatedFilter, List widgetTypes, PageLink pageLink) { - boolean deprecatedFilterEnabled = !DeprecatedFilter.ALL.equals(deprecatedFilter); - boolean deprecatedFilterBool = DeprecatedFilter.DEPRECATED.equals(deprecatedFilter); - boolean widgetTypesEmpty = widgetTypes == null || widgetTypes.isEmpty(); + public PageData findTenantWidgetTypesByTenantId(WidgetTypeFilter widgetTypeFilter, PageLink pageLink) { + boolean deprecatedFilterEnabled = !DeprecatedFilter.ALL.equals(widgetTypeFilter.getDeprecatedFilter()); + boolean deprecatedFilterBool = DeprecatedFilter.DEPRECATED.equals(widgetTypeFilter.getDeprecatedFilter()); + boolean widgetTypesEmpty = widgetTypeFilter.getWidgetTypes() == null || widgetTypeFilter.getWidgetTypes().isEmpty(); return DaoUtil.toPageData( widgetTypeInfoRepository .findTenantWidgetTypesByTenantId( - tenantId, + widgetTypeFilter.getTenantId().getId(), pageLink.getTextSearch(), - fullSearch, + widgetTypeFilter.isFullSearch(), deprecatedFilterEnabled, deprecatedFilterBool, widgetTypesEmpty, - widgetTypes == null ? Collections.emptyList() : widgetTypes, + widgetTypeFilter.getWidgetTypes() == null ? Collections.emptyList() : widgetTypeFilter.getWidgetTypes(), + widgetTypeFilter.isScadaFirst(), DaoUtil.toPageable(pageLink, WidgetTypeInfoEntity.SEARCH_COLUMNS_MAP))); } @@ -176,6 +180,11 @@ public class JpaWidgetTypeDao extends JpaAbstractDao findWidgetTypesInfosByTenantIdAndResourceId(UUID tenantId, UUID tbResourceId) { return DaoUtil.convertDataList(widgetTypeRepository.findWidgetTypesInfosByTenantIdAndResourceId(tenantId, tbResourceId)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetsBundleDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetsBundleDao.java index 668eb0bcbf..290e949c38 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetsBundleDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetsBundleDao.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.id.WidgetsBundleId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.common.data.widget.WidgetsBundleFilter; import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.model.sql.WidgetsBundleEntity; import org.thingsboard.server.dao.sql.JpaAbstractDao; @@ -64,8 +65,8 @@ public class JpaWidgetsBundleDao extends JpaAbstractDao findSystemWidgetsBundles(TenantId tenantId, boolean fullSearch, PageLink pageLink) { - if (fullSearch) { + public PageData findSystemWidgetsBundles(WidgetsBundleFilter widgetsBundleFilter, PageLink pageLink) { + if (widgetsBundleFilter.isFullSearch()) { return DaoUtil.toPageData( widgetsBundleRepository .findSystemWidgetsBundlesFullSearch( @@ -93,13 +94,13 @@ public class JpaWidgetsBundleDao extends JpaAbstractDao findAllTenantWidgetsBundlesByTenantId(UUID tenantId, boolean fullSearch, PageLink pageLink) { - return findTenantWidgetsBundlesByTenantIds(Arrays.asList(tenantId, NULL_UUID), fullSearch, pageLink); + public PageData findAllTenantWidgetsBundlesByTenantId(WidgetsBundleFilter widgetsBundleFilter, PageLink pageLink) { + return findTenantWidgetsBundlesByTenantIds(Arrays.asList(widgetsBundleFilter.getTenantId().getId(), NULL_UUID), widgetsBundleFilter, pageLink); } @Override - public PageData findTenantWidgetsBundlesByTenantId(UUID tenantId, boolean fullSearch, PageLink pageLink) { - return findTenantWidgetsBundlesByTenantIds(Collections.singletonList(tenantId), fullSearch, pageLink); + public PageData findTenantWidgetsBundlesByTenantId(WidgetsBundleFilter widgetsBundleFilter, PageLink pageLink) { + return findTenantWidgetsBundlesByTenantIds(Collections.singletonList(widgetsBundleFilter.getTenantId().getId()), widgetsBundleFilter, pageLink); } @Override @@ -107,13 +108,14 @@ public class JpaWidgetsBundleDao extends JpaAbstractDao findTenantWidgetsBundlesByTenantIds(List tenantIds, boolean fullSearch, PageLink pageLink) { - if (fullSearch) { + private PageData findTenantWidgetsBundlesByTenantIds(List tenantIds, WidgetsBundleFilter widgetsBundleFilter, PageLink pageLink) { + if (widgetsBundleFilter.isFullSearch()) { return DaoUtil.toPageData( widgetsBundleRepository .findAllTenantWidgetsBundlesByTenantIdsFullSearch( tenantIds, pageLink.getTextSearch(), + widgetsBundleFilter.isScadaFirst(), DaoUtil.toPageable(pageLink))); } else { return DaoUtil.toPageData( diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java index 85079bb7a8..4b85fcb7dc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java @@ -41,7 +41,8 @@ public interface WidgetTypeInfoRepository extends JpaRepository widgetTypes, + @Param("scadaFirst") boolean scadaFirst, Pageable pageable); @Query(nativeQuery = true, @@ -80,7 +82,8 @@ public interface WidgetTypeInfoRepository extends JpaRepository widgetTypes, + @Param("scadaFirst") boolean scadaFirst, Pageable pageable); @Query(nativeQuery = true, @@ -120,7 +124,8 @@ public interface WidgetTypeInfoRepository extends JpaRepository widgetTypes, + @Param("scadaFirst") boolean scadaFirst, Pageable pageable); @Query("SELECT wti FROM WidgetTypeInfoEntity wti, WidgetsBundleWidgetEntity wbw " + diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeRepository.java index 396e82561c..a87a96ac36 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeRepository.java @@ -67,6 +67,8 @@ public interface WidgetTypeRepository extends JpaRepository> 'resources' LIKE LOWER(CONCAT('%', :resourceId, '%'))", nativeQuery = true) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetsBundleRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetsBundleRepository.java index 78dd1ef4bf..8de78986ae 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetsBundleRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetsBundleRepository.java @@ -21,7 +21,6 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.thingsboard.server.dao.ExportableEntityRepository; -import org.thingsboard.server.dao.model.sql.WidgetTypeInfoEntity; import org.thingsboard.server.dao.model.sql.WidgetsBundleEntity; import java.util.List; @@ -107,7 +106,7 @@ public interface WidgetsBundleRepository extends JpaRepository findAllTenantWidgetsBundlesByTenantIdsFullSearch(@Param("tenantIds") List tenantIds, - @Param("textSearch") String textSearch, - Pageable pageable); + @Param("textSearch") String textSearch, + @Param("scadaFirst") boolean scadaFirst, + Pageable pageable); WidgetsBundleEntity findFirstByTenantIdAndTitle(UUID tenantId, String title); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDao.java index 9a52f3a192..f3b7b96c87 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDao.java @@ -60,7 +60,7 @@ public abstract class AbstractChunkedAggregationTimeseriesDao extends AbstractSq @Autowired protected InsertTsRepository insertRepository; - protected TbSqlBlockingQueueWrapper tsQueue; + protected TbSqlBlockingQueueWrapper tsQueue; @Autowired private StatsFactory statsFactory; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractSqlTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractSqlTimeseriesDao.java index b9694fbff7..18364c6b1b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractSqlTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractSqlTimeseriesDao.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.sqlts; import com.google.common.base.Function; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import jakarta.annotation.Nullable; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -29,7 +30,6 @@ import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.sql.ScheduledLogExecutorComponent; -import jakarta.annotation.Nullable; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/BaseAbstractSqlTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/BaseAbstractSqlTimeseriesDao.java index 91be4e0e2e..c7ebbd4964 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/BaseAbstractSqlTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/BaseAbstractSqlTimeseriesDao.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.sqlts; import com.google.common.base.Function; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import jakarta.annotation.Nullable; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult; @@ -25,7 +26,6 @@ import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.model.sql.AbstractTsKvEntity; import org.thingsboard.server.dao.sql.JpaAbstractDaoListeningExecutorService; -import jakarta.annotation.Nullable; import java.util.List; import java.util.Objects; import java.util.Optional; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java new file mode 100644 index 0000000000..0be078a89f --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java @@ -0,0 +1,170 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.sqlts; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; +import org.thingsboard.server.cache.TbCacheValueWrapper; +import org.thingsboard.server.cache.VersionedTbCache; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; +import org.thingsboard.server.common.stats.DefaultCounter; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.dao.cache.CacheExecutorService; +import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao; +import org.thingsboard.server.dao.timeseries.TsLatestCacheKey; +import org.thingsboard.server.dao.util.SqlTsLatestAnyDaoCachedRedis; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@Component +@SqlTsLatestAnyDaoCachedRedis +@RequiredArgsConstructor +@Primary +public class CachedRedisSqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao implements TimeseriesLatestDao { + public static final String STATS_NAME = "ts_latest.cache"; + final CacheExecutorService cacheExecutorService; + final SqlTimeseriesLatestDao sqlDao; + final StatsFactory statsFactory; + final VersionedTbCache cache; + DefaultCounter hitCounter; + DefaultCounter missCounter; + + @PostConstruct + public void init() { + log.info("Init Redis cache-aside SQL Timeseries Latest DAO"); + this.hitCounter = statsFactory.createDefaultCounter(STATS_NAME, "result", "hit"); + this.missCounter = statsFactory.createDefaultCounter(STATS_NAME, "result", "miss"); + } + + @Override + public ListenableFuture saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { + ListenableFuture future = sqlDao.saveLatest(tenantId, entityId, tsKvEntry); + future = Futures.transform(future, version -> { + cache.put(new TsLatestCacheKey(entityId, tsKvEntry.getKey()), new BasicTsKvEntry(tsKvEntry.getTs(), ((BasicTsKvEntry) tsKvEntry).getKv(), version)); + return version; + }, + cacheExecutorService); + if (log.isTraceEnabled()) { + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(Long result) { + log.trace("saveLatest onSuccess [{}][{}][{}]", entityId, tsKvEntry.getKey(), tsKvEntry); + } + + @Override + public void onFailure(Throwable t) { + log.info("saveLatest onFailure [{}][{}][{}]", entityId, tsKvEntry.getKey(), tsKvEntry, t); + } + }, MoreExecutors.directExecutor()); + } + return future; + } + + @Override + public ListenableFuture removeLatest(TenantId tenantId, EntityId entityId, DeleteTsKvQuery query) { + ListenableFuture future = sqlDao.removeLatest(tenantId, entityId, query); + future = Futures.transform(future, x -> { + if (x.isRemoved()) { + TsLatestCacheKey key = new TsLatestCacheKey(entityId, query.getKey()); + Long version = x.getVersion(); + TsKvEntry newTsKvEntry = x.getData(); + if (newTsKvEntry != null) { + cache.put(key, new BasicTsKvEntry(newTsKvEntry.getTs(), ((BasicTsKvEntry) newTsKvEntry).getKv(), version)); + } else { + cache.evict(key, version); + } + } + return x; + }, + cacheExecutorService); + if (log.isTraceEnabled()) { + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(TsKvLatestRemovingResult result) { + log.trace("removeLatest onSuccess [{}][{}][{}]", entityId, query.getKey(), query); + } + + @Override + public void onFailure(Throwable t) { + log.info("removeLatest onFailure [{}][{}][{}]", entityId, query.getKey(), query, t); + } + }, MoreExecutors.directExecutor()); + } + return future; + } + + @Override + public ListenableFuture> findLatestOpt(TenantId tenantId, EntityId entityId, String key) { + log.trace("findLatestOpt"); + return doFindLatest(tenantId, entityId, key); + } + + @Override + public ListenableFuture findLatest(TenantId tenantId, EntityId entityId, String key) { + return Futures.transform(doFindLatest(tenantId, entityId, key), x -> sqlDao.wrapNullTsKvEntry(key, x.orElse(null)), MoreExecutors.directExecutor()); + } + + public ListenableFuture> doFindLatest(TenantId tenantId, EntityId entityId, String key) { + final TsLatestCacheKey cacheKey = new TsLatestCacheKey(entityId, key); + ListenableFuture> cacheFuture = cacheExecutorService.submit(() -> cache.get(cacheKey)); + + return Futures.transformAsync(cacheFuture, (cacheValueWrap) -> { + if (cacheValueWrap != null) { + final TsKvEntry tsKvEntry = cacheValueWrap.get(); + log.debug("findLatest cache hit [{}][{}][{}]", entityId, key, tsKvEntry); + return Futures.immediateFuture(Optional.ofNullable(tsKvEntry)); + } + log.debug("findLatest cache miss [{}][{}]", entityId, key); + ListenableFuture> daoFuture = sqlDao.findLatestOpt(tenantId, entityId, key); + + return Futures.transform(daoFuture, daoValue -> { + cache.put(cacheKey, daoValue.orElse(null)); + return daoValue; + }, MoreExecutors.directExecutor()); + }, MoreExecutors.directExecutor()); + } + + @Override + public ListenableFuture> findAllLatest(TenantId tenantId, EntityId entityId) { + return sqlDao.findAllLatest(tenantId, entityId); + } + + @Override + public List findAllKeysByDeviceProfileId(TenantId tenantId, DeviceProfileId deviceProfileId) { + return sqlDao.findAllKeysByDeviceProfileId(tenantId, deviceProfileId); + } + + @Override + public List findAllKeysByEntityIds(TenantId tenantId, List entityIds) { + return sqlDao.findAllKeysByEntityIds(tenantId, entityIds); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java index 425bb10a0e..44859c26e7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java @@ -46,6 +46,7 @@ import org.thingsboard.server.dao.model.sqlts.latest.TsKvLatestEntity; import org.thingsboard.server.dao.sql.ScheduledLogExecutorComponent; import org.thingsboard.server.dao.sql.TbSqlBlockingQueueParams; import org.thingsboard.server.dao.sql.TbSqlBlockingQueueWrapper; +import org.thingsboard.server.dao.sql.TbSqlQueueElement; import org.thingsboard.server.dao.sqlts.insert.latest.InsertLatestTsRepository; import org.thingsboard.server.dao.sqlts.latest.SearchTsKvLatestRepository; import org.thingsboard.server.dao.sqlts.latest.TsKvLatestRepository; @@ -81,7 +82,7 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme @Autowired private InsertLatestTsRepository insertLatestTsRepository; - private TbSqlBlockingQueueWrapper tsLatestQueue; + private TbSqlBlockingQueueWrapper tsLatestQueue; @Value("${sql.ts_latest.batch_size:1000}") private int tsLatestBatchSize; @@ -115,25 +116,26 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme .maxDelay(tsLatestMaxDelay) .statsPrintIntervalMs(tsLatestStatsPrintIntervalMs) .statsNamePrefix("ts.latest") - .batchSortEnabled(false) + .batchSortEnabled(batchSortEnabled) + .withResponse(true) .build(); java.util.function.Function hashcodeFunction = entity -> entity.getEntityId().hashCode(); tsLatestQueue = new TbSqlBlockingQueueWrapper<>(tsLatestParams, hashcodeFunction, tsLatestBatchThreads, statsFactory); - tsLatestQueue.init(logExecutor, v -> { - Map trueLatest = new HashMap<>(); - v.forEach(ts -> { - TsKey key = new TsKey(ts.getEntityId(), ts.getKey()); - trueLatest.merge(key, ts, (oldTs, newTs) -> oldTs.getTs() <= newTs.getTs() ? newTs : oldTs); - }); - List latestEntities = new ArrayList<>(trueLatest.values()); - if (batchSortEnabled) { - latestEntities.sort(Comparator.comparing((Function) AbstractTsKvEntity::getEntityId) - .thenComparingInt(AbstractTsKvEntity::getKey)); - } - insertLatestTsRepository.saveOrUpdate(latestEntities); - }, (l, r) -> 0); + tsLatestQueue.init(logExecutor, + v -> insertLatestTsRepository.saveOrUpdate(v), + Comparator.comparing((Function) AbstractTsKvEntity::getEntityId) + .thenComparingInt(AbstractTsKvEntity::getKey), + v -> { + Map> trueLatest = new HashMap<>(); + v.forEach(element -> { + var entity = element.getEntity(); + TsKey key = new TsKey(entity.getEntityId(), entity.getKey()); + trueLatest.merge(key, element, (oldElement, newElement) -> oldElement.getEntity().getTs() <= newElement.getEntity().getTs() ? newElement : oldElement); + }); + return new ArrayList<>(trueLatest.values()); + }); } @PreDestroy @@ -144,7 +146,7 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme } @Override - public ListenableFuture saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { + public ListenableFuture saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { return getSaveLatestFuture(entityId, tsKvEntry); } @@ -155,12 +157,13 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme @Override public ListenableFuture> findLatestOpt(TenantId tenantId, EntityId entityId, String key) { - return service.submit(() -> Optional.ofNullable(doFindLatest(entityId, key))); + return service.submit(() -> Optional.ofNullable(doFindLatestSync(entityId, key))); } @Override public ListenableFuture findLatest(TenantId tenantId, EntityId entityId, String key) { - return service.submit(() -> getLatestTsKvEntry(entityId, key)); + log.trace("findLatest [{}][{}][{}]", tenantId, entityId, key); + return service.submit(() -> wrapNullTsKvEntry(key, doFindLatestSync(entityId, key))); } @Override @@ -187,7 +190,7 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme return Futures.transformAsync(future, entryList -> { if (entryList.size() == 1) { TsKvEntry entry = entryList.get(0); - return Futures.transform(getSaveLatestFuture(entityId, entry), v -> new TsKvLatestRemovingResult(entry), MoreExecutors.directExecutor()); + return Futures.transform(getSaveLatestFuture(entityId, entry), v -> new TsKvLatestRemovingResult(entry, v), MoreExecutors.directExecutor()); } else { log.trace("Could not find new latest value for [{}], key - {}", entityId, query.getKey()); } @@ -204,7 +207,7 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme ReadTsKvQueryResult::getData, MoreExecutors.directExecutor()); } - protected TsKvEntry doFindLatest(EntityId entityId, String key) { + protected TsKvEntry doFindLatestSync(EntityId entityId, String key) { TsKvLatestCompositeKey compositeKey = new TsKvLatestCompositeKey( entityId.getId(), @@ -220,24 +223,24 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme } protected ListenableFuture getRemoveLatestFuture(TenantId tenantId, EntityId entityId, DeleteTsKvQuery query) { - ListenableFuture latestFuture = service.submit(() -> doFindLatest(entityId, query.getKey())); + ListenableFuture latestFuture = service.submit(() -> doFindLatestSync(entityId, query.getKey())); return Futures.transformAsync(latestFuture, latest -> { if (latest == null) { return Futures.immediateFuture(new TsKvLatestRemovingResult(query.getKey(), false)); } boolean isRemoved = false; + Long version = null; long ts = latest.getTs(); if (ts >= query.getStartTs() && ts < query.getEndTs()) { - TsKvLatestEntity latestEntity = new TsKvLatestEntity(); - latestEntity.setEntityId(entityId.getId()); - latestEntity.setKey(keyDictionaryDao.getOrSaveKeyId(query.getKey())); - tsKvLatestRepository.delete(latestEntity); + version = transactionTemplate.execute(status -> jdbcTemplate.query("DELETE FROM ts_kv_latest WHERE entity_id = ? " + + "AND key = ? RETURNING nextval('ts_kv_latest_version_seq')", + rs -> rs.next() ? rs.getLong(1) : null, entityId.getId(), keyDictionaryDao.getOrSaveKeyId(query.getKey()))); isRemoved = true; if (query.getRewriteLatestIfDeleted()) { return getNewLatestEntryFuture(tenantId, entityId, query); } } - return Futures.immediateFuture(new TsKvLatestRemovingResult(query.getKey(), isRemoved)); + return Futures.immediateFuture(new TsKvLatestRemovingResult(query.getKey(), isRemoved, version)); }, MoreExecutors.directExecutor()); } @@ -247,7 +250,7 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme searchTsKvLatestRepository.findAllByEntityId(entityId.getId())))); } - protected ListenableFuture getSaveLatestFuture(EntityId entityId, TsKvEntry tsKvEntry) { + protected ListenableFuture getSaveLatestFuture(EntityId entityId, TsKvEntry tsKvEntry) { TsKvLatestEntity latestEntity = new TsKvLatestEntity(); latestEntity.setEntityId(entityId.getId()); latestEntity.setTs(tsKvEntry.getTs()); @@ -261,10 +264,9 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme return tsLatestQueue.add(latestEntity); } - private TsKvEntry getLatestTsKvEntry(EntityId entityId, String key) { - TsKvEntry latest = doFindLatest(entityId, key); + protected TsKvEntry wrapNullTsKvEntry(final String key, final TsKvEntry latest) { if (latest == null) { - latest = new BasicTsKvEntry(System.currentTimeMillis(), new StringDataEntry(key, null)); + return new BasicTsKvEntry(System.currentTimeMillis(), new StringDataEntry(key, null)); } return latest; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/JpaKeyDictionaryDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/JpaKeyDictionaryDao.java index 870c0bc505..b01d1c4ea0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/JpaKeyDictionaryDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/JpaKeyDictionaryDao.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.dao.sqlts.dictionary; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.ConstraintViolationException; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.dao.dictionary.KeyDictionaryDao; import org.thingsboard.server.dao.model.sqlts.dictionary.KeyDictionaryCompositeKey; import org.thingsboard.server.dao.model.sqlts.dictionary.KeyDictionaryEntry; @@ -34,14 +36,15 @@ import java.util.concurrent.locks.ReentrantLock; @Component @Slf4j @SqlDao +@RequiredArgsConstructor public class JpaKeyDictionaryDao extends JpaAbstractDaoListeningExecutorService implements KeyDictionaryDao { - private final ConcurrentMap keyDictionaryMap = new ConcurrentHashMap<>(); - protected static final ReentrantLock creationLock = new ReentrantLock(); + private final KeyDictionaryRepository keyDictionaryRepository; - @Autowired - private KeyDictionaryRepository keyDictionaryRepository; + private final ConcurrentMap keyDictionaryMap = new ConcurrentHashMap<>(); + private static final ReentrantLock creationLock = new ReentrantLock(); + @Transactional(propagation = Propagation.NOT_SUPPORTED) @Override public Integer getOrSaveKeyId(String strKey) { Integer keyId = keyDictionaryMap.get(strKey); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/KeyDictionaryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/KeyDictionaryRepository.java index f61da09779..17e24ea5e5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/KeyDictionaryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/KeyDictionaryRepository.java @@ -16,8 +16,8 @@ package org.thingsboard.server.dao.sqlts.dictionary; import org.springframework.data.jpa.repository.JpaRepository; -import org.thingsboard.server.dao.model.sqlts.dictionary.KeyDictionaryEntry; import org.thingsboard.server.dao.model.sqlts.dictionary.KeyDictionaryCompositeKey; +import org.thingsboard.server.dao.model.sqlts.dictionary.KeyDictionaryEntry; import java.util.Optional; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/InsertLatestTsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/InsertLatestTsRepository.java index 0aa95fa324..d85b66a258 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/InsertLatestTsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/InsertLatestTsRepository.java @@ -21,6 +21,6 @@ import java.util.List; public interface InsertLatestTsRepository { - void saveOrUpdate(List entities); + List saveOrUpdate(List entities); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/sql/SqlLatestInsertTsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/sql/SqlLatestInsertTsRepository.java index 530ac7ce23..0a3ffad09f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/sql/SqlLatestInsertTsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/sql/SqlLatestInsertTsRepository.java @@ -15,14 +15,12 @@ */ package org.thingsboard.server.dao.sqlts.insert.latest.sql; +import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; -import org.springframework.jdbc.core.BatchPreparedStatementSetter; import org.springframework.stereotype.Repository; -import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionCallbackWithoutResult; +import org.thingsboard.server.dao.AbstractVersionedInsertRepository; import org.thingsboard.server.dao.model.sqlts.latest.TsKvLatestEntity; -import org.thingsboard.server.dao.sqlts.insert.AbstractInsertRepository; import org.thingsboard.server.dao.sqlts.insert.latest.InsertLatestTsRepository; import org.thingsboard.server.dao.util.SqlDao; import org.thingsboard.server.dao.util.SqlTsLatestAnyDao; @@ -30,143 +28,123 @@ import org.thingsboard.server.dao.util.SqlTsLatestAnyDao; import java.sql.PreparedStatement; import java.sql.SQLException; import java.sql.Types; -import java.util.ArrayList; import java.util.List; - @SqlTsLatestAnyDao @Repository @Transactional @SqlDao -public class SqlLatestInsertTsRepository extends AbstractInsertRepository implements InsertLatestTsRepository { +public class SqlLatestInsertTsRepository extends AbstractVersionedInsertRepository implements InsertLatestTsRepository { @Value("${sql.ts_latest.update_by_latest_ts:true}") private Boolean updateByLatestTs; private static final String BATCH_UPDATE = - "UPDATE ts_kv_latest SET ts = ?, bool_v = ?, str_v = ?, long_v = ?, dbl_v = ?, json_v = cast(? AS json) WHERE entity_id = ? AND key = ?"; + "UPDATE ts_kv_latest SET ts = ?, bool_v = ?, str_v = ?, long_v = ?, dbl_v = ?, json_v = cast(? AS json), version = nextval('ts_kv_latest_version_seq') WHERE entity_id = ? AND key = ?"; private static final String INSERT_OR_UPDATE = - "INSERT INTO ts_kv_latest (entity_id, key, ts, bool_v, str_v, long_v, dbl_v, json_v) VALUES(?, ?, ?, ?, ?, ?, ?, cast(? AS json)) " + - "ON CONFLICT (entity_id, key) DO UPDATE SET ts = ?, bool_v = ?, str_v = ?, long_v = ?, dbl_v = ?, json_v = cast(? AS json)"; + "INSERT INTO ts_kv_latest (entity_id, key, ts, bool_v, str_v, long_v, dbl_v, json_v, version) VALUES(?, ?, ?, ?, ?, ?, ?, cast(? AS json), nextval('ts_kv_latest_version_seq')) " + + "ON CONFLICT (entity_id, key) DO UPDATE SET ts = ?, bool_v = ?, str_v = ?, long_v = ?, dbl_v = ?, json_v = cast(? AS json), version = nextval('ts_kv_latest_version_seq')"; private static final String BATCH_UPDATE_BY_LATEST_TS = BATCH_UPDATE + " AND ts_kv_latest.ts <= ?"; private static final String INSERT_OR_UPDATE_BY_LATEST_TS = INSERT_OR_UPDATE + " WHERE ts_kv_latest.ts <= ?"; + private static final String RETURNING = " RETURNING version"; + + private String batchUpdateQuery; + private String insertOrUpdateQuery; + + @PostConstruct + private void init() { + this.batchUpdateQuery = (updateByLatestTs ? BATCH_UPDATE_BY_LATEST_TS : BATCH_UPDATE) + RETURNING; + this.insertOrUpdateQuery = (updateByLatestTs ? INSERT_OR_UPDATE_BY_LATEST_TS : INSERT_OR_UPDATE) + RETURNING; + } + + @Override + protected void setOnBatchUpdateValues(PreparedStatement ps, int i, List entities) throws SQLException { + TsKvLatestEntity tsKvLatestEntity = entities.get(i); + ps.setLong(1, tsKvLatestEntity.getTs()); + + if (tsKvLatestEntity.getBooleanValue() != null) { + ps.setBoolean(2, tsKvLatestEntity.getBooleanValue()); + } else { + ps.setNull(2, Types.BOOLEAN); + } + + ps.setString(3, replaceNullChars(tsKvLatestEntity.getStrValue())); + + if (tsKvLatestEntity.getLongValue() != null) { + ps.setLong(4, tsKvLatestEntity.getLongValue()); + } else { + ps.setNull(4, Types.BIGINT); + } + + if (tsKvLatestEntity.getDoubleValue() != null) { + ps.setDouble(5, tsKvLatestEntity.getDoubleValue()); + } else { + ps.setNull(5, Types.DOUBLE); + } + + ps.setString(6, replaceNullChars(tsKvLatestEntity.getJsonValue())); + + ps.setObject(7, tsKvLatestEntity.getEntityId()); + ps.setInt(8, tsKvLatestEntity.getKey()); + if (updateByLatestTs) { + ps.setLong(9, tsKvLatestEntity.getTs()); + } + } + + @Override + protected void setOnInsertOrUpdateValues(PreparedStatement ps, int i, List insertEntities) throws SQLException { + TsKvLatestEntity tsKvLatestEntity = insertEntities.get(i); + ps.setObject(1, tsKvLatestEntity.getEntityId()); + ps.setInt(2, tsKvLatestEntity.getKey()); + + ps.setLong(3, tsKvLatestEntity.getTs()); + ps.setLong(9, tsKvLatestEntity.getTs()); + if (updateByLatestTs) { + ps.setLong(15, tsKvLatestEntity.getTs()); + } + + if (tsKvLatestEntity.getBooleanValue() != null) { + ps.setBoolean(4, tsKvLatestEntity.getBooleanValue()); + ps.setBoolean(10, tsKvLatestEntity.getBooleanValue()); + } else { + ps.setNull(4, Types.BOOLEAN); + ps.setNull(10, Types.BOOLEAN); + } + + ps.setString(5, replaceNullChars(tsKvLatestEntity.getStrValue())); + ps.setString(11, replaceNullChars(tsKvLatestEntity.getStrValue())); + + if (tsKvLatestEntity.getLongValue() != null) { + ps.setLong(6, tsKvLatestEntity.getLongValue()); + ps.setLong(12, tsKvLatestEntity.getLongValue()); + } else { + ps.setNull(6, Types.BIGINT); + ps.setNull(12, Types.BIGINT); + } + + if (tsKvLatestEntity.getDoubleValue() != null) { + ps.setDouble(7, tsKvLatestEntity.getDoubleValue()); + ps.setDouble(13, tsKvLatestEntity.getDoubleValue()); + } else { + ps.setNull(7, Types.DOUBLE); + ps.setNull(13, Types.DOUBLE); + } + + ps.setString(8, replaceNullChars(tsKvLatestEntity.getJsonValue())); + ps.setString(14, replaceNullChars(tsKvLatestEntity.getJsonValue())); + } + + @Override + protected String getBatchUpdateQuery() { + return batchUpdateQuery; + } + @Override - public void saveOrUpdate(List entities) { - transactionTemplate.execute(new TransactionCallbackWithoutResult() { - @Override - protected void doInTransactionWithoutResult(TransactionStatus status) { - String batchUpdateQuery = updateByLatestTs ? BATCH_UPDATE_BY_LATEST_TS : BATCH_UPDATE; - String insertOrUpdateQuery = updateByLatestTs ? INSERT_OR_UPDATE_BY_LATEST_TS : INSERT_OR_UPDATE; - - int[] result = jdbcTemplate.batchUpdate(batchUpdateQuery, new BatchPreparedStatementSetter() { - @Override - public void setValues(PreparedStatement ps, int i) throws SQLException { - TsKvLatestEntity tsKvLatestEntity = entities.get(i); - ps.setLong(1, tsKvLatestEntity.getTs()); - - if (tsKvLatestEntity.getBooleanValue() != null) { - ps.setBoolean(2, tsKvLatestEntity.getBooleanValue()); - } else { - ps.setNull(2, Types.BOOLEAN); - } - - ps.setString(3, replaceNullChars(tsKvLatestEntity.getStrValue())); - - if (tsKvLatestEntity.getLongValue() != null) { - ps.setLong(4, tsKvLatestEntity.getLongValue()); - } else { - ps.setNull(4, Types.BIGINT); - } - - if (tsKvLatestEntity.getDoubleValue() != null) { - ps.setDouble(5, tsKvLatestEntity.getDoubleValue()); - } else { - ps.setNull(5, Types.DOUBLE); - } - - ps.setString(6, replaceNullChars(tsKvLatestEntity.getJsonValue())); - - ps.setObject(7, tsKvLatestEntity.getEntityId()); - ps.setInt(8, tsKvLatestEntity.getKey()); - if (updateByLatestTs) { - ps.setLong(9, tsKvLatestEntity.getTs()); - } - } - - @Override - public int getBatchSize() { - return entities.size(); - } - }); - - int updatedCount = 0; - for (int i = 0; i < result.length; i++) { - if (result[i] == 0) { - updatedCount++; - } - } - - List insertEntities = new ArrayList<>(updatedCount); - for (int i = 0; i < result.length; i++) { - if (result[i] == 0) { - insertEntities.add(entities.get(i)); - } - } - - jdbcTemplate.batchUpdate(insertOrUpdateQuery, new BatchPreparedStatementSetter() { - @Override - public void setValues(PreparedStatement ps, int i) throws SQLException { - TsKvLatestEntity tsKvLatestEntity = insertEntities.get(i); - ps.setObject(1, tsKvLatestEntity.getEntityId()); - ps.setInt(2, tsKvLatestEntity.getKey()); - - ps.setLong(3, tsKvLatestEntity.getTs()); - ps.setLong(9, tsKvLatestEntity.getTs()); - if (updateByLatestTs) { - ps.setLong(15, tsKvLatestEntity.getTs()); - } - - if (tsKvLatestEntity.getBooleanValue() != null) { - ps.setBoolean(4, tsKvLatestEntity.getBooleanValue()); - ps.setBoolean(10, tsKvLatestEntity.getBooleanValue()); - } else { - ps.setNull(4, Types.BOOLEAN); - ps.setNull(10, Types.BOOLEAN); - } - - ps.setString(5, replaceNullChars(tsKvLatestEntity.getStrValue())); - ps.setString(11, replaceNullChars(tsKvLatestEntity.getStrValue())); - - if (tsKvLatestEntity.getLongValue() != null) { - ps.setLong(6, tsKvLatestEntity.getLongValue()); - ps.setLong(12, tsKvLatestEntity.getLongValue()); - } else { - ps.setNull(6, Types.BIGINT); - ps.setNull(12, Types.BIGINT); - } - - if (tsKvLatestEntity.getDoubleValue() != null) { - ps.setDouble(7, tsKvLatestEntity.getDoubleValue()); - ps.setDouble(13, tsKvLatestEntity.getDoubleValue()); - } else { - ps.setNull(7, Types.DOUBLE); - ps.setNull(13, Types.DOUBLE); - } - - ps.setString(8, replaceNullChars(tsKvLatestEntity.getJsonValue())); - ps.setString(14, replaceNullChars(tsKvLatestEntity.getJsonValue())); - } - - @Override - public int getBatchSize() { - return insertEntities.size(); - } - }); - } - }); + protected String getInsertOrUpdateQuery() { + return insertOrUpdateQuery; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/sql/DedicatedEventsSqlPartitioningRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/sql/DedicatedEventsSqlPartitioningRepository.java new file mode 100644 index 0000000000..7e1be6bbd9 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/sql/DedicatedEventsSqlPartitioningRepository.java @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.sqlts.insert.sql; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.dao.config.DedicatedEventsDataSource; +import org.thingsboard.server.dao.timeseries.SqlPartition; + +import static org.thingsboard.server.dao.config.DedicatedEventsJpaDaoConfig.EVENTS_JDBC_TEMPLATE; +import static org.thingsboard.server.dao.config.DedicatedEventsJpaDaoConfig.EVENTS_TRANSACTION_MANAGER; + +@DedicatedEventsDataSource +@Repository +public class DedicatedEventsSqlPartitioningRepository extends SqlPartitioningRepository { + + @Autowired + @Qualifier(EVENTS_JDBC_TEMPLATE) + private JdbcTemplate jdbcTemplate; + + @Transactional(propagation = Propagation.NOT_SUPPORTED, transactionManager = EVENTS_TRANSACTION_MANAGER) + @Override + public void save(SqlPartition partition) { + super.save(partition); + } + + @Transactional(propagation = Propagation.NOT_SUPPORTED, transactionManager = EVENTS_TRANSACTION_MANAGER) + @Override + public void createPartitionIfNotExists(String table, long entityTs, long partitionDurationMs) { + super.createPartitionIfNotExists(table, entityTs, partitionDurationMs); + } + + @Override + protected JdbcTemplate getJdbcTemplate() { + return jdbcTemplate; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/sql/SqlPartitioningRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/sql/SqlPartitioningRepository.java index 83a7415780..0f3922e070 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/sql/SqlPartitioningRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/sql/SqlPartitioningRepository.java @@ -19,6 +19,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Primary; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; @@ -32,6 +33,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; +@Primary @Repository @Slf4j public class SqlPartitioningRepository { @@ -49,7 +51,7 @@ public class SqlPartitioningRepository { @Transactional(propagation = Propagation.NOT_SUPPORTED) public void save(SqlPartition partition) { - jdbcTemplate.execute(partition.getQuery()); + getJdbcTemplate().execute(partition.getQuery()); } @Transactional(propagation = Propagation.NOT_SUPPORTED) // executing non-transactionally, so that parent transaction is not aborted on partition save error @@ -119,8 +121,8 @@ public class SqlPartitioningRepository { String dropStmtStr = "DROP TABLE " + tablePartition; try { - jdbcTemplate.execute(detachPsqlStmtStr); - jdbcTemplate.execute(dropStmtStr); + getJdbcTemplate().execute(detachPsqlStmtStr); + getJdbcTemplate().execute(dropStmtStr); return true; } catch (DataAccessException e) { log.error("[{}] Error occurred trying to detach and drop the partition {} ", table, partitionTs, e); @@ -134,7 +136,7 @@ public class SqlPartitioningRepository { public List fetchPartitions(String table) { List partitions = new ArrayList<>(); - List partitionsTables = jdbcTemplate.queryForList(SELECT_PARTITIONS_STMT, String.class, table); + List partitionsTables = getJdbcTemplate().queryForList(SELECT_PARTITIONS_STMT, String.class, table); for (String partitionTableName : partitionsTables) { String partitionTsStr = partitionTableName.substring(table.length() + 1); try { @@ -153,7 +155,7 @@ public class SqlPartitioningRepository { private synchronized int getCurrentServerVersion() { if (currentServerVersion == null) { try { - currentServerVersion = jdbcTemplate.queryForObject("SELECT current_setting('server_version_num')", Integer.class); + currentServerVersion = getJdbcTemplate().queryForObject("SELECT current_setting('server_version_num')", Integer.class); } catch (Exception e) { log.warn("Error occurred during fetch of the server version", e); } @@ -164,4 +166,8 @@ public class SqlPartitioningRepository { return currentServerVersion; } + protected JdbcTemplate getJdbcTemplate() { + return jdbcTemplate; + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/SearchTsKvLatestRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/SearchTsKvLatestRepository.java index 50652d4f60..b3180dae50 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/SearchTsKvLatestRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/SearchTsKvLatestRepository.java @@ -15,12 +15,12 @@ */ package org.thingsboard.server.dao.sqlts.latest; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import org.springframework.stereotype.Repository; import org.thingsboard.server.dao.model.sqlts.latest.TsKvLatestEntity; import org.thingsboard.server.dao.util.SqlTsLatestAnyDao; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; import java.util.List; import java.util.UUID; @@ -31,7 +31,7 @@ public class SearchTsKvLatestRepository { public static final String FIND_ALL_BY_ENTITY_ID = "findAllByEntityId"; public static final String FIND_ALL_BY_ENTITY_ID_QUERY = "SELECT ts_kv_latest.entity_id AS entityId, ts_kv_latest.key AS key, key_dictionary.key AS strKey, ts_kv_latest.str_v AS strValue," + - " ts_kv_latest.bool_v AS boolValue, ts_kv_latest.long_v AS longValue, ts_kv_latest.dbl_v AS doubleValue, ts_kv_latest.json_v AS jsonValue, ts_kv_latest.ts AS ts FROM ts_kv_latest " + + " ts_kv_latest.bool_v AS boolValue, ts_kv_latest.long_v AS longValue, ts_kv_latest.dbl_v AS doubleValue, ts_kv_latest.json_v AS jsonValue, ts_kv_latest.ts AS ts, ts_kv_latest.version AS version FROM ts_kv_latest " + "INNER JOIN key_dictionary ON ts_kv_latest.key = key_dictionary.key_id WHERE ts_kv_latest.entity_id = cast(:id AS uuid)"; @PersistenceContext diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/AggregationRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/AggregationRepository.java index 1e3ae68f4c..135ab097c9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/AggregationRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/AggregationRepository.java @@ -15,12 +15,12 @@ */ package org.thingsboard.server.dao.sqlts.timescale; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import org.springframework.stereotype.Repository; import org.thingsboard.server.dao.model.sqlts.timescale.ts.TimescaleTsKvEntity; import org.thingsboard.server.dao.util.TimescaleDBTsOrTsLatestDao; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; import java.util.List; import java.util.UUID; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/TimescaleTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/TimescaleTimeseriesDao.java index c8bff8ceb9..cd9f397bb3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/TimescaleTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/TimescaleTimeseriesDao.java @@ -18,8 +18,9 @@ package org.thingsboard.server.dao.sqlts.timescale; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -38,7 +39,6 @@ import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.dictionary.KeyDictionaryDao; import org.thingsboard.server.dao.model.sql.AbstractTsKvEntity; import org.thingsboard.server.dao.model.sqlts.timescale.ts.TimescaleTsKvEntity; -import org.thingsboard.server.dao.model.sqlts.ts.TsKvEntity; import org.thingsboard.server.dao.sql.TbSqlBlockingQueueParams; import org.thingsboard.server.dao.sql.TbSqlBlockingQueueWrapper; import org.thingsboard.server.dao.sqlts.AbstractSqlTimeseriesDao; @@ -47,8 +47,6 @@ import org.thingsboard.server.dao.timeseries.TimeseriesDao; import org.thingsboard.server.dao.util.TimeUtils; import org.thingsboard.server.dao.util.TimescaleDBTsDao; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -77,7 +75,7 @@ public class TimescaleTimeseriesDao extends AbstractSqlTimeseriesDao implements @Autowired protected KeyDictionaryDao keyDictionaryDao; - protected TbSqlBlockingQueueWrapper tsQueue; + protected TbSqlBlockingQueueWrapper tsQueue; @PostConstruct protected void init() { diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java index b72ac1514b..168ac4ecb2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.timeseries; import com.datastax.oss.driver.api.core.cql.Row; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import jakarta.annotation.Nullable; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.kv.AggTsKvEntry; import org.thingsboard.server.common.data.kv.Aggregation; @@ -32,7 +33,6 @@ import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvEntryAggWrapper; import org.thingsboard.server.dao.nosql.TbResultSet; -import jakarta.annotation.Nullable; import java.util.List; import java.util.Optional; import java.util.concurrent.Executor; diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index 60056b2b8f..f9c4218d64 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -187,8 +187,8 @@ public class BaseTimeseriesService implements TimeseriesService { } @Override - public ListenableFuture> saveLatest(TenantId tenantId, EntityId entityId, List tsKvEntries) { - List> futures = new ArrayList<>(tsKvEntries.size()); + public ListenableFuture> saveLatest(TenantId tenantId, EntityId entityId, List tsKvEntries) { + List> futures = new ArrayList<>(tsKvEntries.size()); for (TsKvEntry tsKvEntry : tsKvEntries) { futures.add(timeseriesLatestDao.saveLatest(tenantId, entityId, tsKvEntry)); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java index 6700e3614a..6acf906875 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java @@ -28,6 +28,9 @@ import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -55,9 +58,6 @@ import org.thingsboard.server.dao.sqlts.AggregationTimeseriesDao; import org.thingsboard.server.dao.util.NoSqlTsDao; import org.thingsboard.server.dao.util.TimeUtils; -import jakarta.annotation.Nullable; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java index 7a5904eb6b..01c91d4801 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java @@ -100,7 +100,7 @@ public class CassandraBaseTimeseriesLatestDao extends AbstractCassandraBaseTimes } @Override - public ListenableFuture saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { + public ListenableFuture saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { BoundStatementBuilder stmtBuilder = new BoundStatementBuilder(getLatestStmt().bind()); stmtBuilder.setString(0, entityId.getEntityType().name()) .setUuid(1, entityId.getId()) @@ -161,7 +161,7 @@ public class CassandraBaseTimeseriesLatestDao extends AbstractCassandraBaseTimes var entryList = result.getData(); if (entryList.size() == 1) { TsKvEntry entry = entryList.get(0); - return Futures.transform(saveLatest(tenantId, entityId, entryList.get(0)), v -> new TsKvLatestRemovingResult(entry), MoreExecutors.directExecutor()); + return Futures.transform(saveLatest(tenantId, entityId, entryList.get(0)), v -> new TsKvLatestRemovingResult(entry, v), MoreExecutors.directExecutor()); } else { log.trace("Could not find new latest value for [{}], key - {}", entityId, query.getKey()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java index d339f49e11..9f62fd033a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java @@ -42,7 +42,7 @@ public interface TimeseriesLatestDao { ListenableFuture> findAllLatest(TenantId tenantId, EntityId entityId); - ListenableFuture saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry); + ListenableFuture saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry); ListenableFuture removeLatest(TenantId tenantId, EntityId entityId, DeleteTsKvQuery query); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestCacheKey.java new file mode 100644 index 0000000000..adb572922a --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestCacheKey.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.timeseries; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.id.EntityId; + +import java.io.Serial; +import java.io.Serializable; + +@EqualsAndHashCode +@Getter +@AllArgsConstructor +public class TsLatestCacheKey implements Serializable { + private static final long serialVersionUID = 2024369077925351881L; + + private final EntityId entityId; + private final String key; + + @Override + public String toString() { + return "{" + entityId + "}" + key; + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java new file mode 100644 index 0000000000..241132b4c8 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.timeseries; + +import com.google.protobuf.InvalidProtocolBufferException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.SerializationException; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CacheSpecsMap; +import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.cache.TbRedisSerializer; +import org.thingsboard.server.cache.VersionedRedisTbCache; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.util.KvProtoUtil; +import org.thingsboard.server.gen.transport.TransportProtos; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +@Service("TsLatestCache") +@Slf4j +public class TsLatestRedisCache extends VersionedRedisTbCache { + + public TsLatestRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.TS_LATEST_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbRedisSerializer<>() { + @Override + public byte[] serialize(TsKvEntry tsKvEntry) throws SerializationException { + return KvProtoUtil.toTsKvProto(tsKvEntry.getTs(), tsKvEntry, tsKvEntry.getVersion()).toByteArray(); + } + + @Override + public TsKvEntry deserialize(TsLatestCacheKey key, byte[] bytes) throws SerializationException { + try { + return KvProtoUtil.fromTsKvProto(TransportProtos.TsKvProto.parseFrom(bytes)); + } catch (InvalidProtocolBufferException e) { + throw new SerializationException(e.getMessage()); + } + } + }); + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index 34602d81b3..f64ccf4970 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java @@ -17,6 +17,9 @@ package org.thingsboard.server.dao.user; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.BooleanNode; +import com.fasterxml.jackson.databind.node.IntNode; +import com.fasterxml.jackson.databind.node.LongNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.ListenableFuture; import lombok.RequiredArgsConstructor; @@ -411,16 +414,11 @@ public class UserServiceImpl extends AbstractCachedEntityService 0) { + metaData = JacksonUtil.toJsonNode(metaElements.item(0).getTextContent()); + } + return new ScadaSymbolMetadataInfo(fileName, metaData); + } + private static int[] getThumbnailDimensions(int originalWidth, int originalHeight, int maxDimension, boolean originalIfSmaller) { if (originalWidth <= maxDimension && originalHeight <= maxDimension && originalIfSmaller) { return new int[]{originalWidth, originalHeight}; @@ -264,4 +278,45 @@ public class ImageUtils { private ProcessedImage preview; } + @Data + public static class ScadaSymbolMetadataInfo { + private String title; + private String description; + private String[] searchTags; + private int widgetSizeX; + private int widgetSizeY; + + public ScadaSymbolMetadataInfo(String fileName, JsonNode metaData) { + if (metaData != null && metaData.has("title")) { + title = metaData.get("title").asText(); + } else { + title = fileName; + } + if (metaData != null && metaData.has("description")) { + description = metaData.get("description").asText(); + } else { + description = ""; + } + if (metaData != null && metaData.has("searchTags") && metaData.get("searchTags").isArray()) { + var tagsNode = (ArrayNode) metaData.get("searchTags"); + searchTags = new String[tagsNode.size()]; + for (int i = 0; i < tagsNode.size(); i++) { + searchTags[i] = tagsNode.get(i).asText(); + } + } else { + searchTags = new String[0]; + } + if (metaData != null && metaData.has("widgetSizeX")) { + widgetSizeX = metaData.get("widgetSizeX").asInt(); + } else { + widgetSizeX = 3; + } + if (metaData != null && metaData.has("widgetSizeY")) { + widgetSizeY = metaData.get("widgetSizeY").asInt(); + } else { + widgetSizeY = 3; + } + } + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/JsonPathProcessingTask.java b/dao/src/main/java/org/thingsboard/server/dao/util/JsonPathProcessingTask.java index 8947a44642..ead70ce4ea 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/JsonPathProcessingTask.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/JsonPathProcessingTask.java @@ -17,7 +17,6 @@ package org.thingsboard.server.dao.util; import com.fasterxml.jackson.databind.JsonNode; import lombok.Data; -import org.thingsboard.server.dao.dashboard.DashboardServiceImpl; import java.util.Arrays; import java.util.HashMap; diff --git a/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeDao.java b/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeDao.java index 0a7cabb457..d90c105aae 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeDao.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.DeprecatedFilter; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetTypeFilter; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.common.data.widget.WidgetsBundleWidget; import org.thingsboard.server.dao.Dao; @@ -55,11 +56,11 @@ public interface WidgetTypeDao extends Dao, ExportableEntityD boolean existsByTenantIdAndId(TenantId tenantId, UUID widgetTypeId); - PageData findSystemWidgetTypes(TenantId tenantId, boolean fullSearch, DeprecatedFilter deprecatedFilter, List widgetTypes, PageLink pageLink); + PageData findSystemWidgetTypes(WidgetTypeFilter widgetTypeFilter, PageLink pageLink); - PageData findAllTenantWidgetTypesByTenantId(UUID tenantId, boolean fullSearch, DeprecatedFilter deprecatedFilter, List widgetTypes, PageLink pageLink); + PageData findAllTenantWidgetTypesByTenantId(WidgetTypeFilter widgetTypeFilter, PageLink pageLink); - PageData findTenantWidgetTypesByTenantId(UUID tenantId, boolean fullSearch, DeprecatedFilter deprecatedFilter, List widgetTypes, PageLink pageLink); + PageData findTenantWidgetTypesByTenantId(WidgetTypeFilter widgetTypeFilter, PageLink pageLink); /** * Find widget types by widgetsBundleId. @@ -92,6 +93,8 @@ public interface WidgetTypeDao extends Dao, ExportableEntityD */ WidgetType findByTenantIdAndFqn(UUID tenantId, String fqn); + WidgetTypeDetails findDetailsByTenantIdAndFqn(UUID tenantId, String fqn); + /** * Find widget types infos by tenantId and resourceId in descriptor. * diff --git a/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeServiceImpl.java index f2506a2d9b..3e2fe0bf35 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeServiceImpl.java @@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.DeprecatedFilter; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetTypeFilter; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.common.data.widget.WidgetsBundleWidget; import org.thingsboard.server.dao.entity.AbstractCachedEntityService; @@ -121,28 +122,30 @@ public class WidgetTypeServiceImpl implements WidgetTypeService { } @Override - public PageData findSystemWidgetTypesByPageLink(TenantId tenantId, boolean fullSearch, DeprecatedFilter deprecatedFilter, List widgetTypes, PageLink pageLink) { - log.trace("Executing findSystemWidgetTypesByPageLink, fullSearch [{}], deprecatedFilter [{}], widgetTypes [{}], pageLink [{}]", fullSearch, deprecatedFilter, widgetTypes, pageLink); + public PageData findSystemWidgetTypesByPageLink(WidgetTypeFilter widgetTypeFilter, PageLink pageLink) { + log.trace("Executing findSystemWidgetTypesByPageLink, pageLink [{}]", pageLink); Validator.validatePageLink(pageLink); - return widgetTypeDao.findSystemWidgetTypes(tenantId, fullSearch, deprecatedFilter, widgetTypes, pageLink); + return widgetTypeDao.findSystemWidgetTypes(widgetTypeFilter, pageLink); } @Override - public PageData findAllTenantWidgetTypesByTenantIdAndPageLink(TenantId tenantId, boolean fullSearch, DeprecatedFilter deprecatedFilter, List widgetTypes, PageLink pageLink) { - log.trace("Executing findAllTenantWidgetTypesByTenantIdAndPageLink, tenantId [{}], fullSearch [{}], deprecatedFilter [{}], widgetTypes [{}], pageLink [{}]", - tenantId, fullSearch, deprecatedFilter, widgetTypes, pageLink); + public PageData findAllTenantWidgetTypesByTenantIdAndPageLink(WidgetTypeFilter widgetTypeFilter, PageLink pageLink) { + TenantId tenantId = widgetTypeFilter.getTenantId(); + log.trace("Executing findAllTenantWidgetTypesByTenantIdAndPageLink, tenantId [{}], pageLink [{}]", + tenantId, pageLink); Validator.validateId(tenantId, id -> INCORRECT_TENANT_ID + id); Validator.validatePageLink(pageLink); - return widgetTypeDao.findAllTenantWidgetTypesByTenantId(tenantId.getId(), fullSearch, deprecatedFilter, widgetTypes, pageLink); + return widgetTypeDao.findAllTenantWidgetTypesByTenantId(widgetTypeFilter, pageLink); } @Override - public PageData findTenantWidgetTypesByTenantIdAndPageLink(TenantId tenantId, boolean fullSearch, DeprecatedFilter deprecatedFilter, List widgetTypes, PageLink pageLink) { - log.trace("Executing findTenantWidgetTypesByTenantIdAndPageLink, tenantId [{}], fullSearch [{}], deprecatedFilter [{}], widgetTypes [{}], pageLink [{}]", - tenantId, fullSearch, deprecatedFilter, widgetTypes, pageLink); + public PageData findTenantWidgetTypesByTenantIdAndPageLink(WidgetTypeFilter widgetTypeFilter, PageLink pageLink) { + TenantId tenantId = widgetTypeFilter.getTenantId(); + log.trace("Executing findTenantWidgetTypesByTenantIdAndPageLink, tenantId [{}], pageLink [{}]", + tenantId, pageLink); Validator.validateId(tenantId, id -> INCORRECT_TENANT_ID + id); Validator.validatePageLink(pageLink); - return widgetTypeDao.findTenantWidgetTypesByTenantId(tenantId.getId(), fullSearch, deprecatedFilter, widgetTypes, pageLink); + return widgetTypeDao.findTenantWidgetTypesByTenantId(widgetTypeFilter, pageLink); } @Override @@ -189,6 +192,15 @@ public class WidgetTypeServiceImpl implements WidgetTypeService { return widgetTypeDao.findByTenantIdAndFqn(tenantId.getId(), fqn); } + @Override + public WidgetTypeDetails findWidgetTypeDetailsByTenantIdAndFqn(TenantId tenantId, String fqn) { + log.trace("Executing findWidgetTypeDetailsByTenantIdAndFqn, tenantId [{}], fqn [{}]", tenantId, fqn); + Validator.validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + Validator.validateString(fqn, f -> "Incorrect fqn " + f); + return widgetTypeDao.findDetailsByTenantIdAndFqn(tenantId.getId(), fqn); + } + + @Override public void updateWidgetsBundleWidgetTypes(TenantId tenantId, WidgetsBundleId widgetsBundleId, List widgetTypeIds) { log.trace("Executing updateWidgetsBundleWidgetTypes, tenantId [{}], widgetsBundleId [{}], widgetTypeIds [{}]", tenantId, widgetsBundleId, widgetTypeIds); @@ -251,7 +263,13 @@ public class WidgetTypeServiceImpl implements WidgetTypeService { @Override protected PageData findEntities(TenantId tenantId, TenantId id, PageLink pageLink) { - return widgetTypeDao.findTenantWidgetTypesByTenantId(id.getId(), false, DeprecatedFilter.ALL, null, pageLink); + return widgetTypeDao.findTenantWidgetTypesByTenantId( + WidgetTypeFilter.builder() + .tenantId(id) + .fullSearch(false) + .deprecatedFilter(DeprecatedFilter.ALL) + .widgetTypes(null).build(), + pageLink); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleDao.java b/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleDao.java index 02d5b0f007..a37149ae88 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleDao.java @@ -15,12 +15,12 @@ */ package org.thingsboard.server.dao.widget; -import org.thingsboard.server.common.data.DashboardInfo; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.WidgetsBundleId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.common.data.widget.WidgetsBundleFilter; import org.thingsboard.server.dao.Dao; import org.thingsboard.server.dao.ExportableEntityDao; import org.thingsboard.server.dao.ImageContainerDao; @@ -56,7 +56,7 @@ public interface WidgetsBundleDao extends Dao, ExportableEntityDa * @param pageLink the page link * @return the list of widgets bundles objects */ - PageData findSystemWidgetsBundles(TenantId tenantId, boolean fullSearch, PageLink pageLink); + PageData findSystemWidgetsBundles(WidgetsBundleFilter widgetsBundleFilter, PageLink pageLink); /** * Find tenant widgets bundles by tenantId and page link. @@ -74,7 +74,7 @@ public interface WidgetsBundleDao extends Dao, ExportableEntityDa * @param pageLink the page link * @return the list of widgets bundles objects */ - PageData findAllTenantWidgetsBundlesByTenantId(UUID tenantId, boolean fullSearch, PageLink pageLink); + PageData findAllTenantWidgetsBundlesByTenantId(WidgetsBundleFilter widgetsBundleFilter, PageLink pageLink); /** * Find all tenant widgets bundles (does not include system) by tenantId and page link. @@ -83,7 +83,7 @@ public interface WidgetsBundleDao extends Dao, ExportableEntityDa * @param pageLink the page link * @return the list of widgets bundles objects */ - PageData findTenantWidgetsBundlesByTenantId(UUID tenantId, boolean fullSearch, PageLink pageLink); + PageData findTenantWidgetsBundlesByTenantId(WidgetsBundleFilter widgetsBundleFilter, PageLink pageLink); PageData findAllWidgetsBundles(PageLink pageLink); diff --git a/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleServiceImpl.java index ae53c70ec8..d7006e9e56 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleServiceImpl.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.id.WidgetsBundleId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.common.data.widget.WidgetsBundleFilter; import org.thingsboard.server.dao.entity.AbstractCachedEntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; @@ -118,10 +119,10 @@ public class WidgetsBundleServiceImpl implements WidgetsBundleService { } @Override - public PageData findSystemWidgetsBundlesByPageLink(TenantId tenantId, boolean fullSearch, PageLink pageLink) { - log.trace("Executing findSystemWidgetsBundles, fullSearch [{}], pageLink [{}]", fullSearch, pageLink); + public PageData findSystemWidgetsBundlesByPageLink(WidgetsBundleFilter widgetsBundleFilter, PageLink pageLink) { + log.trace("Executing findSystemWidgetsBundles, widgetsBundleFilter [{}], pageLink [{}]", widgetsBundleFilter, pageLink); Validator.validatePageLink(pageLink); - return widgetsBundleDao.findSystemWidgetsBundles(tenantId, fullSearch, pageLink); + return widgetsBundleDao.findSystemWidgetsBundles(widgetsBundleFilter, pageLink); } @Override @@ -131,7 +132,7 @@ public class WidgetsBundleServiceImpl implements WidgetsBundleService { PageLink pageLink = new PageLink(DEFAULT_WIDGETS_BUNDLE_LIMIT); PageData pageData; do { - pageData = findSystemWidgetsBundlesByPageLink(tenantId, false, pageLink); + pageData = findSystemWidgetsBundlesByPageLink(WidgetsBundleFilter.fromTenantId(tenantId), pageLink); widgetsBundles.addAll(pageData.getData()); if (pageData.hasNext()) { pageLink = pageLink.nextPageLink(); @@ -149,19 +150,21 @@ public class WidgetsBundleServiceImpl implements WidgetsBundleService { } @Override - public PageData findAllTenantWidgetsBundlesByTenantIdAndPageLink(TenantId tenantId, boolean fullSearch, PageLink pageLink) { - log.trace("Executing findAllTenantWidgetsBundlesByTenantIdAndPageLink, tenantId [{}], fullSearch [{}], pageLink [{}]", tenantId, fullSearch, pageLink); + public PageData findAllTenantWidgetsBundlesByTenantIdAndPageLink(WidgetsBundleFilter widgetsBundleFilter, PageLink pageLink) { + TenantId tenantId = widgetsBundleFilter.getTenantId(); + log.trace("Executing findAllTenantWidgetsBundlesByTenantIdAndPageLink, tenantId [{}], pageLink [{}]", tenantId, pageLink); Validator.validateId(tenantId, id -> INCORRECT_TENANT_ID + id); Validator.validatePageLink(pageLink); - return widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId.getId(), fullSearch, pageLink); + return widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(widgetsBundleFilter, pageLink); } @Override - public PageData findTenantWidgetsBundlesByTenantIdAndPageLink(TenantId tenantId, boolean fullSearch, PageLink pageLink) { - log.trace("Executing findTenantWidgetsBundlesByTenantIdAndPageLink, tenantId [{}], fullSearch [{}], pageLink [{}]", tenantId, fullSearch, pageLink); + public PageData findTenantWidgetsBundlesByTenantIdAndPageLink(WidgetsBundleFilter widgetsBundleFilter, PageLink pageLink) { + TenantId tenantId = widgetsBundleFilter.getTenantId(); + log.trace("Executing findTenantWidgetsBundlesByTenantIdAndPageLink, tenantId [{}], pageLink [{}]", tenantId, pageLink); Validator.validateId(tenantId, id -> INCORRECT_TENANT_ID + id); Validator.validatePageLink(pageLink); - return widgetsBundleDao.findTenantWidgetsBundlesByTenantId(tenantId.getId(), fullSearch, pageLink); + return widgetsBundleDao.findTenantWidgetsBundlesByTenantId(widgetsBundleFilter, pageLink); } @Override @@ -172,7 +175,7 @@ public class WidgetsBundleServiceImpl implements WidgetsBundleService { PageLink pageLink = new PageLink(DEFAULT_WIDGETS_BUNDLE_LIMIT); PageData pageData; do { - pageData = findAllTenantWidgetsBundlesByTenantIdAndPageLink(tenantId, false, pageLink); + pageData = findAllTenantWidgetsBundlesByTenantIdAndPageLink(WidgetsBundleFilter.fromTenantId(tenantId), pageLink); widgetsBundles.addAll(pageData.getData()); if (pageData.hasNext()) { pageLink = pageLink.nextPageLink(); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index f2978b7f4a..66fe8dc1bb 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -102,6 +102,8 @@ CREATE TABLE IF NOT EXISTS audit_log ( action_failure_details varchar(1000000) ) PARTITION BY RANGE (created_time); +CREATE SEQUENCE IF NOT EXISTS attribute_kv_version_seq cache 1; + CREATE TABLE IF NOT EXISTS attribute_kv ( entity_id uuid, attribute_type int, @@ -112,6 +114,7 @@ CREATE TABLE IF NOT EXISTS attribute_kv ( dbl_v double precision, json_v json, last_update_ts bigint, + version bigint default 0, CONSTRAINT attribute_kv_pkey PRIMARY KEY (entity_id, attribute_type, attribute_key) ); @@ -145,6 +148,7 @@ CREATE TABLE IF NOT EXISTS customer ( zip varchar(255), external_id uuid, is_public boolean, + version BIGINT DEFAULT 1, CONSTRAINT customer_title_unq_key UNIQUE (tenant_id, title), CONSTRAINT customer_external_id_unq_key UNIQUE (tenant_id, external_id) ); @@ -160,6 +164,7 @@ CREATE TABLE IF NOT EXISTS dashboard ( mobile_order int, image varchar(1000000), external_id uuid, + version BIGINT DEFAULT 1, CONSTRAINT dashboard_external_id_unq_key UNIQUE (tenant_id, external_id) ); @@ -175,6 +180,7 @@ CREATE TABLE IF NOT EXISTS rule_chain ( debug_mode boolean, tenant_id uuid, external_id uuid, + version BIGINT DEFAULT 1, CONSTRAINT rule_chain_external_id_unq_key UNIQUE (tenant_id, external_id) ); @@ -252,6 +258,7 @@ CREATE TABLE IF NOT EXISTS asset_profile ( default_queue_name varchar(255), default_edge_rule_chain_id uuid, external_id uuid, + version BIGINT DEFAULT 1, CONSTRAINT asset_profile_name_unq_key UNIQUE (tenant_id, name), CONSTRAINT asset_profile_external_id_unq_key UNIQUE (tenant_id, external_id), CONSTRAINT fk_default_rule_chain_asset_profile FOREIGN KEY (default_rule_chain_id) REFERENCES rule_chain(id), @@ -270,6 +277,7 @@ CREATE TABLE IF NOT EXISTS asset ( tenant_id uuid, type varchar(255), external_id uuid, + version BIGINT DEFAULT 1, CONSTRAINT asset_name_unq_key UNIQUE (tenant_id, name), CONSTRAINT asset_external_id_unq_key UNIQUE (tenant_id, external_id), CONSTRAINT fk_asset_profile FOREIGN KEY (asset_profile_id) REFERENCES asset_profile(id) @@ -295,6 +303,7 @@ CREATE TABLE IF NOT EXISTS device_profile ( provision_device_key varchar, default_edge_rule_chain_id uuid, external_id uuid, + version BIGINT DEFAULT 1, CONSTRAINT device_profile_name_unq_key UNIQUE (tenant_id, name), CONSTRAINT device_provision_key_unq_key UNIQUE (provision_device_key), CONSTRAINT device_profile_external_id_unq_key UNIQUE (tenant_id, external_id), @@ -340,6 +349,7 @@ CREATE TABLE IF NOT EXISTS device ( firmware_id uuid, software_id uuid, external_id uuid, + version BIGINT DEFAULT 1, CONSTRAINT device_name_unq_key UNIQUE (tenant_id, name), CONSTRAINT device_external_id_unq_key UNIQUE (tenant_id, external_id), CONSTRAINT fk_device_profile FOREIGN KEY (device_profile_id) REFERENCES device_profile(id), @@ -354,6 +364,7 @@ CREATE TABLE IF NOT EXISTS device_credentials ( credentials_type varchar(255), credentials_value varchar, device_id uuid, + version BIGINT DEFAULT 1, CONSTRAINT device_credentials_id_unq_key UNIQUE (credentials_id), CONSTRAINT device_credentials_device_id_unq_key UNIQUE (device_id) ); @@ -417,6 +428,8 @@ CREATE TABLE IF NOT EXISTS error_event ( e_error varchar ) PARTITION BY RANGE (ts); +CREATE SEQUENCE IF NOT EXISTS relation_version_seq cache 1; + CREATE TABLE IF NOT EXISTS relation ( from_id uuid, from_type varchar(255), @@ -425,6 +438,7 @@ CREATE TABLE IF NOT EXISTS relation ( relation_type_group varchar(255), relation_type varchar(255), additional_info varchar, + version bigint default 0, CONSTRAINT relation_pkey PRIMARY KEY (from_id, from_type, relation_type_group, relation_type, to_id, to_type) ); @@ -438,7 +452,8 @@ CREATE TABLE IF NOT EXISTS tb_user ( first_name varchar(255), last_name varchar(255), phone varchar(255), - tenant_id uuid + tenant_id uuid, + version BIGINT DEFAULT 1 ); CREATE TABLE IF NOT EXISTS tenant_profile ( @@ -468,6 +483,7 @@ CREATE TABLE IF NOT EXISTS tenant ( state varchar(255), title varchar(255), zip varchar(255), + version BIGINT DEFAULT 1, CONSTRAINT fk_tenant_profile FOREIGN KEY (tenant_profile_id) REFERENCES tenant_profile(id) ); @@ -490,10 +506,12 @@ CREATE TABLE IF NOT EXISTS widget_type ( name varchar(255), tenant_id uuid, image varchar(1000000), + scada boolean NOT NULL DEFAULT false, deprecated boolean NOT NULL DEFAULT false, description varchar(1024), tags text[], external_id uuid, + version BIGINT DEFAULT 1, CONSTRAINT uq_widget_type_fqn UNIQUE (tenant_id, fqn), CONSTRAINT widget_type_external_id_unq_key UNIQUE (tenant_id, external_id) ); @@ -505,9 +523,11 @@ CREATE TABLE IF NOT EXISTS widgets_bundle ( tenant_id uuid, title varchar(255), image varchar(1000000), + scada boolean NOT NULL DEFAULT false, description varchar(1024), widgets_bundle_order int, external_id uuid, + version BIGINT DEFAULT 1, CONSTRAINT uq_widgets_bundle_alias UNIQUE (tenant_id, alias), CONSTRAINT widgets_bundle_external_id_unq_key UNIQUE (tenant_id, external_id) ); @@ -535,9 +555,12 @@ CREATE TABLE IF NOT EXISTS entity_view ( end_ts bigint, additional_info varchar, external_id uuid, + version BIGINT DEFAULT 1, CONSTRAINT entity_view_external_id_unq_key UNIQUE (tenant_id, external_id) ); +CREATE SEQUENCE IF NOT EXISTS ts_kv_latest_version_seq cache 1; + CREATE TABLE IF NOT EXISTS ts_kv_latest ( entity_id uuid NOT NULL, @@ -548,6 +571,7 @@ CREATE TABLE IF NOT EXISTS ts_kv_latest long_v bigint, dbl_v double precision, json_v json, + version bigint default 0, CONSTRAINT ts_kv_latest_pkey PRIMARY KEY (entity_id, key) ); @@ -715,6 +739,7 @@ CREATE TABLE IF NOT EXISTS resource ( tenant_id uuid NOT NULL, title varchar(255) NOT NULL, resource_type varchar(32) NOT NULL, + resource_sub_type varchar(32), resource_key varchar(255) NOT NULL, search_text varchar(255), file_name varchar(255) NOT NULL, @@ -740,6 +765,7 @@ CREATE TABLE IF NOT EXISTS edge ( routing_key varchar(255), secret varchar(255), tenant_id uuid, + version BIGINT DEFAULT 1, CONSTRAINT edge_name_unq_key UNIQUE (tenant_id, name), CONSTRAINT edge_routing_key_unq_key UNIQUE (routing_key) ); diff --git a/dao/src/main/resources/sql/schema-timescale.sql b/dao/src/main/resources/sql/schema-timescale.sql index ecd8aa1e9d..6142770c1c 100644 --- a/dao/src/main/resources/sql/schema-timescale.sql +++ b/dao/src/main/resources/sql/schema-timescale.sql @@ -34,6 +34,8 @@ CREATE TABLE IF NOT EXISTS key_dictionary ( CONSTRAINT key_dictionary_id_pkey PRIMARY KEY (key) ); +CREATE SEQUENCE IF NOT EXISTS ts_kv_latest_version_seq cache 1; + CREATE TABLE IF NOT EXISTS ts_kv_latest ( entity_id uuid NOT NULL, key int NOT NULL, @@ -43,6 +45,7 @@ CREATE TABLE IF NOT EXISTS ts_kv_latest ( long_v bigint, dbl_v double precision, json_v json, + version bigint default 0, CONSTRAINT ts_kv_latest_pkey PRIMARY KEY (entity_id, key) ); diff --git a/dao/src/main/resources/sql/schema-ts-latest-psql.sql b/dao/src/main/resources/sql/schema-ts-latest-psql.sql index c6701315b4..725c14e9bf 100644 --- a/dao/src/main/resources/sql/schema-ts-latest-psql.sql +++ b/dao/src/main/resources/sql/schema-ts-latest-psql.sql @@ -14,6 +14,8 @@ -- limitations under the License. -- +CREATE SEQUENCE IF NOT EXISTS ts_kv_latest_version_seq cache 1; + CREATE TABLE IF NOT EXISTS ts_kv_latest ( entity_id uuid NOT NULL, @@ -24,12 +26,6 @@ CREATE TABLE IF NOT EXISTS ts_kv_latest long_v bigint, dbl_v double precision, json_v json, + version bigint default 0, CONSTRAINT ts_kv_latest_pkey PRIMARY KEY (entity_id, key) ); - -CREATE TABLE IF NOT EXISTS ts_kv_dictionary -( - key varchar(255) NOT NULL, - key_id serial UNIQUE, - CONSTRAINT ts_key_id_pkey PRIMARY KEY (key) -); \ No newline at end of file diff --git a/dao/src/test/java/org/thingsboard/server/dao/AbstractDaoServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/AbstractDaoServiceTest.java index efa529cb5e..c4bb8fb336 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/AbstractDaoServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/AbstractDaoServiceTest.java @@ -25,10 +25,14 @@ import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; import org.springframework.test.context.support.DirtiesContextTestExecutionListener; import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.dao.config.DedicatedEventsJpaDaoConfig; +import org.thingsboard.server.dao.config.JpaDaoConfig; +import org.thingsboard.server.dao.config.SqlTsDaoConfig; +import org.thingsboard.server.dao.config.SqlTsLatestDaoConfig; import org.thingsboard.server.dao.service.DaoSqlTest; @RunWith(SpringRunner.class) -@ContextConfiguration(classes = {JpaDaoConfig.class, SqlTsDaoConfig.class, SqlTsLatestDaoConfig.class, SqlTimeseriesDaoConfig.class}) +@ContextConfiguration(classes = {JpaDaoConfig.class, SqlTsDaoConfig.class, SqlTsLatestDaoConfig.class, DedicatedEventsJpaDaoConfig.class}) @DaoSqlTest @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) @TestExecutionListeners({ diff --git a/dao/src/test/java/org/thingsboard/server/dao/AbstractJpaDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/AbstractJpaDaoTest.java index 8682aa5968..f3ddda769d 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/AbstractJpaDaoTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/AbstractJpaDaoTest.java @@ -24,13 +24,18 @@ import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; import org.springframework.test.context.support.DirtiesContextTestExecutionListener; import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.dao.config.DedicatedEventsJpaDaoConfig; +import org.thingsboard.server.dao.config.DefaultDedicatedJpaDaoConfig; +import org.thingsboard.server.dao.config.JpaDaoConfig; +import org.thingsboard.server.dao.config.SqlTsDaoConfig; +import org.thingsboard.server.dao.config.SqlTsLatestDaoConfig; import org.thingsboard.server.dao.service.DaoSqlTest; /** * Created by Valerii Sosliuk on 4/22/2017. */ @RunWith(SpringRunner.class) -@ContextConfiguration(classes = {JpaDaoConfig.class, SqlTsDaoConfig.class, SqlTsLatestDaoConfig.class, SqlTimeseriesDaoConfig.class}) +@ContextConfiguration(classes = {JpaDaoConfig.class, SqlTsDaoConfig.class, SqlTsLatestDaoConfig.class, DedicatedEventsJpaDaoConfig.class, DefaultDedicatedJpaDaoConfig.class}) @DaoSqlTest @TestExecutionListeners({ DependencyInjectionTestExecutionListener.class, diff --git a/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisClusterContainer.java b/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisClusterContainer.java new file mode 100644 index 0000000000..90ff4fbc34 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisClusterContainer.java @@ -0,0 +1,91 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao; + +import lombok.extern.slf4j.Slf4j; +import org.junit.ClassRule; +import org.junit.rules.ExternalResource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.output.OutputFrame; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +public class AbstractRedisClusterContainer { + + static final String nodes = "127.0.0.1:6371,127.0.0.1:6372,127.0.0.1:6373,127.0.0.1:6374,127.0.0.1:6375,127.0.0.1:6376"; + + @ClassRule(order = 0) + public static Network network = Network.newNetwork(); + @ClassRule(order = 1) + public static GenericContainer redis1 = new GenericContainer("bitnami/redis-cluster:latest").withEnv("REDIS_PORT_NUMBER", "6371").withNetworkMode("host").withLogConsumer(x -> log.warn("{}", ((OutputFrame) x).getUtf8StringWithoutLineEnding())).withEnv("ALLOW_EMPTY_PASSWORD", "yes").withEnv("REDIS_NODES", nodes); + @ClassRule(order = 2) + public static GenericContainer redis2 = new GenericContainer("bitnami/redis-cluster:latest").withEnv("REDIS_PORT_NUMBER", "6372").withNetworkMode("host").withLogConsumer(x -> log.warn("{}", ((OutputFrame) x).getUtf8StringWithoutLineEnding())).withEnv("ALLOW_EMPTY_PASSWORD", "yes").withEnv("REDIS_NODES", nodes); + @ClassRule(order = 3) + public static GenericContainer redis3 = new GenericContainer("bitnami/redis-cluster:latest").withEnv("REDIS_PORT_NUMBER", "6373").withNetworkMode("host").withLogConsumer(x -> log.warn("{}", ((OutputFrame) x).getUtf8StringWithoutLineEnding())).withEnv("ALLOW_EMPTY_PASSWORD", "yes").withEnv("REDIS_NODES", nodes); + @ClassRule(order = 4) + public static GenericContainer redis4 = new GenericContainer("bitnami/redis-cluster:latest").withEnv("REDIS_PORT_NUMBER", "6374").withNetworkMode("host").withLogConsumer(x -> log.warn("{}", ((OutputFrame) x).getUtf8StringWithoutLineEnding())).withEnv("ALLOW_EMPTY_PASSWORD", "yes").withEnv("REDIS_NODES", nodes); + @ClassRule(order = 5) + public static GenericContainer redis5 = new GenericContainer("bitnami/redis-cluster:latest").withEnv("REDIS_PORT_NUMBER", "6375").withNetworkMode("host").withLogConsumer(x -> log.warn("{}", ((OutputFrame) x).getUtf8StringWithoutLineEnding())).withEnv("ALLOW_EMPTY_PASSWORD", "yes").withEnv("REDIS_NODES", nodes); + @ClassRule(order = 6) + public static GenericContainer redis6 = new GenericContainer("bitnami/redis-cluster:latest").withEnv("REDIS_PORT_NUMBER", "6376").withNetworkMode("host").withLogConsumer(x -> log.warn("{}", ((OutputFrame) x).getUtf8StringWithoutLineEnding())).withEnv("ALLOW_EMPTY_PASSWORD", "yes").withEnv("REDIS_NODES", nodes); + + + @ClassRule(order = 100) + public static ExternalResource resource = new ExternalResource() { + @Override + protected void before() throws Throwable { + redis1.start(); + redis2.start(); + redis3.start(); + redis4.start(); + redis5.start(); + redis6.start(); + + Thread.sleep(TimeUnit.SECONDS.toMillis(5)); // otherwise not all containers have time to start + + String clusterCreateCommand = "echo yes | redis-cli --cluster create " + + "127.0.0.1:6371 127.0.0.1:6372 127.0.0.1:6373 127.0.0.1:6374 127.0.0.1:6375 127.0.0.1:6376 " + + "--cluster-replicas 1"; + log.warn("Command to init Redis Cluster: {}", clusterCreateCommand); + var result = redis6.execInContainer("/bin/sh", "-c", clusterCreateCommand); + log.warn("Init cluster result: {}", result); + + Thread.sleep(TimeUnit.SECONDS.toMillis(5)); // otherwise cluster not always ready + + log.warn("Connect to nodes: {}", nodes); + System.setProperty("cache.type", "redis"); + System.setProperty("redis.connection.type", "cluster"); + System.setProperty("redis.cluster.nodes", nodes); + System.setProperty("redis.cluster.useDefaultPoolConfig", "false"); + } + + @Override + protected void after() { + redis1.stop(); + redis2.stop(); + redis3.stop(); + redis4.stop(); + redis5.stop(); + redis6.stop(); + List.of("cache.type", "redis.connection.type", "redis.cluster.nodes", "redis.cluster.useDefaultPoolConfig\"") + .forEach(System.getProperties()::remove); + } + }; + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/PostgreSqlInitializer.java b/dao/src/test/java/org/thingsboard/server/dao/PostgreSqlInitializer.java index 4feef028d4..80938b5724 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/PostgreSqlInitializer.java +++ b/dao/src/test/java/org/thingsboard/server/dao/PostgreSqlInitializer.java @@ -63,4 +63,5 @@ public class PostgreSqlInitializer { throw new RuntimeException("Unable to clean up the Postgres database. Reason: " + e.getMessage(), e); } } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/RedisClusterSqlTestSuite.java b/dao/src/test/java/org/thingsboard/server/dao/RedisClusterSqlTestSuite.java new file mode 100644 index 0000000000..be0bcc6fc7 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/RedisClusterSqlTestSuite.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao; + +import org.junit.extensions.cpsuite.ClasspathSuite; +import org.junit.extensions.cpsuite.ClasspathSuite.ClassnameFilters; +import org.junit.runner.RunWith; + +@RunWith(ClasspathSuite.class) +@ClassnameFilters( + //All the same tests using redis instead of caffeine. + { + "org.thingsboard.server.dao.service.*ServiceSqlTest", + } +) +public class RedisClusterSqlTestSuite extends AbstractRedisClusterContainer { + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/RedisJUnit5Test.java b/dao/src/test/java/org/thingsboard/server/dao/RedisJUnit5Test.java new file mode 100644 index 0000000000..43e788cccb --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/RedisJUnit5Test.java @@ -0,0 +1,64 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +@Slf4j +public class RedisJUnit5Test { + + @Container + private static final GenericContainer REDIS = new GenericContainer("redis:7.2-bookworm") + .withLogConsumer(s -> log.error(((OutputFrame) s).getUtf8String().trim())) + .withExposedPorts(6379); + + @BeforeAll + static void beforeAll() { + log.warn("Starting redis..."); + REDIS.start(); + System.setProperty("cache.type", "redis"); + System.setProperty("redis.connection.type", "standalone"); + System.setProperty("redis.standalone.host", REDIS.getHost()); + System.setProperty("redis.standalone.port", String.valueOf(REDIS.getMappedPort(6379))); + + } + + @AfterAll + static void afterAll() { + List.of("cache.type", "redis.connection.type", "redis.standalone.host", "redis.standalone.port") + .forEach(System.getProperties()::remove); + REDIS.stop(); + log.warn("Redis is stopped"); + } + + @Test + void test() { + assertThat(REDIS.isRunning()).isTrue(); + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/cache/RedisTbTransactionalCacheTest.java b/dao/src/test/java/org/thingsboard/server/dao/cache/RedisTbTransactionalCacheTest.java new file mode 100644 index 0000000000..12b6f5d7d1 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/cache/RedisTbTransactionalCacheTest.java @@ -0,0 +1,82 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.cache; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.thingsboard.server.cache.CacheSpecsMap; +import org.thingsboard.server.cache.RedisSslCredentials; +import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.dao.relation.RelationCacheKey; +import org.thingsboard.server.dao.relation.RelationRedisCache; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = {RelationRedisCache.class, CacheSpecsMap.class, TBRedisCacheConfiguration.class}) +@TestPropertySource(properties = { + "cache.type=redis", + "cache.specs.relations.timeToLiveInMinutes=1440", + "cache.specs.relations.maxSize=0", +}) +@Slf4j +public class RedisTbTransactionalCacheTest { + + @MockBean + private RelationRedisCache relationRedisCache; + @MockBean + private RedisConnectionFactory connectionFactory; + @MockBean + private RedisConnection redisConnection; + @MockBean + private RedisSslCredentials redisSslCredentials; + + @Test + public void testNoOpWhenCacheDisabled() { + when(connectionFactory.getConnection()).thenReturn(redisConnection); + + relationRedisCache.put(createRelationCacheKey(), null); + relationRedisCache.putIfAbsent(createRelationCacheKey(), null); + relationRedisCache.evict(createRelationCacheKey()); + relationRedisCache.evict(List.of(createRelationCacheKey())); + relationRedisCache.getAndPutInTransaction(createRelationCacheKey(), null, false); + relationRedisCache.getAndPutInTransaction(createRelationCacheKey(), null, null, null, false); + relationRedisCache.getOrFetchFromDB(createRelationCacheKey(), null, false, false); + + verify(connectionFactory, never()).getConnection(); + verifyNoInteractions(redisConnection); + } + + private RelationCacheKey createRelationCacheKey() { + return new RelationCacheKey(new DeviceId(UUID.randomUUID()), new DeviceId(UUID.randomUUID()), null, RelationTypeGroup.COMMON); + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java index 6e5c7f3030..aa1c96d84b 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java @@ -58,7 +58,6 @@ import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.user.UserService; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutionException; @@ -245,8 +244,8 @@ public class AlarmServiceTest extends AbstractServiceTest { // Check child relation PageData alarms = alarmService.findAlarmsV2(tenantId, AlarmQueryV2.builder() .affectedEntityId(childId) - .severityList(Arrays.asList(AlarmSeverity.CRITICAL)) - .statusList(Arrays.asList(AlarmSearchStatus.ACTIVE, AlarmSearchStatus.UNACK)).pageLink( + .severityList(List.of(AlarmSeverity.CRITICAL)) + .statusList(List.of(AlarmSearchStatus.ACTIVE, AlarmSearchStatus.UNACK)).pageLink( new TimePageLink(1, 0, "", new SortOrder("createdTime", SortOrder.Direction.DESC), 0L, System.currentTimeMillis()) ).build()); @@ -257,8 +256,8 @@ public class AlarmServiceTest extends AbstractServiceTest { // Check parent relation alarms = alarmService.findAlarmsV2(tenantId, AlarmQueryV2.builder() .affectedEntityId(parentId) - .severityList(Arrays.asList(AlarmSeverity.CRITICAL)) - .statusList(Arrays.asList(AlarmSearchStatus.ACTIVE, AlarmSearchStatus.UNACK)).pageLink( + .severityList(List.of(AlarmSeverity.CRITICAL)) + .statusList(List.of(AlarmSearchStatus.ACTIVE, AlarmSearchStatus.UNACK)).pageLink( new TimePageLink(1, 0, "", new SortOrder("createdTime", SortOrder.Direction.DESC), 0L, System.currentTimeMillis()) ).build()); @@ -272,8 +271,8 @@ public class AlarmServiceTest extends AbstractServiceTest { // Check child relation alarms = alarmService.findAlarmsV2(tenantId, AlarmQueryV2.builder() .affectedEntityId(childId) - .severityList(Arrays.asList(AlarmSeverity.CRITICAL)) - .statusList(Arrays.asList(AlarmSearchStatus.ACTIVE, AlarmSearchStatus.UNACK)).pageLink( + .severityList(List.of(AlarmSeverity.CRITICAL)) + .statusList(List.of(AlarmSearchStatus.ACTIVE, AlarmSearchStatus.UNACK)).pageLink( new TimePageLink(1, 0, "", new SortOrder("createdTime", SortOrder.Direction.DESC), 0L, System.currentTimeMillis()) ).build()); @@ -284,8 +283,8 @@ public class AlarmServiceTest extends AbstractServiceTest { // Check parent relation alarms = alarmService.findAlarmsV2(tenantId, AlarmQueryV2.builder() .affectedEntityId(parentId) - .severityList(Arrays.asList(AlarmSeverity.CRITICAL)) - .statusList(Arrays.asList(AlarmSearchStatus.ACTIVE, AlarmSearchStatus.UNACK)).pageLink( + .severityList(List.of(AlarmSeverity.CRITICAL)) + .statusList(List.of(AlarmSearchStatus.ACTIVE, AlarmSearchStatus.UNACK)).pageLink( new TimePageLink(1, 0, "", new SortOrder("createdTime", SortOrder.Direction.DESC), 0L, System.currentTimeMillis()) ).build()); @@ -298,8 +297,8 @@ public class AlarmServiceTest extends AbstractServiceTest { alarms = alarmService.findAlarmsV2(tenantId, AlarmQueryV2.builder() .affectedEntityId(childId) - .severityList(Arrays.asList(AlarmSeverity.CRITICAL)) - .statusList(Arrays.asList(AlarmSearchStatus.ACTIVE, AlarmSearchStatus.ACK)).pageLink( + .severityList(List.of(AlarmSeverity.CRITICAL)) + .statusList(List.of(AlarmSearchStatus.ACTIVE, AlarmSearchStatus.ACK)).pageLink( new TimePageLink(1, 0, "", new SortOrder("createdTime", SortOrder.Direction.DESC), 0L, System.currentTimeMillis()) ).build()); @@ -310,8 +309,8 @@ public class AlarmServiceTest extends AbstractServiceTest { // Check not existing relation alarms = alarmService.findAlarmsV2(tenantId, AlarmQueryV2.builder() .affectedEntityId(childId) - .severityList(Arrays.asList(AlarmSeverity.CRITICAL)) - .statusList(Arrays.asList(AlarmSearchStatus.ACTIVE, AlarmSearchStatus.UNACK)).pageLink( + .severityList(List.of(AlarmSeverity.CRITICAL)) + .statusList(List.of(AlarmSearchStatus.ACTIVE, AlarmSearchStatus.UNACK)).pageLink( new TimePageLink(1, 0, "", new SortOrder("createdTime", SortOrder.Direction.DESC), 0L, System.currentTimeMillis()) ).build()); @@ -323,8 +322,8 @@ public class AlarmServiceTest extends AbstractServiceTest { alarms = alarmService.findAlarmsV2(tenantId, AlarmQueryV2.builder() .affectedEntityId(childId) - .severityList(Arrays.asList(AlarmSeverity.CRITICAL)) - .statusList(Arrays.asList(AlarmSearchStatus.CLEARED, AlarmSearchStatus.ACK)).pageLink( + .severityList(List.of(AlarmSeverity.CRITICAL)) + .statusList(List.of(AlarmSearchStatus.CLEARED, AlarmSearchStatus.ACK)).pageLink( new TimePageLink(1, 0, "", new SortOrder("createdTime", SortOrder.Direction.DESC), 0L, System.currentTimeMillis()) ).build()); @@ -334,14 +333,14 @@ public class AlarmServiceTest extends AbstractServiceTest { } @Test - public void testFindAssignedAlarm() throws ExecutionException, InterruptedException { + public void testFindAssignedAlarm() { AssetId parentId = new AssetId(Uuids.timeBased()); AssetId childId = new AssetId(Uuids.timeBased()); EntityRelation relation = new EntityRelation(parentId, childId, EntityRelation.CONTAINS_TYPE); - Assert.assertTrue(relationService.saveRelation(tenantId, relation)); + Assert.assertNotNull(relationService.saveRelation(tenantId, relation)); long ts = System.currentTimeMillis(); AlarmApiCallResult result = alarmService.createAlarm(AlarmCreateOrUpdateActiveRequest.builder() @@ -368,7 +367,6 @@ public class AlarmServiceTest extends AbstractServiceTest { PageData alarms = alarmService.findAlarms(tenantId, AlarmQuery.builder() .assigneeId(tenantUser.getId()) - .fetchOriginator(true) .pageLink(new TimePageLink(1, 0, "", new SortOrder("createdTime", SortOrder.Direction.DESC), 0L, System.currentTimeMillis()) ).build()); @@ -405,7 +403,7 @@ public class AlarmServiceTest extends AbstractServiceTest { } @Test - public void testFindCustomerAlarm() throws ExecutionException, InterruptedException { + public void testFindCustomerAlarm() { Customer customer = new Customer(); customer.setTitle("TestCustomer"); customer.setTenantId(tenantId); @@ -451,10 +449,10 @@ public class AlarmServiceTest extends AbstractServiceTest { pageLink.setStartTs(0L); pageLink.setEndTs(System.currentTimeMillis()); pageLink.setSearchPropagatedAlarms(true); - pageLink.setSeverityList(Arrays.asList(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); - pageLink.setStatusList(Arrays.asList(AlarmSearchStatus.ACTIVE)); + pageLink.setSeverityList(List.of(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); + pageLink.setStatusList(List.of(AlarmSearchStatus.ACTIVE)); - PageData tenantAlarms = alarmService.findAlarmDataByQueryForEntities(tenantId, toQuery(pageLink), Arrays.asList(tenantDevice.getId(), customerDevice.getId())); + PageData tenantAlarms = alarmService.findAlarmDataByQueryForEntities(tenantId, toQuery(pageLink), List.of(tenantDevice.getId(), customerDevice.getId())); Assert.assertEquals(2, tenantAlarms.getData().size()); PageData customerAlarms = alarmService.findAlarmDataByQueryForEntities(tenantId, toQuery(pageLink), Collections.singletonList(customerDevice.getId())); @@ -473,7 +471,7 @@ public class AlarmServiceTest extends AbstractServiceTest { } @Test - public void testFindPropagatedCustomerAssetAlarm() throws ExecutionException, InterruptedException { + public void testFindPropagatedCustomerAssetAlarm() { Customer customer = new Customer(); customer.setTitle("TestCustomer"); customer.setTenantId(tenantId); @@ -525,8 +523,8 @@ public class AlarmServiceTest extends AbstractServiceTest { pageLink.setStartTs(0L); pageLink.setEndTs(System.currentTimeMillis()); pageLink.setSearchPropagatedAlarms(true); - pageLink.setSeverityList(Arrays.asList(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); - pageLink.setStatusList(Arrays.asList(AlarmSearchStatus.ACTIVE)); + pageLink.setSeverityList(List.of(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); + pageLink.setStatusList(List.of(AlarmSearchStatus.ACTIVE)); //TEST that propagated alarms are visible on the asset level. PageData customerAlarms = alarmService.findAlarmDataByQueryForEntities(tenantId, toQuery(pageLink), Collections.singletonList(customerAsset.getId())); @@ -576,7 +574,7 @@ public class AlarmServiceTest extends AbstractServiceTest { pageLink.setStartTs(0L); pageLink.setEndTs(System.currentTimeMillis()); pageLink.setSearchPropagatedAlarms(true); - pageLink.setSeverityList(Arrays.asList(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); + pageLink.setSeverityList(List.of(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); pageLink.setStatusList(Collections.singletonList(AlarmSearchStatus.ACTIVE)); //TEST that propagated alarms are visible on the asset level. @@ -599,7 +597,7 @@ public class AlarmServiceTest extends AbstractServiceTest { } @Test - public void testFindHighestAlarmSeverity() throws ExecutionException, InterruptedException { + public void testFindHighestAlarmSeverity() { Customer customer = new Customer(); customer.setTitle("TestCustomer"); customer.setTenantId(tenantId); @@ -679,8 +677,8 @@ public class AlarmServiceTest extends AbstractServiceTest { pageLink.setStartTs(0L); pageLink.setEndTs(System.currentTimeMillis()); pageLink.setSearchPropagatedAlarms(false); - pageLink.setSeverityList(Arrays.asList(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); - pageLink.setStatusList(Arrays.asList(AlarmSearchStatus.ACTIVE)); + pageLink.setSeverityList(List.of(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); + pageLink.setStatusList(List.of(AlarmSearchStatus.ACTIVE)); PageData alarms = alarmService.findAlarmDataByQueryForEntities(tenantId, toQuery(pageLink), Collections.singletonList(childId)); @@ -695,8 +693,8 @@ public class AlarmServiceTest extends AbstractServiceTest { pageLink.setStartTs(0L); pageLink.setEndTs(System.currentTimeMillis()); pageLink.setSearchPropagatedAlarms(false); - pageLink.setSeverityList(Arrays.asList(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); - pageLink.setStatusList(Arrays.asList(AlarmSearchStatus.ACTIVE)); + pageLink.setSeverityList(List.of(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); + pageLink.setStatusList(List.of(AlarmSearchStatus.ACTIVE)); alarms = alarmService.findAlarmDataByQueryForEntities(tenantId, toQuery(pageLink), Collections.singletonList(childId)); Assert.assertNotNull(alarms.getData()); @@ -722,8 +720,8 @@ public class AlarmServiceTest extends AbstractServiceTest { pageLink.setStartTs(0L); pageLink.setEndTs(System.currentTimeMillis()); pageLink.setSearchPropagatedAlarms(true); - pageLink.setSeverityList(Arrays.asList(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); - pageLink.setStatusList(Arrays.asList(AlarmSearchStatus.ACTIVE)); + pageLink.setSeverityList(List.of(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); + pageLink.setStatusList(List.of(AlarmSearchStatus.ACTIVE)); alarms = alarmService.findAlarmDataByQueryForEntities(tenantId, toQuery(pageLink), Collections.singletonList(childId)); Assert.assertNotNull(alarms.getData()); @@ -738,8 +736,8 @@ public class AlarmServiceTest extends AbstractServiceTest { pageLink.setStartTs(0L); pageLink.setEndTs(System.currentTimeMillis()); pageLink.setSearchPropagatedAlarms(true); - pageLink.setSeverityList(Arrays.asList(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); - pageLink.setStatusList(Arrays.asList(AlarmSearchStatus.ACTIVE)); + pageLink.setSeverityList(List.of(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); + pageLink.setStatusList(List.of(AlarmSearchStatus.ACTIVE)); alarms = alarmService.findAlarmDataByQueryForEntities(tenantId, toQuery(pageLink), Collections.singletonList(parentId)); Assert.assertNotNull(alarms.getData()); @@ -748,7 +746,6 @@ public class AlarmServiceTest extends AbstractServiceTest { PageData alarmsInfoData = alarmService.findAlarms(tenantId, AlarmQuery.builder() .affectedEntityId(childId) - .fetchOriginator(true) .status(AlarmStatus.ACTIVE_UNACK).pageLink( new TimePageLink(10, 0, "", new SortOrder("createdTime", SortOrder.Direction.DESC), 0L, System.currentTimeMillis()) @@ -759,7 +756,6 @@ public class AlarmServiceTest extends AbstractServiceTest { alarmsInfoData = alarmService.findAlarms(tenantId, AlarmQuery.builder() .affectedEntityId(parentId) - .fetchOriginator(true) .status(AlarmStatus.ACTIVE_UNACK).pageLink( new TimePageLink(10, 0, "", new SortOrder("createdTime", SortOrder.Direction.DESC), 0L, System.currentTimeMillis()) @@ -770,7 +766,6 @@ public class AlarmServiceTest extends AbstractServiceTest { alarmsInfoData = alarmService.findAlarms(tenantId, AlarmQuery.builder() .affectedEntityId(parentId2) - .fetchOriginator(true) .status(AlarmStatus.ACTIVE_UNACK).pageLink( new TimePageLink(10, 0, "", new SortOrder("createdTime", SortOrder.Direction.DESC), 0L, System.currentTimeMillis()) @@ -786,8 +781,8 @@ public class AlarmServiceTest extends AbstractServiceTest { pageLink.setStartTs(0L); pageLink.setEndTs(System.currentTimeMillis()); pageLink.setSearchPropagatedAlarms(true); - pageLink.setSeverityList(Arrays.asList(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); - pageLink.setStatusList(Arrays.asList(AlarmSearchStatus.ACTIVE)); + pageLink.setSeverityList(List.of(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); + pageLink.setStatusList(List.of(AlarmSearchStatus.ACTIVE)); alarms = alarmService.findAlarmDataByQueryForEntities(tenantId, toQuery(pageLink), Collections.singletonList(parentId)); Assert.assertNotNull(alarms.getData()); @@ -803,8 +798,8 @@ public class AlarmServiceTest extends AbstractServiceTest { pageLink.setStartTs(0L); pageLink.setEndTs(System.currentTimeMillis()); pageLink.setSearchPropagatedAlarms(true); - pageLink.setSeverityList(Arrays.asList(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); - pageLink.setStatusList(Arrays.asList(AlarmSearchStatus.ACTIVE)); + pageLink.setSeverityList(List.of(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)); + pageLink.setStatusList(List.of(AlarmSearchStatus.ACTIVE)); alarms = alarmService.findAlarmDataByQueryForEntities(tenantId, toQuery(pageLink), Collections.singletonList(childId)); Assert.assertNotNull(alarms.getData()); @@ -813,7 +808,7 @@ public class AlarmServiceTest extends AbstractServiceTest { } @Test - public void testCountAlarmsUsingAlarmDataQuery() throws ExecutionException, InterruptedException { + public void testCountAlarmsUsingAlarmDataQuery() { AssetId childId = new AssetId(Uuids.timeBased()); long ts = System.currentTimeMillis(); @@ -829,7 +824,7 @@ public class AlarmServiceTest extends AbstractServiceTest { .startTs(0L) .endTs(System.currentTimeMillis()) .searchPropagatedAlarms(false) - .severityList(Arrays.asList(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)) + .severityList(List.of(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)) .statusList(List.of(AlarmSearchStatus.ACTIVE)) .build(); @@ -841,7 +836,7 @@ public class AlarmServiceTest extends AbstractServiceTest { .startTs(0L) .endTs(System.currentTimeMillis()) .searchPropagatedAlarms(true) - .severityList(Arrays.asList(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)) + .severityList(List.of(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)) .statusList(List.of(AlarmSearchStatus.ACTIVE)) .build(); @@ -866,7 +861,7 @@ public class AlarmServiceTest extends AbstractServiceTest { .startTs(0L) .endTs(System.currentTimeMillis()) .searchPropagatedAlarms(true) - .severityList(Arrays.asList(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)) + .severityList(List.of(AlarmSeverity.CRITICAL, AlarmSeverity.WARNING)) .statusList(List.of(AlarmSearchStatus.ACTIVE, AlarmSearchStatus.CLEARED)) .build(); @@ -882,7 +877,7 @@ public class AlarmServiceTest extends AbstractServiceTest { EntityRelation relation = new EntityRelation(parentId, childId, EntityRelation.CONTAINS_TYPE); - Assert.assertTrue(relationService.saveRelation(tenantId, relation)); + Assert.assertNotNull(relationService.saveRelation(tenantId, relation)); long ts = System.currentTimeMillis(); AlarmApiCallResult result = alarmService.createAlarm(AlarmCreateOrUpdateActiveRequest.builder() @@ -939,6 +934,6 @@ public class AlarmServiceTest extends AbstractServiceTest { ).build()); Assert.assertNotNull(alarms.getData()); Assert.assertEquals(0, alarms.getData().size()); - } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/DeviceCredentialsServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/DeviceCredentialsServiceTest.java index 188763dc71..2be8cd12c5 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/DeviceCredentialsServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/DeviceCredentialsServiceTest.java @@ -180,7 +180,7 @@ public class DeviceCredentialsServiceTest extends AbstractServiceTest { Assert.assertEquals(savedDevice.getId(), deviceCredentials.getDeviceId()); deviceCredentials.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); deviceCredentials.setCredentialsId("access_token"); - deviceCredentialsService.updateDeviceCredentials(tenantId, deviceCredentials); + deviceCredentials = deviceCredentialsService.updateDeviceCredentials(tenantId, deviceCredentials); DeviceCredentials foundDeviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(tenantId, savedDevice.getId()); Assert.assertEquals(deviceCredentials, foundDeviceCredentials); deviceService.deleteDevice(tenantId, savedDevice.getId()); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java index 87463ebda6..9fe3f85463 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java @@ -33,13 +33,18 @@ import org.thingsboard.server.common.data.DeviceInfo; import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.HasOtaPackage; import org.thingsboard.server.common.data.OtaPackage; +import org.thingsboard.server.common.data.OtaPackageInfo; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.OtaPackageId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.ota.ChecksumAlgorithm; +import org.thingsboard.server.common.data.ota.OtaPackageType; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.DeviceCredentials; @@ -63,6 +68,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.thingsboard.server.common.data.ota.OtaPackageType.FIRMWARE; +import static org.thingsboard.server.common.data.ota.OtaPackageType.SOFTWARE; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; @DaoSqlTest @@ -202,6 +208,125 @@ public class DeviceServiceTest extends AbstractServiceTest { deleteDevice(anotherTenantId, anotherDevice); } + @Test + public void testCountDevicesWithoutFirmware() { + testCountDevicesWithoutOta(FIRMWARE); + } + + @Test + public void testCountDevicesWithoutSoftware() { + testCountDevicesWithoutOta(SOFTWARE); + } + + public void testCountDevicesWithoutOta(OtaPackageType type) { + var defaultDeviceProfile = deviceProfileService.findDefaultDeviceProfile(tenantId); + var deviceProfileId = defaultDeviceProfile.getId(); + Assert.assertEquals(0, deviceService.countByTenantId(tenantId)); + Assert.assertEquals(0, deviceService.countDevicesByTenantIdAndDeviceProfileIdAndEmptyOtaPackage(tenantId, deviceProfileId, type)); + + int maxDevices = 8; + List devices = new ArrayList<>(maxDevices); + + for (int i = 1; i <= maxDevices; i++) { + devices.add(this.saveDevice(tenantId, "My device " + i)); + Assert.assertEquals(i, deviceService.countDevicesByTenantIdAndDeviceProfileIdAndEmptyOtaPackage(tenantId, deviceProfileId, type)); + } + + Assert.assertEquals(maxDevices, deviceService.countDevicesByTenantIdAndDeviceProfileIdAndEmptyOtaPackage(tenantId, deviceProfileId, type)); + + var otaPackageId = createOta(deviceProfileId, type); + + int devicesWithOta = maxDevices / 2; + + for (int i = 0; i < devicesWithOta; i++) { + var device = devices.get(i); + setOtaPackageId(device, type, otaPackageId); + deviceService.saveDevice(device); + } + + Assert.assertEquals(maxDevices - devicesWithOta, deviceService.countDevicesByTenantIdAndDeviceProfileIdAndEmptyOtaPackage(tenantId, deviceProfileId, type)); + + devices.forEach(device -> deleteDevice(tenantId, device)); + } + + @Test + public void testFindDevicesWithoutFirmware() { + testFindDevicesWithoutOta(FIRMWARE); + } + + @Test + public void testFindDevicesWithoutSoftware() { + testFindDevicesWithoutOta(SOFTWARE); + } + + public void testFindDevicesWithoutOta(OtaPackageType type) { + var defaultDeviceProfile = deviceProfileService.findDefaultDeviceProfile(tenantId); + var deviceProfileId = defaultDeviceProfile.getId(); + + PageLink pageLink = new PageLink(100); + + Assert.assertEquals(0, deviceService.countByTenantId(tenantId)); + Assert.assertEquals(0, deviceService.findDevicesByTenantIdAndTypeAndEmptyOtaPackage(tenantId, deviceProfileId, type, pageLink).getData().size()); + + int maxDevices = 8; + List devices = new ArrayList<>(maxDevices); + + for (int i = 1; i <= maxDevices; i++) { + devices.add(this.saveDevice(tenantId, "My device " + i)); + } + + var foundDevices = deviceService.findDevicesByTenantIdAndTypeAndEmptyOtaPackage(tenantId, deviceProfileId, type, pageLink).getData(); + Assert.assertEquals(maxDevices, foundDevices.size()); + + devices.sort(idComparator); + foundDevices.sort(idComparator); + + Assert.assertEquals(devices, foundDevices); + + var otaPackageId = createOta(deviceProfileId, type); + + int devicesWithOta = maxDevices / 2; + + for (int i = 0; i < devicesWithOta; i++) { + var device = devices.get(i); + setOtaPackageId(device, type, otaPackageId); + deviceService.saveDevice(device); + } + + foundDevices = deviceService.findDevicesByTenantIdAndTypeAndEmptyOtaPackage(tenantId, deviceProfileId, type, pageLink).getData(); + + Assert.assertEquals(maxDevices - devicesWithOta, foundDevices.size()); + + foundDevices.sort(idComparator); + + for (int i = 0; i < foundDevices.size(); i++) { + Assert.assertEquals(devices.get(i + devicesWithOta), foundDevices.get(i)); + } + + devices.forEach(device -> deleteDevice(tenantId, device)); + } + + private void setOtaPackageId(T obj, OtaPackageType type, OtaPackageId otaPackageId) { + switch (type) { + case FIRMWARE -> obj.setFirmwareId(otaPackageId); + case SOFTWARE -> obj.setSoftwareId(otaPackageId); + } + } + + private OtaPackageId createOta(DeviceProfileId deviceProfileId, OtaPackageType type) { + OtaPackageInfo ota = new OtaPackageInfo(); + ota.setTenantId(tenantId); + ota.setDeviceProfileId(deviceProfileId); + ota.setType(type); + ota.setTitle("Test_" + type); + ota.setVersion("v1.0"); + ota.setUrl("http://ota.test.org"); + ota.setDataSize(0L); + OtaPackageInfo savedOta = otaPackageService.saveOtaPackageInfo(ota, true); + Assert.assertNotNull(savedOta); + return savedOta.getId(); + } + void deleteDevice(TenantId tenantId, Device device) { deviceService.deleteDevice(tenantId, device.getId()); } @@ -368,7 +493,7 @@ public class DeviceServiceTest extends AbstractServiceTest { Device device = new Device(); device.setType(deviceProfile.getName()); device.setTenantId(tenantId); - device.setName("My device"+ StringUtils.randomAlphabetic(5)); + device.setName("My device" + StringUtils.randomAlphabetic(5)); DefaultTransactionDefinition def = new DefaultTransactionDefinition(); TransactionStatus status = platformTransactionManager.getTransaction(def); @@ -945,8 +1070,8 @@ public class DeviceServiceTest extends AbstractServiceTest { deviceInfosWithLabel.stream() .anyMatch( d -> d.getId().equals(savedDevice.getId()) - && d.getTenantId().equals(tenantId) - && d.getLabel().equals(savedDevice.getLabel()) + && d.getTenantId().equals(tenantId) + && d.getLabel().equals(savedDevice.getLabel()) ) ); @@ -1004,9 +1129,9 @@ public class DeviceServiceTest extends AbstractServiceTest { deviceInfosWithLabel.stream() .anyMatch( d -> d.getId().equals(savedDevice.getId()) - && d.getTenantId().equals(tenantId) - && d.getDeviceProfileName().equals(savedDevice.getType()) - && d.getLabel().equals(savedDevice.getLabel()) + && d.getTenantId().equals(tenantId) + && d.getDeviceProfileName().equals(savedDevice.getType()) + && d.getLabel().equals(savedDevice.getLabel()) ) ); @@ -1072,4 +1197,5 @@ public class DeviceServiceTest extends AbstractServiceTest { ) ); } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceTest.java index 1fc2ff2ce6..6c87b0128f 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceTest.java @@ -412,7 +412,7 @@ public class EntityServiceTest extends AbstractServiceTest { List highTemperatures = new ArrayList<>(); createTestHierarchy(tenantId, assets, devices, new ArrayList<>(), new ArrayList<>(), temperatures, highTemperatures); - List>> attributeFutures = new ArrayList<>(); + List>> attributeFutures = new ArrayList<>(); for (int i = 0; i < devices.size(); i++) { Device device = devices.get(i); attributeFutures.add(saveLongAttribute(device.getId(), "temperature", temperatures.get(i), AttributeScope.CLIENT_SCOPE)); @@ -591,7 +591,7 @@ public class EntityServiceTest extends AbstractServiceTest { List highTemperatures = new ArrayList<>(); createTestHierarchy(tenantId, assets, devices, new ArrayList<>(), new ArrayList<>(), temperatures, highTemperatures); - List>> attributeFutures = new ArrayList<>(); + List>> attributeFutures = new ArrayList<>(); for (int i = 0; i < devices.size(); i++) { Device device = devices.get(i); attributeFutures.add(saveLongAttribute(device.getId(), "temperature", temperatures.get(i), AttributeScope.CLIENT_SCOPE)); @@ -666,7 +666,7 @@ public class EntityServiceTest extends AbstractServiceTest { List highConsumptions = new ArrayList<>(); createTestHierarchy(tenantId, assets, devices, consumptions, highConsumptions, new ArrayList<>(), new ArrayList<>()); - List>> attributeFutures = new ArrayList<>(); + List>> attributeFutures = new ArrayList<>(); for (int i = 0; i < assets.size(); i++) { Asset asset = assets.get(i); attributeFutures.add(saveLongAttribute(asset.getId(), "consumption", consumptions.get(i), AttributeScope.SERVER_SCOPE)); @@ -1586,7 +1586,7 @@ public class EntityServiceTest extends AbstractServiceTest { } } - List>> attributeFutures = new ArrayList<>(); + List>> attributeFutures = new ArrayList<>(); for (int i = 0; i < devices.size(); i++) { Device device = devices.get(i); for (AttributeScope currentScope : AttributeScope.values()) { @@ -1688,7 +1688,7 @@ public class EntityServiceTest extends AbstractServiceTest { } } - List>> attributeFutures = new ArrayList<>(); + List>> attributeFutures = new ArrayList<>(); for (int i = 0; i < devices.size(); i++) { Device device = devices.get(i); attributeFutures.add(saveLongAttribute(device.getId(), "temperature", temperatures.get(i), AttributeScope.CLIENT_SCOPE)); @@ -1966,7 +1966,7 @@ public class EntityServiceTest extends AbstractServiceTest { } } - List>> attributeFutures = new ArrayList<>(); + List>> attributeFutures = new ArrayList<>(); for (int i = 0; i < devices.size(); i++) { Device device = devices.get(i); attributeFutures.add(saveStringAttribute(device.getId(), "attributeString", attributeStrings.get(i), AttributeScope.CLIENT_SCOPE)); @@ -2428,13 +2428,13 @@ public class EntityServiceTest extends AbstractServiceTest { return filter; } - private ListenableFuture> saveLongAttribute(EntityId entityId, String key, long value, AttributeScope scope) { + private ListenableFuture> saveLongAttribute(EntityId entityId, String key, long value, AttributeScope scope) { KvEntry attrValue = new LongDataEntry(key, value); AttributeKvEntry attr = new BaseAttributeKvEntry(attrValue, 42L); return attributesService.save(SYSTEM_TENANT_ID, entityId, scope, Collections.singletonList(attr)); } - private ListenableFuture> saveStringAttribute(EntityId entityId, String key, String value, AttributeScope scope) { + private ListenableFuture> saveStringAttribute(EntityId entityId, String key, String value, AttributeScope scope) { KvEntry attrValue = new StringDataEntry(key, value); AttributeKvEntry attr = new BaseAttributeKvEntry(attrValue, 42L); return attributesService.save(SYSTEM_TENANT_ID, entityId, scope, Collections.singletonList(attr)); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/RelationCacheTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/RelationCacheTest.java index 495f3c24e0..e86517930f 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/RelationCacheTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/RelationCacheTest.java @@ -85,8 +85,12 @@ public class RelationCacheTest extends AbstractServiceTest { @Test public void testDeleteRelations_EvictsCache() { + EntityRelation relation = new EntityRelation(ENTITY_ID_FROM, ENTITY_ID_TO, RELATION_TYPE); when(relationDao.getRelation(SYSTEM_TENANT_ID, ENTITY_ID_FROM, ENTITY_ID_TO, RELATION_TYPE, RelationTypeGroup.COMMON)) - .thenReturn(new EntityRelation(ENTITY_ID_FROM, ENTITY_ID_TO, RELATION_TYPE)); + .thenReturn(relation); + + when(relationDao.deleteRelation(SYSTEM_TENANT_ID, ENTITY_ID_FROM, ENTITY_ID_TO, RELATION_TYPE, RelationTypeGroup.COMMON)) + .thenReturn(relation); relationService.getRelation(SYSTEM_TENANT_ID, ENTITY_ID_FROM, ENTITY_ID_TO, RELATION_TYPE, RelationTypeGroup.COMMON); relationService.getRelation(SYSTEM_TENANT_ID, ENTITY_ID_FROM, ENTITY_ID_TO, RELATION_TYPE, RelationTypeGroup.COMMON); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java index 4e20744ef7..710a69fac9 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java @@ -57,13 +57,13 @@ public class RelationServiceTest extends AbstractServiceTest { } @Test - public void testSaveRelation() throws ExecutionException, InterruptedException { + public void testSaveRelation() { AssetId parentId = new AssetId(Uuids.timeBased()); AssetId childId = new AssetId(Uuids.timeBased()); EntityRelation relation = new EntityRelation(parentId, childId, EntityRelation.CONTAINS_TYPE); - Assert.assertTrue(saveRelation(relation)); + Assert.assertNotNull(saveRelation(relation)); Assert.assertTrue(relationService.checkRelation(SYSTEM_TENANT_ID, parentId, childId, EntityRelation.CONTAINS_TYPE, RelationTypeGroup.COMMON)); @@ -204,8 +204,8 @@ public class RelationServiceTest extends AbstractServiceTest { Assert.assertEquals(0, relations.size()); } - private Boolean saveRelation(EntityRelation relationA1) { - return relationService.saveRelation(SYSTEM_TENANT_ID, relationA1); + private EntityRelation saveRelation(EntityRelation relation) { + return relationService.saveRelation(SYSTEM_TENANT_ID, relation); } @Test @@ -265,9 +265,9 @@ public class RelationServiceTest extends AbstractServiceTest { EntityRelation relationB = new EntityRelation(assetB, assetC, EntityRelation.CONTAINS_TYPE); EntityRelation relationC = new EntityRelation(assetC, assetA, EntityRelation.CONTAINS_TYPE); - saveRelation(relationA); - saveRelation(relationB); - saveRelation(relationC); + relationA = saveRelation(relationA); + relationB = saveRelation(relationB); + relationC = saveRelation(relationC); EntityRelationsQuery query = new EntityRelationsQuery(); query.setParameters(new RelationsSearchParameters(assetA, EntitySearchDirection.FROM, -1, false)); @@ -299,8 +299,8 @@ public class RelationServiceTest extends AbstractServiceTest { EntityRelation relationBD = new EntityRelation(assetB, deviceD, EntityRelation.CONTAINS_TYPE); - saveRelation(relationAB); - saveRelation(relationBC); + relationAB = saveRelation(relationAB); + relationBC = saveRelation(relationBC); saveRelation(relationBD); EntityRelationsQuery query = new EntityRelationsQuery(); @@ -329,26 +329,20 @@ public class RelationServiceTest extends AbstractServiceTest { EntityRelation relationAB = new EntityRelation(root, left, EntityRelation.CONTAINS_TYPE); EntityRelation relationBC = new EntityRelation(root, right, EntityRelation.CONTAINS_TYPE); - saveRelation(relationAB); - expected.add(relationAB); - - saveRelation(relationBC); - expected.add(relationBC); + expected.add(saveRelation(relationAB)); + expected.add(saveRelation(relationBC)); for (int i = 0; i < maxLevel; i++) { var newLeft = new AssetId(Uuids.timeBased()); var newRight = new AssetId(Uuids.timeBased()); EntityRelation relationLeft = new EntityRelation(left, newLeft, EntityRelation.CONTAINS_TYPE); EntityRelation relationRight = new EntityRelation(right, newRight, EntityRelation.CONTAINS_TYPE); - saveRelation(relationLeft); - expected.add(relationLeft); - saveRelation(relationRight); - expected.add(relationRight); + expected.add(saveRelation(relationLeft)); + expected.add(saveRelation(relationRight)); left = newLeft; right = newRight; } - EntityRelationsQuery query = new EntityRelationsQuery(); query.setParameters(new RelationsSearchParameters(root, EntitySearchDirection.FROM, -1, false)); query.setFilters(Collections.singletonList(new RelationEntityTypeFilter(EntityRelation.CONTAINS_TYPE, Collections.singletonList(EntityType.ASSET)))); @@ -372,7 +366,7 @@ public class RelationServiceTest extends AbstractServiceTest { relation.setTo(new AssetId(Uuids.timeBased())); relation.setType(EntityRelation.CONTAINS_TYPE); Assertions.assertThrows(DataValidationException.class, () -> { - Assert.assertTrue(saveRelation(relation)); + Assert.assertNotNull(saveRelation(relation)); }); } @@ -382,7 +376,7 @@ public class RelationServiceTest extends AbstractServiceTest { relation.setFrom(new AssetId(Uuids.timeBased())); relation.setType(EntityRelation.CONTAINS_TYPE); Assertions.assertThrows(DataValidationException.class, () -> { - Assert.assertTrue(saveRelation(relation)); + Assert.assertNotNull(saveRelation(relation)); }); } @@ -392,7 +386,7 @@ public class RelationServiceTest extends AbstractServiceTest { relation.setFrom(new AssetId(Uuids.timeBased())); relation.setTo(new AssetId(Uuids.timeBased())); Assertions.assertThrows(DataValidationException.class, () -> { - Assert.assertTrue(saveRelation(relation)); + Assert.assertNotNull(saveRelation(relation)); }); } @@ -414,10 +408,10 @@ public class RelationServiceTest extends AbstractServiceTest { EntityRelation relationC = new EntityRelation(assetC, assetD, EntityRelation.CONTAINS_TYPE); EntityRelation relationD = new EntityRelation(assetC, assetE, EntityRelation.CONTAINS_TYPE); - saveRelation(relationA); - saveRelation(relationB); - saveRelation(relationC); - saveRelation(relationD); + relationA = saveRelation(relationA); + relationB = saveRelation(relationB); + relationC = saveRelation(relationC); + relationD = saveRelation(relationD); EntityRelationsQuery query = new EntityRelationsQuery(); query.setParameters(new RelationsSearchParameters(assetA, EntitySearchDirection.FROM, -1, true)); @@ -450,9 +444,9 @@ public class RelationServiceTest extends AbstractServiceTest { EntityRelation relationB = new EntityRelation(assetB, assetC, EntityRelation.CONTAINS_TYPE); EntityRelation relationC = new EntityRelation(assetC, assetD, EntityRelation.CONTAINS_TYPE); - saveRelation(relationA); - saveRelation(relationB); - saveRelation(relationC); + relationA = saveRelation(relationA); + relationB = saveRelation(relationB); + relationC = saveRelation(relationC); EntityRelationsQuery query = new EntityRelationsQuery(); query.setParameters(new RelationsSearchParameters(assetA, EntitySearchDirection.FROM, -1, true)); @@ -494,12 +488,12 @@ public class RelationServiceTest extends AbstractServiceTest { EntityRelation relationE = new EntityRelation(assetD, assetF, EntityRelation.CONTAINS_TYPE); EntityRelation relationF = new EntityRelation(assetD, assetG, EntityRelation.CONTAINS_TYPE); - saveRelation(relationA); - saveRelation(relationB); - saveRelation(relationC); - saveRelation(relationD); - saveRelation(relationE); - saveRelation(relationF); + relationA = saveRelation(relationA); + relationB = saveRelation(relationB); + relationC = saveRelation(relationC); + relationD = saveRelation(relationD); + relationE = saveRelation(relationE); + relationF = saveRelation(relationF); EntityRelationsQuery query = new EntityRelationsQuery(); query.setParameters(new RelationsSearchParameters(assetA, EntitySearchDirection.FROM, 2, true)); @@ -547,12 +541,12 @@ public class RelationServiceTest extends AbstractServiceTest { EntityRelation relationE = new EntityRelation(assetD, assetF, EntityRelation.CONTAINS_TYPE); EntityRelation relationF = new EntityRelation(assetD, assetG, EntityRelation.CONTAINS_TYPE); - saveRelation(relationA); - saveRelation(relationB); - saveRelation(relationC); - saveRelation(relationD); - saveRelation(relationE); - saveRelation(relationF); + relationA = saveRelation(relationA); + relationB = saveRelation(relationB); + relationC = saveRelation(relationC); + relationD = saveRelation(relationD); + relationE = saveRelation(relationE); + relationF = saveRelation(relationF); EntityRelationsQuery query = new EntityRelationsQuery(); query.setParameters(new RelationsSearchParameters(assetA, EntitySearchDirection.FROM, 2, false)); @@ -600,12 +594,12 @@ public class RelationServiceTest extends AbstractServiceTest { EntityRelation relationE = new EntityRelation(assetD, assetF, EntityRelation.CONTAINS_TYPE); EntityRelation relationF = new EntityRelation(assetD, assetG, EntityRelation.CONTAINS_TYPE); - saveRelation(relationA); - saveRelation(relationB); - saveRelation(relationC); - saveRelation(relationD); - saveRelation(relationE); - saveRelation(relationF); + relationA = saveRelation(relationA); + relationB = saveRelation(relationB); + relationC = saveRelation(relationC); + relationD = saveRelation(relationD); + relationE = saveRelation(relationE); + relationF = saveRelation(relationF); EntityRelationsQuery query = new EntityRelationsQuery(); query.setParameters(new RelationsSearchParameters(assetA, EntitySearchDirection.FROM, -1, false)); @@ -670,8 +664,8 @@ public class RelationServiceTest extends AbstractServiceTest { EntityRelation firstRelation = new EntityRelation(rootAsset, firstAsset, EntityRelation.CONTAINS_TYPE); EntityRelation secondRelation = new EntityRelation(rootAsset, secondAsset, EntityRelation.CONTAINS_TYPE); - saveRelation(firstRelation); - saveRelation(secondRelation); + firstRelation = saveRelation(firstRelation); + secondRelation = saveRelation(secondRelation); if (!lastLvlOnly || lvl == 1) { entityRelations.add(firstRelation); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/WidgetsBundleServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/WidgetsBundleServiceTest.java index 314a361dd2..d4c00fdf39 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/WidgetsBundleServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/WidgetsBundleServiceTest.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.common.data.widget.WidgetsBundleFilter; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.widget.WidgetsBundleService; @@ -174,7 +175,7 @@ public class WidgetsBundleServiceTest extends AbstractServiceTest { PageLink pageLink = new PageLink(19); PageData pageData = null; do { - pageData = widgetsBundleService.findSystemWidgetsBundlesByPageLink(tenantId, false, pageLink); + pageData = widgetsBundleService.findSystemWidgetsBundlesByPageLink(WidgetsBundleFilter.fromTenantId(tenantId), pageLink); loadedWidgetsBundles.addAll(pageData.getData()); if (pageData.hasNext()) { pageLink = pageLink.nextPageLink(); @@ -297,7 +298,7 @@ public class WidgetsBundleServiceTest extends AbstractServiceTest { PageLink pageLink = new PageLink(17); PageData pageData = null; do { - pageData = widgetsBundleService.findAllTenantWidgetsBundlesByTenantIdAndPageLink(tenantId, false, pageLink); + pageData = widgetsBundleService.findAllTenantWidgetsBundlesByTenantIdAndPageLink(WidgetsBundleFilter.fromTenantId(tenantId), pageLink); loadedWidgetsBundles.addAll(pageData.getData()); if (pageData.hasNext()) { pageLink = pageLink.nextPageLink(); @@ -314,7 +315,7 @@ public class WidgetsBundleServiceTest extends AbstractServiceTest { loadedWidgetsBundles.clear(); pageLink = new PageLink(14); do { - pageData = widgetsBundleService.findAllTenantWidgetsBundlesByTenantIdAndPageLink(tenantId, false, pageLink); + pageData = widgetsBundleService.findAllTenantWidgetsBundlesByTenantIdAndPageLink(WidgetsBundleFilter.fromTenantId(tenantId), pageLink); loadedWidgetsBundles.addAll(pageData.getData()); if (pageData.hasNext()) { pageLink = pageLink.nextPageLink(); @@ -336,7 +337,7 @@ public class WidgetsBundleServiceTest extends AbstractServiceTest { loadedWidgetsBundles.clear(); pageLink = new PageLink(18); do { - pageData = widgetsBundleService.findAllTenantWidgetsBundlesByTenantIdAndPageLink(tenantId, false, pageLink); + pageData = widgetsBundleService.findAllTenantWidgetsBundlesByTenantIdAndPageLink(WidgetsBundleFilter.fromTenantId(tenantId), pageLink); loadedWidgetsBundles.addAll(pageData.getData()); if (pageData.hasNext()) { pageLink = pageLink.nextPageLink(); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java index 98a8cf9d47..2d648f9db3 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java @@ -26,7 +26,6 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.thingsboard.server.cache.TbTransactionalCache; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; @@ -34,7 +33,6 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; -import org.thingsboard.server.dao.attributes.AttributeCacheKey; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.service.AbstractServiceTest; @@ -59,9 +57,6 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { private static final String OLD_VALUE = "OLD VALUE"; private static final String NEW_VALUE = "NEW VALUE"; - @Autowired - private TbTransactionalCache cache; - @Autowired private AttributesService attributesService; @@ -77,7 +72,7 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { attributesService.save(SYSTEM_TENANT_ID, deviceId, AttributeScope.CLIENT_SCOPE, Collections.singletonList(attr)).get(); Optional saved = attributesService.find(SYSTEM_TENANT_ID, deviceId, AttributeScope.CLIENT_SCOPE, attr.getKey()).get(); Assert.assertTrue(saved.isPresent()); - Assert.assertEquals(attr, saved.get()); + equalsIgnoreVersion(attr, saved.get()); } @Test @@ -90,14 +85,15 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { Optional saved = attributesService.find(SYSTEM_TENANT_ID, deviceId, AttributeScope.CLIENT_SCOPE, attrOld.getKey()).get(); Assert.assertTrue(saved.isPresent()); - Assert.assertEquals(attrOld, saved.get()); + equalsIgnoreVersion(attrOld, saved.get()); KvEntry attrNewValue = new StringDataEntry("attribute1", "value2"); AttributeKvEntry attrNew = new BaseAttributeKvEntry(attrNewValue, 73L); attributesService.save(SYSTEM_TENANT_ID, deviceId, AttributeScope.CLIENT_SCOPE, Collections.singletonList(attrNew)).get(); saved = attributesService.find(SYSTEM_TENANT_ID, deviceId, AttributeScope.CLIENT_SCOPE, attrOld.getKey()).get(); - Assert.assertEquals(attrNew, saved.get()); + Assert.assertTrue(saved.isPresent()); + equalsIgnoreVersion(attrNew, saved.get()); } @Test @@ -120,8 +116,8 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { Assert.assertNotNull(saved); Assert.assertEquals(2, saved.size()); - Assert.assertEquals(attrANew, saved.get(0)); - Assert.assertEquals(attrBNew, saved.get(1)); + equalsIgnoreVersion(attrANew, saved.get(0)); + equalsIgnoreVersion(attrBNew, saved.get(1)); } @Test @@ -132,24 +128,6 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { Assert.assertTrue(result.isEmpty()); } - @Test - public void testConcurrentTransaction() throws Exception { - var tenantId = new TenantId(UUID.randomUUID()); - var deviceId = new DeviceId(UUID.randomUUID()); - var scope = AttributeScope.SERVER_SCOPE; - var key = "TEST"; - - var attrKey = new AttributeCacheKey(scope, deviceId, "TEST"); - var oldValue = new BaseAttributeKvEntry(System.currentTimeMillis(), new StringDataEntry(key, OLD_VALUE)); - var newValue = new BaseAttributeKvEntry(System.currentTimeMillis(), new StringDataEntry(key, NEW_VALUE)); - - var trx = cache.newTransactionForKey(attrKey); - cache.putIfAbsent(attrKey, newValue); - trx.putIfAbsent(attrKey, oldValue); - Assert.assertFalse(trx.commit()); - Assert.assertEquals(NEW_VALUE, getAttributeValue(tenantId, deviceId, scope, key)); - } - @Test public void testConcurrentFetchAndUpdate() throws Exception { var tenantId = new TenantId(UUID.randomUUID()); @@ -274,6 +252,11 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { })); futures.add(pool.submit(() -> saveAttribute(tenantId, deviceId, scope, key, NEW_VALUE))); Futures.allAsList(futures).get(10, TimeUnit.SECONDS); + + String attributeValue = getAttributeValue(tenantId, deviceId, scope, key); + if (!NEW_VALUE.equals(attributeValue)) { + System.out.println(); + } Assert.assertEquals(NEW_VALUE, getAttributeValue(tenantId, deviceId, scope, key)); } @@ -330,5 +313,10 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { } } + private void equalsIgnoreVersion(AttributeKvEntry expected, AttributeKvEntry actual) { + Assert.assertEquals(expected.getKey(), actual.getKey()); + Assert.assertEquals(expected.getValue(), actual.getValue()); + Assert.assertEquals(expected.getLastUpdateTs(), actual.getLastUpdateTs()); + } } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/attributes/sql/AttributeCacheServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/sql/AttributeCacheServiceSqlTest.java new file mode 100644 index 0000000000..95e3e15adc --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/sql/AttributeCacheServiceSqlTest.java @@ -0,0 +1,114 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.attributes.sql; + +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.cache.TbCacheValueWrapper; +import org.thingsboard.server.cache.VersionedTbCache; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.dao.attributes.AttributeCacheKey; +import org.thingsboard.server.dao.service.AbstractServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +@DaoSqlTest +public class AttributeCacheServiceSqlTest extends AbstractServiceTest { + + private static final String TEST_KEY = "key"; + private static final String TEST_VALUE = "value"; + private static final DeviceId DEVICE_ID = new DeviceId(UUID.randomUUID()); + + @Autowired + VersionedTbCache cache; + + @Test + public void testPutAndGet() { + AttributeCacheKey testKey = new AttributeCacheKey(AttributeScope.CLIENT_SCOPE, DEVICE_ID, TEST_KEY); + AttributeKvEntry testValue = new BaseAttributeKvEntry(new StringDataEntry(TEST_KEY, TEST_VALUE), 1, 1L); + cache.put(testKey, testValue); + + TbCacheValueWrapper wrapper = cache.get(testKey); + assertNotNull(wrapper); + + assertEquals(testValue, wrapper.get()); + + AttributeKvEntry testValue2 = new BaseAttributeKvEntry(new StringDataEntry(TEST_KEY, TEST_VALUE), 1, 2L); + cache.put(testKey, testValue2); + + wrapper = cache.get(testKey); + assertNotNull(wrapper); + + assertEquals(testValue2, wrapper.get()); + + AttributeKvEntry testValue3 = new BaseAttributeKvEntry(new StringDataEntry(TEST_KEY, TEST_VALUE), 1, 0L); + cache.put(testKey, testValue3); + + wrapper = cache.get(testKey); + assertNotNull(wrapper); + + assertEquals(testValue2, wrapper.get()); + + cache.evict(testKey); + } + + @Test + public void testEvictWithVersion() { + AttributeCacheKey testKey = new AttributeCacheKey(AttributeScope.CLIENT_SCOPE, DEVICE_ID, TEST_KEY); + AttributeKvEntry testValue = new BaseAttributeKvEntry(new StringDataEntry(TEST_KEY, TEST_VALUE), 1, 1L); + cache.put(testKey, testValue); + + TbCacheValueWrapper wrapper = cache.get(testKey); + assertNotNull(wrapper); + + assertEquals(testValue, wrapper.get()); + + cache.evict(testKey, 2L); + + wrapper = cache.get(testKey); + assertNotNull(wrapper); + + assertNull(wrapper.get()); + + cache.evict(testKey); + } + + @Test + public void testEvict() { + AttributeCacheKey testKey = new AttributeCacheKey(AttributeScope.CLIENT_SCOPE, DEVICE_ID, TEST_KEY); + AttributeKvEntry testValue = new BaseAttributeKvEntry(new StringDataEntry(TEST_KEY, TEST_VALUE), 1, 1L); + cache.put(testKey, testValue); + + TbCacheValueWrapper wrapper = cache.get(testKey); + assertNotNull(wrapper); + + assertEquals(testValue, wrapper.get()); + + cache.evict(testKey); + + wrapper = cache.get(testKey); + assertNull(wrapper); + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/event/sql/EventServiceSqlTest_DedicatedEventsDataSource.java b/dao/src/test/java/org/thingsboard/server/dao/service/event/sql/EventServiceSqlTest_DedicatedEventsDataSource.java new file mode 100644 index 0000000000..cb765c29d0 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/event/sql/EventServiceSqlTest_DedicatedEventsDataSource.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.event.sql; + +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +@TestPropertySource(properties = { + "spring.datasource.events.enabled=true", + "spring.datasource.events.url=${spring.datasource.url}", + "spring.datasource.events.driverClassName=${spring.datasource.driverClassName}" +}) +public class EventServiceSqlTest_DedicatedEventsDataSource extends EventServiceSqlTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java index 390cb9afac..443052240a 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java @@ -17,11 +17,13 @@ package org.thingsboard.server.dao.service.timeseries; import com.datastax.oss.driver.api.core.uuid.Uuids; import lombok.extern.slf4j.Slf4j; +import org.assertj.core.data.Offset; import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.id.DeviceId; @@ -32,6 +34,7 @@ import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DataType; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; @@ -50,15 +53,14 @@ import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.Optional; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; /** * @author Andrew Shvayka @@ -73,6 +75,9 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Autowired EntityViewService entityViewService; + @Value("${database.ts.type}") + String databaseTsLatestType; + protected static final int MAX_TIMEOUT = 30; private static final String STRING_KEY = "stringKey"; @@ -89,6 +94,7 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { KvEntry booleanKvEntry = new BooleanDataEntry(BOOLEAN_KEY, Boolean.TRUE); protected TenantId tenantId; + DeviceId deviceId = new DeviceId(Uuids.timeBased()); @Before public void before() { @@ -106,8 +112,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindAllLatest() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); - saveEntries(deviceId, TS - 2); saveEntries(deviceId, TS - 1); saveEntries(deviceId, TS); @@ -133,7 +137,12 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { toTsEntry(TS, booleanKvEntry)); Collections.sort(expected, Comparator.comparing(KvEntry::getKey)); - assertEquals(expected, tsList); + for (int i = 0; i < expected.size(); i++) { + var expectedEntry = expected.get(i); + var actualEntry = tsList.get(i); + equalsIgnoreVersion(expectedEntry, actualEntry); + + } } private EntityView saveAndCreateEntityView(DeviceId deviceId, List timeseries) { @@ -150,34 +159,96 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindLatest() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); - saveEntries(deviceId, TS - 2); saveEntries(deviceId, TS - 1); saveEntries(deviceId, TS); List entries = tsService.findLatest(tenantId, deviceId, Collections.singleton(STRING_KEY)).get(MAX_TIMEOUT, TimeUnit.SECONDS); Assert.assertEquals(1, entries.size()); - Assert.assertEquals(toTsEntry(TS, stringKvEntry), entries.get(0)); + equalsIgnoreVersion(toTsEntry(TS, stringKvEntry), entries.get(0)); } @Test - public void testFindLatestWithoutLatestUpdate() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); + public void testFindLatestOpt_givenSaveWithHistoricalNonOrderedTS() throws Exception { + if (databaseTsLatestType.equals("cassandra")) { + return; + } + + save(tenantId, deviceId, toTsEntry(TS - 1, stringKvEntry)); + save(tenantId, deviceId, toTsEntry(TS, stringKvEntry)); + save(tenantId, deviceId, toTsEntry(TS - 10, stringKvEntry)); + save(tenantId, deviceId, toTsEntry(TS - 11, stringKvEntry)); + + Optional entryOpt = tsService.findLatest(tenantId, deviceId, STRING_KEY).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertThat(entryOpt).isNotNull().isPresent(); + equalsIgnoreVersion(toTsEntry(TS, stringKvEntry), entryOpt.get()); + } + @Test + public void testFindLatestOpt_givenSaveWithSameTSOverwriteValue() throws Exception { + save(tenantId, deviceId, toTsEntry(TS, new StringDataEntry(STRING_KEY, "old"))); + save(tenantId, deviceId, toTsEntry(TS, new StringDataEntry(STRING_KEY, "new"))); + + Optional entryOpt = tsService.findLatest(tenantId, deviceId, STRING_KEY).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertThat(entryOpt).isNotNull().isPresent(); + equalsIgnoreVersion(toTsEntry(TS, new StringDataEntry(STRING_KEY, "new")), entryOpt.get()); + } + + public void testFindLatestOpt_givenSaveWithSameTSOverwriteTypeAndValue() throws Exception { + save(tenantId, deviceId, toTsEntry(TS, new JsonDataEntry("temp", "{\"hello\":\"world\"}"))); + save(tenantId, deviceId, toTsEntry(TS, new BooleanDataEntry("temp", true))); + save(tenantId, deviceId, toTsEntry(TS, new LongDataEntry("temp", 100L))); + save(tenantId, deviceId, toTsEntry(TS, new DoubleDataEntry("temp", Math.PI))); + save(tenantId, deviceId, toTsEntry(TS, new StringDataEntry("temp", "NOOP"))); + + Optional entryOpt = tsService.findLatest(tenantId, deviceId, STRING_KEY).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertThat(entryOpt).isNotNull().isPresent(); + Assert.assertEquals(toTsEntry(TS, new StringDataEntry("temp", "NOOP")), entryOpt.orElse(null)); + } + + @Test + public void testFindLatestOpt() throws Exception { + saveEntries(deviceId, TS - 2); + saveEntries(deviceId, TS - 1); + saveEntries(deviceId, TS); + + Optional entryOpt = tsService.findLatest(tenantId, deviceId, STRING_KEY).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertThat(entryOpt).isNotNull().isPresent(); + equalsIgnoreVersion(toTsEntry(TS, stringKvEntry), entryOpt.get()); + } + + @Test + public void testFindLatest_NotFound() throws Exception { + List entries = tsService.findLatest(tenantId, deviceId, Collections.singleton(STRING_KEY)).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertThat(entries).hasSize(1); + TsKvEntry tsKvEntry = entries.get(0); + assertThat(tsKvEntry).isNotNull(); + // null ts latest representation + assertThat(tsKvEntry.getKey()).isEqualTo(STRING_KEY); + assertThat(tsKvEntry.getDataType()).isEqualTo(DataType.STRING); + assertThat(tsKvEntry.getValue()).isNull(); + assertThat(tsKvEntry.getTs()).isCloseTo(System.currentTimeMillis(), Offset.offset(TimeUnit.MINUTES.toMillis(1))); + } + + @Test + public void testFindLatestOpt_NotFound() throws Exception { + Optional entryOpt = tsService.findLatest(tenantId, deviceId, STRING_KEY).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertThat(entryOpt).isNotNull().isNotPresent(); + } + + @Test + public void testFindLatestWithoutLatestUpdate() throws Exception { saveEntries(deviceId, TS - 2); saveEntries(deviceId, TS - 1); saveEntriesWithoutLatest(deviceId, TS); List entries = tsService.findLatest(tenantId, deviceId, Collections.singleton(STRING_KEY)).get(MAX_TIMEOUT, TimeUnit.SECONDS); Assert.assertEquals(1, entries.size()); - Assert.assertEquals(toTsEntry(TS - 1, stringKvEntry), entries.get(0)); + equalsIgnoreVersion(toTsEntry(TS - 1, stringKvEntry), entries.get(0)); } @Test public void testFindByQueryAscOrder() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); - saveEntries(deviceId, TS - 3); saveEntries(deviceId, TS - 2); saveEntries(deviceId, TS - 1); @@ -202,7 +273,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindByQuery_whenPeriodEqualsOneMilisecondPeriod() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); saveEntries(deviceId, TS - 1L); saveEntries(deviceId, TS); saveEntries(deviceId, TS + 1L); @@ -222,7 +292,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindByQuery_whenPeriodEqualsInterval() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); saveEntries(deviceId, TS - 1L); for (long i = TS; i <= TS + 100L; i += 10L) { saveEntries(deviceId, i); @@ -244,7 +313,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindByQuery_whenPeriodHaveTwoIntervalWithEqualsLength() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); saveEntries(deviceId, TS - 1L); for (long i = TS; i <= TS + 100000L; i += 10000L) { saveEntries(deviceId, i); @@ -268,7 +336,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindByQuery_whenPeriodHaveTwoInterval_whereSecondShorterThanFirst() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); saveEntries(deviceId, TS - 1L); for (long i = TS; i <= TS + 80000L; i += 10000L) { saveEntries(deviceId, i); @@ -292,7 +359,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindByQuery_whenPeriodHaveTwoIntervalWithEqualsLength_whereNotAllEntriesInRange() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); for (long i = TS - 1L; i <= TS + 100000L + 1L; i += 10000) { saveEntries(deviceId, i); } @@ -314,7 +380,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindByQuery_whenPeriodHaveTwoInterval_whereSecondShorterThanFirst_andNotAllEntriesInRange() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); for (long i = TS - 1L; i <= TS + 100000L + 1L; i += 10000L) { saveEntries(deviceId, i); } @@ -336,8 +401,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindByQueryDescOrder() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); - saveEntries(deviceId, TS - 3); saveEntries(deviceId, TS - 2); saveEntries(deviceId, TS - 1); @@ -362,7 +425,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindAllByQueries_verifyQueryId() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); saveEntries(deviceId, TS); saveEntries(deviceId, TS - 2); saveEntries(deviceId, TS - 10); @@ -373,7 +435,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindAllByQueries_verifyQueryId_forEntityView() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); saveEntries(deviceId, TS); saveEntries(deviceId, TS - 2); saveEntries(deviceId, TS - 12); @@ -392,8 +453,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testDeleteDeviceTsDataWithOverwritingLatest() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); - saveEntries(deviceId, 10000); saveEntries(deviceId, 20000); saveEntries(deviceId, 30000); @@ -412,7 +471,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindDeviceTsData() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); List entries = new ArrayList<>(); entries.add(save(deviceId, 5000, 100)); @@ -563,7 +621,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindDeviceLongAndDoubleTsData() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); List entries = new ArrayList<>(); entries.add(save(deviceId, 5000, 100)); @@ -654,8 +711,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testSaveTs_RemoveTs_AndSaveTsAgain() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); - save(deviceId, 2000000L, 95); save(deviceId, 4000000L, 100); save(deviceId, 6000000L, 105); @@ -686,7 +741,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { BasicTsKvEntry jsonEntry = new BasicTsKvEntry(TimeUnit.MINUTES.toMillis(5), new JsonDataEntry("test", "{\"test\":\"testValue\"}")); List timeseries = List.of(booleanEntry, stringEntry, longEntry, doubleEntry, jsonEntry); - DeviceId deviceId = new DeviceId(Uuids.timeBased()); for (TsKvEntry tsKvEntry : timeseries) { save(tenantId, deviceId, tsKvEntry); } @@ -745,5 +799,10 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { return new BasicTsKvEntry(ts, entry); } + private static void equalsIgnoreVersion(TsKvEntry expected, TsKvEntry actual) { + assertEquals(expected.getKey(), actual.getKey()); + assertEquals(expected.getValue(), actual.getValue()); + assertEquals(expected.getTs(), actual.getTs()); + } } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/LatestTimeseriesPerformanceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/LatestTimeseriesPerformanceTest.java new file mode 100644 index 0000000000..d81eea997c --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/LatestTimeseriesPerformanceTest.java @@ -0,0 +1,153 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.timeseries.sql; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.dao.service.AbstractServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +@DaoSqlTest +@Slf4j +public class LatestTimeseriesPerformanceTest extends AbstractServiceTest { + + private static final String STRING_KEY = "stringKey"; + private static final String LONG_KEY = "longKey"; + private static final String DOUBLE_KEY = "doubleKey"; + private static final String BOOLEAN_KEY = "booleanKey"; + public static final int AMOUNT_OF_UNIQ_KEY = 10000; + + private final Random random = new Random(); + + @Autowired + private TimeseriesLatestDao timeseriesLatestDao; + + private ListeningExecutorService testExecutor; + + private EntityId entityId; + + private AtomicLong saveCounter; + + @Before + public void before() { + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + Tenant savedTenant = tenantService.saveTenant(tenant); + Assert.assertNotNull(savedTenant); + tenantId = savedTenant.getId(); + entityId = new DeviceId(UUID.randomUUID()); + saveCounter = new AtomicLong(0); + testExecutor = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(200, ThingsBoardThreadFactory.forName(getClass().getSimpleName() + "-test-scope"))); + } + + @After + public void after() { + tenantService.deleteTenant(tenantId); + if (testExecutor != null) { + testExecutor.shutdownNow(); + } + } + + @Test + public void test_save_latest_timeseries() throws Exception { + warmup(); + saveCounter.set(0); + + long startTime = System.currentTimeMillis(); + List> futures = new ArrayList<>(); + for (int i = 0; i < 25_000; i++) { + futures.add(save(generateStrEntry(getRandomKey()))); + futures.add(save(generateLngEntry(getRandomKey()))); + futures.add(save(generateDblEntry(getRandomKey()))); + futures.add(save(generateBoolEntry(getRandomKey()))); + } + Futures.allAsList(futures).get(60, TimeUnit.SECONDS); + long endTime = System.currentTimeMillis(); + + long totalTime = endTime - startTime; + + log.info("Total time: {}", totalTime); + log.info("Saved count: {}", saveCounter.get()); + log.warn("Saved per 1 sec: {}", saveCounter.get() * 1000 / totalTime); + } + + private void warmup() throws Exception { + List> futures = new ArrayList<>(); + for (int i = 0; i < AMOUNT_OF_UNIQ_KEY; i++) { + futures.add(save(generateStrEntry(i))); + futures.add(save(generateLngEntry(i))); + futures.add(save(generateDblEntry(i))); + futures.add(save(generateBoolEntry(i))); + } + Futures.allAsList(futures).get(60, TimeUnit.SECONDS); + } + + private ListenableFuture save(TsKvEntry tsKvEntry) { + return Futures.transformAsync(testExecutor.submit(() -> timeseriesLatestDao.saveLatest(tenantId, entityId, tsKvEntry)), result -> { + saveCounter.incrementAndGet(); + return result; + }, testExecutor); + } + + private TsKvEntry generateStrEntry(int keyIndex) { + return new BasicTsKvEntry(System.currentTimeMillis(), new StringDataEntry(STRING_KEY + keyIndex, RandomStringUtils.random(10))); + } + + private TsKvEntry generateLngEntry(int keyIndex) { + return new BasicTsKvEntry(System.currentTimeMillis(), new LongDataEntry(LONG_KEY + keyIndex, random.nextLong())); + } + + private TsKvEntry generateDblEntry(int keyIndex) { + return new BasicTsKvEntry(System.currentTimeMillis(), new DoubleDataEntry(DOUBLE_KEY + keyIndex, random.nextDouble())); + } + + private TsKvEntry generateBoolEntry(int keyIndex) { + return new BasicTsKvEntry(System.currentTimeMillis(), new BooleanDataEntry(BOOLEAN_KEY + keyIndex, random.nextBoolean())); + } + + private int getRandomKey() { + return random.nextInt(AMOUNT_OF_UNIQ_KEY); + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDaoTest.java index e8f3bba9db..8010eab4cd 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDaoTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDaoTest.java @@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.widget.BaseWidgetType; import org.thingsboard.server.common.data.widget.DeprecatedFilter; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetTypeFilter; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.common.data.widget.WidgetsBundleWidget; @@ -151,12 +152,22 @@ public class JpaWidgetTypeDaoTest extends AbstractJpaDaoTest { @Test public void testFindSystemWidgetTypes() { - PageData widgetTypes = widgetTypeDao.findSystemWidgetTypes(TenantId.SYS_TENANT_ID, true, DeprecatedFilter.ALL, Collections.singletonList("static"), + PageData widgetTypes = widgetTypeDao.findSystemWidgetTypes( + WidgetTypeFilter.builder() + .tenantId(TenantId.SYS_TENANT_ID) + .fullSearch(true) + .deprecatedFilter(DeprecatedFilter.ALL) + .widgetTypes(Collections.singletonList("static")).build(), new PageLink(1024, 0, "TYPE_DESCRIPTION", new SortOrder("createdTime"))); assertEquals(1, widgetTypes.getData().size()); assertEquals(new WidgetTypeInfo(widgetTypeList.get(1)), widgetTypes.getData().get(0)); - widgetTypes = widgetTypeDao.findSystemWidgetTypes(TenantId.SYS_TENANT_ID, true, DeprecatedFilter.ALL, Collections.emptyList(), + widgetTypes = widgetTypeDao.findSystemWidgetTypes( + WidgetTypeFilter.builder() + .tenantId(TenantId.SYS_TENANT_ID) + .fullSearch(true) + .deprecatedFilter(DeprecatedFilter.ALL) + .widgetTypes(Collections.emptyList()).build(), new PageLink(1024, 0, "hfgfd tag2_2 ghg", new SortOrder("createdTime"))); assertEquals(1, widgetTypes.getData().size()); assertEquals(new WidgetTypeInfo(widgetTypeList.get(2)), widgetTypes.getData().get(0)); @@ -175,12 +186,21 @@ public class JpaWidgetTypeDaoTest extends AbstractJpaDaoTest { sameNameList.sort(Comparator.comparing(BaseWidgetType::getName).thenComparing((BaseWidgetType baseWidgetType) -> baseWidgetType.getId().getId())); List expected = sameNameList.stream().map(WidgetTypeInfo::new).collect(Collectors.toList()); - PageData widgetTypesFirstPage = widgetTypeDao.findSystemWidgetTypes(TenantId.SYS_TENANT_ID, true, DeprecatedFilter.ALL, Collections.singletonList("static"), + PageData widgetTypesFirstPage = widgetTypeDao.findSystemWidgetTypes( + WidgetTypeFilter.builder() + .tenantId(TenantId.SYS_TENANT_ID) + .fullSearch(true) + .deprecatedFilter(DeprecatedFilter.ALL) + .widgetTypes(Collections.singletonList("static")).build(), new PageLink(10, 0, null, new SortOrder("name"))); assertEquals(10, widgetTypesFirstPage.getData().size()); assertThat(widgetTypesFirstPage.getData()).containsExactlyElementsOf(expected.subList(0, 10)); - PageData widgetTypesSecondPage = widgetTypeDao.findSystemWidgetTypes(TenantId.SYS_TENANT_ID, true, DeprecatedFilter.ALL, Collections.singletonList("static"), + PageData widgetTypesSecondPage = widgetTypeDao.findSystemWidgetTypes(WidgetTypeFilter.builder() + .tenantId(TenantId.SYS_TENANT_ID) + .fullSearch(true) + .deprecatedFilter(DeprecatedFilter.ALL) + .widgetTypes(Collections.singletonList("static")).build(), new PageLink(10, 1, null, new SortOrder("name"))); assertEquals(10, widgetTypesSecondPage.getData().size()); assertThat(widgetTypesSecondPage.getData()).containsExactlyElementsOf(expected.subList(10, 20)); @@ -195,7 +215,12 @@ public class JpaWidgetTypeDaoTest extends AbstractJpaDaoTest { WidgetTypeDetails savedWidgetType = createAndSaveWidgetType(TenantId.SYS_TENANT_ID, WIDGET_TYPE_COUNT + 1, tags); PageData widgetTypes = widgetTypeDao.findSystemWidgetTypes( - TenantId.SYS_TENANT_ID, true, DeprecatedFilter.ALL, null, new PageLink(10, 0, searchText) + WidgetTypeFilter.builder() + .tenantId(TenantId.SYS_TENANT_ID) + .fullSearch(true) + .deprecatedFilter(DeprecatedFilter.ALL) + .widgetTypes(null).build(), + new PageLink(10, 0, searchText) ); assertThat(widgetTypes.getData()).hasSize(1); @@ -211,7 +236,12 @@ public class JpaWidgetTypeDaoTest extends AbstractJpaDaoTest { WidgetTypeDetails savedWidgetType = createAndSaveWidgetType(TenantId.SYS_TENANT_ID, WIDGET_TYPE_COUNT + 1, tags); PageData widgetTypes = widgetTypeDao.findSystemWidgetTypes( - TenantId.SYS_TENANT_ID, true, DeprecatedFilter.ALL, null, new PageLink(10, 0, searchText) + WidgetTypeFilter.builder() + .tenantId(TenantId.SYS_TENANT_ID) + .fullSearch(true) + .deprecatedFilter(DeprecatedFilter.ALL) + .widgetTypes(null).build(), + new PageLink(10, 0, searchText) ); assertThat(widgetTypes.getData()).hasSize(0); @@ -227,7 +257,12 @@ public class JpaWidgetTypeDaoTest extends AbstractJpaDaoTest { var widgetType = createAndSaveWidgetType(new TenantId(tenantId), i); widgetTypeList.add(widgetType); } - PageData widgetTypes = widgetTypeDao.findTenantWidgetTypesByTenantId(tenantId, true, DeprecatedFilter.ALL, null, + PageData widgetTypes = widgetTypeDao.findTenantWidgetTypesByTenantId( + WidgetTypeFilter.builder() + .tenantId(new TenantId(tenantId)) + .fullSearch(true) + .deprecatedFilter(DeprecatedFilter.ALL) + .widgetTypes(null).build(), new PageLink(10, 0, "", new SortOrder("createdTime"))); assertEquals(WIDGET_TYPE_COUNT, widgetTypes.getData().size()); assertEquals(new WidgetTypeInfo(widgetTypeList.get(3)), widgetTypes.getData().get(0)); @@ -244,7 +279,12 @@ public class JpaWidgetTypeDaoTest extends AbstractJpaDaoTest { WidgetTypeDetails savedWidgetType = createAndSaveWidgetType(TenantId.SYS_TENANT_ID, WIDGET_TYPE_COUNT + 1, tags); PageData widgetTypes = widgetTypeDao.findTenantWidgetTypesByTenantId( - TenantId.SYS_TENANT_ID.getId(), true, DeprecatedFilter.ALL, null, new PageLink(10, 0, searchText) + WidgetTypeFilter.builder() + .tenantId(TenantId.SYS_TENANT_ID) + .fullSearch(true) + .deprecatedFilter(DeprecatedFilter.ALL) + .widgetTypes(null).build(), + new PageLink(10, 0, searchText) ); assertThat(widgetTypes.getData()).hasSize(1); @@ -260,7 +300,12 @@ public class JpaWidgetTypeDaoTest extends AbstractJpaDaoTest { WidgetTypeDetails savedWidgetType = createAndSaveWidgetType(TenantId.SYS_TENANT_ID, WIDGET_TYPE_COUNT + 1, tags); PageData widgetTypes = widgetTypeDao.findTenantWidgetTypesByTenantId( - TenantId.SYS_TENANT_ID.getId(), true, DeprecatedFilter.ALL, null, new PageLink(10, 0, searchText) + WidgetTypeFilter.builder() + .tenantId(TenantId.SYS_TENANT_ID) + .fullSearch(true) + .deprecatedFilter(DeprecatedFilter.ALL) + .widgetTypes(null).build(), + new PageLink(10, 0, searchText) ); assertThat(widgetTypes.getData()).hasSize(0); @@ -278,7 +323,12 @@ public class JpaWidgetTypeDaoTest extends AbstractJpaDaoTest { WidgetTypeDetails savedWidgetType = createAndSaveWidgetType(TenantId.SYS_TENANT_ID, WIDGET_TYPE_COUNT + 1, tags); PageData widgetTypes = widgetTypeDao.findAllTenantWidgetTypesByTenantId( - TenantId.SYS_TENANT_ID.getId(), true, DeprecatedFilter.ALL, null, new PageLink(10, 0, searchText) + WidgetTypeFilter.builder() + .tenantId(TenantId.SYS_TENANT_ID) + .fullSearch(true) + .deprecatedFilter(DeprecatedFilter.ALL) + .widgetTypes(null).build(), + new PageLink(10, 0, searchText) ); assertThat(widgetTypes.getData()).hasSize(1); @@ -294,7 +344,12 @@ public class JpaWidgetTypeDaoTest extends AbstractJpaDaoTest { WidgetTypeDetails savedWidgetType = createAndSaveWidgetType(TenantId.SYS_TENANT_ID, WIDGET_TYPE_COUNT + 1, tags); PageData widgetTypes = widgetTypeDao.findAllTenantWidgetTypesByTenantId( - TenantId.SYS_TENANT_ID.getId(), true, DeprecatedFilter.ALL, null, new PageLink(10, 0, searchText) + WidgetTypeFilter.builder() + .tenantId(TenantId.SYS_TENANT_ID) + .fullSearch(true) + .deprecatedFilter(DeprecatedFilter.ALL) + .widgetTypes(null).build(), + new PageLink(10, 0, searchText) ); assertThat(widgetTypes.getData()).hasSize(0); diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetsBundleDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetsBundleDaoTest.java index e2221dcc93..5ec50189c4 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetsBundleDaoTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetsBundleDaoTest.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.common.data.widget.WidgetsBundleFilter; import org.thingsboard.server.common.data.widget.WidgetsBundleWidget; import org.thingsboard.server.dao.AbstractJpaDaoTest; import org.thingsboard.server.dao.widget.WidgetTypeDao; @@ -42,6 +43,8 @@ import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; public class JpaWidgetsBundleDaoTest extends AbstractJpaDaoTest { @@ -113,11 +116,11 @@ public class JpaWidgetsBundleDaoTest extends AbstractJpaDaoTest { assertEquals(30, widgetsBundles.size()); // Get first page PageLink pageLink = new PageLink(10, 0, "WB"); - PageData widgetsBundles1 = widgetsBundleDao.findSystemWidgetsBundles(TenantId.SYS_TENANT_ID, false, pageLink); + PageData widgetsBundles1 = widgetsBundleDao.findSystemWidgetsBundles(WidgetsBundleFilter.fromTenantId(TenantId.SYS_TENANT_ID), pageLink); assertEquals(10, widgetsBundles1.getData().size()); // Get next page pageLink = pageLink.nextPageLink(); - PageData widgetsBundles2 = widgetsBundleDao.findSystemWidgetsBundles(TenantId.SYS_TENANT_ID, false, pageLink); + PageData widgetsBundles2 = widgetsBundleDao.findSystemWidgetsBundles(WidgetsBundleFilter.fromTenantId(TenantId.SYS_TENANT_ID), pageLink); assertEquals(10, widgetsBundles2.getData().size()); } @@ -142,19 +145,19 @@ public class JpaWidgetsBundleDaoTest extends AbstractJpaDaoTest { widgetTypeDao.saveWidgetsBundleWidget(new WidgetsBundleWidget(widgetsBundle3.getId(), widgetType2.getId(), 1)); PageLink pageLink = new PageLink(10, 0, "widget type 1", new SortOrder("title")); - PageData widgetsBundles1 = widgetsBundleDao.findSystemWidgetsBundles(TenantId.SYS_TENANT_ID, true, pageLink); + PageData widgetsBundles1 = widgetsBundleDao.findSystemWidgetsBundles(WidgetsBundleFilter.fullSearchFromTenantId(TenantId.SYS_TENANT_ID), pageLink); assertEquals(2, widgetsBundles1.getData().size()); assertEquals(widgetsBundle1, widgetsBundles1.getData().get(0)); assertEquals(widgetsBundle3, widgetsBundles1.getData().get(1)); pageLink = new PageLink(10, 0, "Test widget type 2", new SortOrder("title")); - PageData widgetsBundles2 = widgetsBundleDao.findSystemWidgetsBundles(TenantId.SYS_TENANT_ID, true, pageLink); + PageData widgetsBundles2 = widgetsBundleDao.findSystemWidgetsBundles(WidgetsBundleFilter.fullSearchFromTenantId(TenantId.SYS_TENANT_ID), pageLink); assertEquals(2, widgetsBundles2.getData().size()); assertEquals(widgetsBundle2, widgetsBundles2.getData().get(0)); assertEquals(widgetsBundle3, widgetsBundles2.getData().get(1)); pageLink = new PageLink(10, 0, "ppp Fd v TAG1 tt", new SortOrder("title")); - PageData widgetsBundles3 = widgetsBundleDao.findSystemWidgetsBundles(TenantId.SYS_TENANT_ID, true, pageLink); + PageData widgetsBundles3 = widgetsBundleDao.findSystemWidgetsBundles(WidgetsBundleFilter.fullSearchFromTenantId(TenantId.SYS_TENANT_ID), pageLink); assertEquals(2, widgetsBundles3.getData().size()); assertEquals(widgetsBundle1, widgetsBundles3.getData().get(0)); assertEquals(widgetsBundle3, widgetsBundles3.getData().get(1)); @@ -171,7 +174,7 @@ public class JpaWidgetsBundleDaoTest extends AbstractJpaDaoTest { widgetTypeDao.saveWidgetsBundleWidget(new WidgetsBundleWidget(systemWidgetBundle.getId(), widgetType.getId(), 0)); PageData widgetTypes = widgetsBundleDao.findSystemWidgetsBundles( - TenantId.SYS_TENANT_ID, true, new PageLink(10, 0, searchText) + WidgetsBundleFilter.fullSearchFromTenantId(TenantId.SYS_TENANT_ID), new PageLink(10, 0, searchText) ); assertThat(widgetTypes.getData()).hasSize(1); @@ -191,7 +194,7 @@ public class JpaWidgetsBundleDaoTest extends AbstractJpaDaoTest { widgetTypeDao.saveWidgetsBundleWidget(new WidgetsBundleWidget(systemWidgetBundle.getId(), widgetType.getId(), 0)); PageData widgetTypes = widgetsBundleDao.findSystemWidgetsBundles( - TenantId.SYS_TENANT_ID, true, new PageLink(10, 0, searchText) + WidgetsBundleFilter.fullSearchFromTenantId(TenantId.SYS_TENANT_ID), new PageLink(10, 0, searchText) ); assertThat(widgetTypes.getData()).hasSize(0); @@ -245,19 +248,19 @@ public class JpaWidgetsBundleDaoTest extends AbstractJpaDaoTest { assertEquals(100, widgetsBundleDao.find(TenantId.SYS_TENANT_ID).size()); PageLink pageLink = new PageLink(30, 0, "WB"); - PageData widgetsBundles1 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId1, false, pageLink); + PageData widgetsBundles1 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(WidgetsBundleFilter.fromTenantId(TenantId.fromUUID(tenantId1)), pageLink); assertEquals(30, widgetsBundles1.getData().size()); pageLink = pageLink.nextPageLink(); - PageData widgetsBundles2 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId1, false, pageLink); + PageData widgetsBundles2 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(WidgetsBundleFilter.fromTenantId(TenantId.fromUUID(tenantId1)), pageLink); assertEquals(30, widgetsBundles2.getData().size()); pageLink = pageLink.nextPageLink(); - PageData widgetsBundles3 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId1, false, pageLink); + PageData widgetsBundles3 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(WidgetsBundleFilter.fromTenantId(TenantId.fromUUID(tenantId1)), pageLink); assertEquals(10, widgetsBundles3.getData().size()); pageLink = pageLink.nextPageLink(); - PageData widgetsBundles4 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId1, false, pageLink); + PageData widgetsBundles4 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(WidgetsBundleFilter.fromTenantId(TenantId.fromUUID(tenantId1)), pageLink); assertEquals(0, widgetsBundles4.getData().size()); } @@ -287,29 +290,75 @@ public class JpaWidgetsBundleDaoTest extends AbstractJpaDaoTest { widgetTypeDao.saveWidgetsBundleWidget(new WidgetsBundleWidget(widgetsBundle3.getId(), widgetType2.getId(), 1)); PageLink pageLink = new PageLink(10, 0, "widget type 1", new SortOrder("title")); - PageData widgetsBundles1 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId1, true, pageLink); + PageData widgetsBundles1 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(WidgetsBundleFilter.fullSearchFromTenantId(TenantId.fromUUID(tenantId1)), pageLink); assertEquals(1, widgetsBundles1.getData().size()); assertEquals(widgetsBundle1, widgetsBundles1.getData().get(0)); pageLink = new PageLink(10, 0, "Test widget type 2", new SortOrder("title")); - PageData widgetsBundles2 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId1, true, pageLink); + PageData widgetsBundles2 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(WidgetsBundleFilter.fullSearchFromTenantId(TenantId.fromUUID(tenantId1)), pageLink); assertEquals(0, widgetsBundles2.getData().size()); - PageData widgetsBundles3 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId2, true, pageLink); + PageData widgetsBundles3 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(WidgetsBundleFilter.fullSearchFromTenantId(TenantId.fromUUID(tenantId2)), pageLink); assertEquals(2, widgetsBundles3.getData().size()); assertEquals(widgetsBundle2, widgetsBundles3.getData().get(0)); assertEquals(widgetsBundle3, widgetsBundles3.getData().get(1)); pageLink = new PageLink(10, 0, "ttt Tag2 ffff hhhh", new SortOrder("title")); - PageData widgetsBundles4 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId1, true, pageLink); + PageData widgetsBundles4 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(WidgetsBundleFilter.fullSearchFromTenantId(TenantId.fromUUID(tenantId1)), pageLink); assertEquals(1, widgetsBundles4.getData().size()); assertEquals(widgetsBundle1, widgetsBundles4.getData().get(0)); - PageData widgetsBundles5 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId2, true, pageLink); + PageData widgetsBundles5 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(WidgetsBundleFilter.fullSearchFromTenantId(TenantId.fromUUID(tenantId2)), pageLink); assertEquals(1, widgetsBundles5.getData().size()); assertEquals(widgetsBundle3, widgetsBundles5.getData().get(0)); } + @Test + public void testFindAllWidgetsBundlesByTenantIdFullSearchScadaFirst() { + UUID tenantId1 = Uuids.timeBased(); + UUID tenantId2 = Uuids.timeBased(); + for (int i = 0; i < 10; i++) { + createWidgetBundles(5, tenantId1, "WB1_" + i + "_"); + createWidgetBundles(2, tenantId1, "WB1_SCADA_" + i + "_", true); + createWidgetBundles(3, tenantId2, "WB2_" + i + "_"); + createWidgetBundles(3, tenantId2, "WB2_SCADA_" + i + "_", true); + createSystemWidgetBundles(2, "WB_SYS_" + i + "_"); + createSystemWidgetBundles(1, "WB_SYS_SCADA_" + i + "_", true); + } + widgetsBundles = widgetsBundleDao.find(TenantId.SYS_TENANT_ID).stream().sorted(Comparator.comparing(WidgetsBundle::getTitle)).collect(Collectors.toList());; + assertEquals(160, widgetsBundles.size()); + + PageLink pageLink = new PageLink(50, 0, "WB", new SortOrder("title")); + PageData widgetsBundles1 = + widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId( + WidgetsBundleFilter.builder().tenantId(TenantId.fromUUID(tenantId1)).fullSearch(true).scadaFirst(true).build(), pageLink); + + for (int i =0; i < 30; i++) { + var widgetsBundle = widgetsBundles1.getData().get(i); + assertTrue(widgetsBundle.isScada()); + } + + for (int i = 30; i < 50; i++) { + var widgetsBundle = widgetsBundles1.getData().get(i); + assertFalse(widgetsBundle.isScada()); + } + + pageLink = new PageLink(50, 0, "WB", new SortOrder("title")); + PageData widgetsBundles2 = + widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId( + WidgetsBundleFilter.builder().tenantId(TenantId.fromUUID(tenantId2)).fullSearch(true).scadaFirst(true).build(), pageLink); + + for (int i =0; i < 40; i++) { + var widgetsBundle = widgetsBundles2.getData().get(i); + assertTrue(widgetsBundle.isScada()); + } + + for (int i = 40; i < 50; i++) { + var widgetsBundle = widgetsBundles2.getData().get(i); + assertFalse(widgetsBundle.isScada()); + } + } + @Test public void testTagsSearchInFindAllWidgetsBundlesByTenantId() { for (var entry : SHOULD_FIND_SEARCH_TO_TAGS_MAP.entrySet()) { @@ -321,7 +370,7 @@ public class JpaWidgetsBundleDaoTest extends AbstractJpaDaoTest { widgetTypeDao.saveWidgetsBundleWidget(new WidgetsBundleWidget(systemWidgetBundle.getId(), widgetType.getId(), 0)); PageData widgetTypes = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId( - TenantId.SYS_TENANT_ID.getId(), true, new PageLink(10, 0, searchText) + WidgetsBundleFilter.fullSearchFromTenantId(TenantId.SYS_TENANT_ID), new PageLink(10, 0, searchText) ); assertThat(widgetTypes.getData()).hasSize(1); @@ -341,7 +390,7 @@ public class JpaWidgetsBundleDaoTest extends AbstractJpaDaoTest { widgetTypeDao.saveWidgetsBundleWidget(new WidgetsBundleWidget(systemWidgetBundle.getId(), widgetType.getId(), 0)); PageData widgetTypes = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId( - TenantId.SYS_TENANT_ID.getId(), true, new PageLink(10, 0, searchText) + WidgetsBundleFilter.fullSearchFromTenantId(TenantId.SYS_TENANT_ID), new PageLink(10, 0, searchText) ); assertThat(widgetTypes.getData()).hasSize(0); @@ -378,7 +427,7 @@ public class JpaWidgetsBundleDaoTest extends AbstractJpaDaoTest { }).collect(Collectors.toList());; assertEquals(20, widgetsBundles.size()); PageLink pageLink = new PageLink(100, 0, "", new SortOrder("title")); - PageData widgetsBundlesData = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId1, true, pageLink); + PageData widgetsBundlesData = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(WidgetsBundleFilter.fullSearchFromTenantId(TenantId.fromUUID(tenantId1)), pageLink); assertEquals(20, widgetsBundlesData.getData().size()); assertEquals(widgetsBundles, widgetsBundlesData.getData()); } @@ -391,19 +440,27 @@ public class JpaWidgetsBundleDaoTest extends AbstractJpaDaoTest { widgetsBundles = widgetsBundleDao.find(TenantId.SYS_TENANT_ID); assertEquals(10, widgetsBundleDao.find(TenantId.SYS_TENANT_ID).size()); PageLink textPageLink = new PageLink(30, 0, "TEXT_NOT_FOUND"); - PageData widgetsBundles4 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId, false, textPageLink); + PageData widgetsBundles4 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(WidgetsBundleFilter.fromTenantId(TenantId.fromUUID(tenantId)), textPageLink); assertEquals(0, widgetsBundles4.getData().size()); } private void createWidgetBundles(int count, UUID tenantId, String prefix) { + createWidgetBundles(count, tenantId, prefix, false); + } + + private void createWidgetBundles(int count, UUID tenantId, String prefix, boolean scada) { for (int i = 0; i < count; i++) { - createWidgetsBundle(TenantId.fromUUID(tenantId), prefix + i, prefix + i, null); + createWidgetsBundle(TenantId.fromUUID(tenantId), prefix + i, prefix + i, null, scada); } } private void createSystemWidgetBundles(int count, String prefix) { + createSystemWidgetBundles(count, prefix, false); + } + + private void createSystemWidgetBundles(int count, String prefix, boolean scada) { for (int i = 0; i < count; i++) { - createWidgetsBundle(TenantId.SYS_TENANT_ID, prefix + i, prefix + i, null); + createWidgetsBundle(TenantId.SYS_TENANT_ID, prefix + i, prefix + i, null, scada); } } @@ -412,12 +469,17 @@ public class JpaWidgetsBundleDaoTest extends AbstractJpaDaoTest { } private WidgetsBundle createWidgetsBundle(TenantId tenantId, String alias, String title, Integer order) { + return createWidgetsBundle(tenantId, alias, title, order, false); + } + + private WidgetsBundle createWidgetsBundle(TenantId tenantId, String alias, String title, Integer order, boolean scada) { WidgetsBundle widgetsBundle = new WidgetsBundle(); widgetsBundle.setAlias(alias); widgetsBundle.setTitle(title); widgetsBundle.setTenantId(tenantId); widgetsBundle.setId(new WidgetsBundleId(Uuids.timeBased())); widgetsBundle.setOrder(order); + widgetsBundle.setScada(scada); return widgetsBundleDao.save(TenantId.SYS_TENANT_ID, widgetsBundle); } diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index be8a69f689..06b8069f11 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -10,6 +10,7 @@ audit-log.sink.type=none #cache.type=caffeine # will be injected redis by RedisContainer or will be default (caffeine) cache.maximumPoolSize=16 cache.attributes.enabled=true +cache.ts_latest.enabled=true cache.specs.relations.timeToLiveInMinutes=1440 cache.specs.relations.maxSize=100000 @@ -59,8 +60,11 @@ cache.specs.assetProfiles.maxSize=100000 cache.specs.attributes.timeToLiveInMinutes=1440 cache.specs.attributes.maxSize=100000 -cache.specs.tokensOutdatageTime.timeToLiveInMinutes=1440 -cache.specs.tokensOutdatageTime.maxSize=100000 +cache.specs.tsLatest.timeToLiveInMinutes=1440 +cache.specs.tsLatest.maxSize=100000 + +cache.specs.userSessionsInvalidation.timeToLiveInMinutes=1440 +cache.specs.userSessionsInvalidation.maxSize=10000 cache.specs.otaPackages.timeToLiveInMinutes=1440 cache.specs.otaPackages.maxSize=100000 @@ -89,6 +93,15 @@ cache.specs.resourceInfo.maxSize=10000 cache.specs.alarmTypes.timeToLiveInMinutes=60 cache.specs.alarmTypes.maxSize=10000 +cache.specs.userSettings.timeToLiveInMinutes=1440 +cache.specs.userSettings.maxSize=10000 + +cache.specs.mobileAppSettings.timeToLiveInMinutes=1440 +cache.specs.mobileAppSettings.maxSize=10000 + +cache.specs.mobileSecretKey.timeToLiveInMinutes=1440 +cache.specs.mobileSecretKey.maxSize=10000 + redis.connection.host=localhost redis.connection.port=6379 redis.connection.db=0 diff --git a/dao/src/test/resources/logback.xml b/dao/src/test/resources/logback-test.xml similarity index 56% rename from dao/src/test/resources/logback.xml rename to dao/src/test/resources/logback-test.xml index 5e293b2982..fe0887c678 100644 --- a/dao/src/test/resources/logback.xml +++ b/dao/src/test/resources/logback-test.xml @@ -9,9 +9,15 @@ + + + + + + diff --git a/dao/src/test/resources/sql-test.properties b/dao/src/test/resources/sql-test.properties index 4292db28d6..eb50e96e75 100644 --- a/dao/src/test/resources/sql-test.properties +++ b/dao/src/test/resources/sql-test.properties @@ -14,7 +14,7 @@ spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.datasource.username=postgres spring.datasource.password=postgres -spring.datasource.url=jdbc:tc:postgresql:12.8:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb +spring.datasource.url=jdbc:tc:postgresql:12.19:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.hikari.maximumPoolSize=16 diff --git a/dao/src/test/resources/sql/psql/drop-all-tables.sql b/dao/src/test/resources/sql/psql/drop-all-tables.sql index 9c772df45b..da6eca161b 100644 --- a/dao/src/test/resources/sql/psql/drop-all-tables.sql +++ b/dao/src/test/resources/sql/psql/drop-all-tables.sql @@ -23,6 +23,7 @@ DROP TABLE IF EXISTS alarm_type; DROP TABLE IF EXISTS asset; DROP TABLE IF EXISTS audit_log; DROP TABLE IF EXISTS attribute_kv; +DROP SEQUENCE IF EXISTS attribute_kv_version_seq; DROP TABLE IF EXISTS component_descriptor; DROP TABLE IF EXISTS customer; DROP TABLE IF EXISTS device; @@ -33,9 +34,11 @@ DROP TABLE IF EXISTS stats_event; DROP TABLE IF EXISTS lc_event; DROP TABLE IF EXISTS error_event; DROP TABLE IF EXISTS relation; +DROP SEQUENCE IF EXISTS relation_version_seq; DROP TABLE IF EXISTS tenant; DROP TABLE IF EXISTS ts_kv; DROP TABLE IF EXISTS ts_kv_latest; +DROP SEQUENCE IF EXISTS ts_kv_latest_version_seq; DROP TABLE IF EXISTS ts_kv_dictionary; DROP TABLE IF EXISTS user_credentials; DROP TABLE IF EXISTS widgets_bundle_widget; diff --git a/docker/docker-compose.redis-cluster.yml b/docker/docker-compose.redis-cluster.yml index 7b5d6d6dde..cb41207701 100644 --- a/docker/docker-compose.redis-cluster.yml +++ b/docker/docker-compose.redis-cluster.yml @@ -18,6 +18,7 @@ version: '3.0' services: # Redis cluster +# The latest version of Redis compatible with ThingsBoard is 7.2 redis-node-0: image: bitnami/redis-cluster:7.2 volumes: diff --git a/docker/docker-compose.redis-sentinel.yml b/docker/docker-compose.redis-sentinel.yml index a131bc7df9..5cb83afe15 100644 --- a/docker/docker-compose.redis-sentinel.yml +++ b/docker/docker-compose.redis-sentinel.yml @@ -18,6 +18,7 @@ version: '3.0' services: # Redis sentinel + # The latest version of Redis compatible with ThingsBoard is 7.2 redis-master: image: 'bitnami/redis:7.2' volumes: diff --git a/docker/docker-compose.redis.yml b/docker/docker-compose.redis.yml index 16b16d4a8e..cf0930c68a 100644 --- a/docker/docker-compose.redis.yml +++ b/docker/docker-compose.redis.yml @@ -18,6 +18,7 @@ version: '3.0' services: # Redis standalone +# The latest version of Redis compatible with ThingsBoard is 7.2 redis: restart: always image: bitnami/redis:7.2 diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/Latencies.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/Latencies.java index ee21b36c53..4ab9d3c457 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/Latencies.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/Latencies.java @@ -17,8 +17,8 @@ package org.thingsboard.monitoring.data; public class Latencies { - public static final String WS_UPDATE = "wsUpdate"; public static final String WS_CONNECT = "wsConnect"; + public static final String WS_SUBSCRIBE = "wsSubscribe"; public static final String LOG_IN = "logIn"; public static String request(String key) { diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java index 77b68f665d..b5357e5f26 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java @@ -15,6 +15,7 @@ */ package org.thingsboard.monitoring.notification.channels.impl; +import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -23,7 +24,6 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import org.thingsboard.monitoring.notification.channels.NotificationChannel; -import jakarta.annotation.PostConstruct; import java.time.Duration; import java.util.Map; diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java index ae5cc63994..b98dcb0e82 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java @@ -15,6 +15,8 @@ */ package org.thingsboard.monitoring.service; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -29,8 +31,6 @@ import org.thingsboard.monitoring.data.MonitoredServiceKey; import org.thingsboard.monitoring.data.ServiceFailureException; import org.thingsboard.monitoring.util.TbStopWatch; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.HashMap; import java.util.Map; import java.util.UUID; diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java index 5ff8c4f9a1..c667c5b7ac 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.monitoring.service; +import jakarta.annotation.PostConstruct; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -29,7 +30,6 @@ import org.thingsboard.monitoring.data.MonitoredServiceKey; import org.thingsboard.monitoring.service.transport.TransportHealthChecker; import org.thingsboard.monitoring.util.TbStopWatch; -import jakarta.annotation.PostConstruct; import java.net.InetAddress; import java.net.URI; import java.net.URISyntaxException; @@ -100,7 +100,10 @@ public abstract class BaseMonitoringService, T ext reporter.reportLatency(Latencies.LOG_IN, stopWatch.getTime()); try (WsClient wsClient = wsClientFactory.createClient(accessToken)) { + stopWatch.start(); wsClient.subscribeForTelemetry(devices, TransportHealthChecker.TEST_TELEMETRY_KEY).waitForReply(); + reporter.reportLatency(Latencies.WS_SUBSCRIBE, stopWatch.getTime()); + for (BaseHealthChecker healthChecker : healthCheckers) { check(healthChecker, wsClient); } diff --git a/msa/black-box-tests/src/test/resources/docker-compose.redis-ssl.yml b/msa/black-box-tests/src/test/resources/docker-compose.redis-ssl.yml index 0407aa3f8c..3163921be6 100644 --- a/msa/black-box-tests/src/test/resources/docker-compose.redis-ssl.yml +++ b/msa/black-box-tests/src/test/resources/docker-compose.redis-ssl.yml @@ -18,6 +18,7 @@ version: '3.0' services: # Redis standalone +# The latest version of Redis compatible with ThingsBoard is 7.2 redis: restart: always image: bitnami/redis:7.2 diff --git a/msa/tb-node/docker/Dockerfile b/msa/tb-node/docker/Dockerfile index 247edd8d9d..92e4893946 100644 --- a/msa/tb-node/docker/Dockerfile +++ b/msa/tb-node/docker/Dockerfile @@ -18,9 +18,6 @@ FROM thingsboard/openjdk17:bookworm-slim COPY start-tb-node.sh ${pkg.name}.deb /tmp/ -# Required for SWAGGER UI when reverse proxy is used -ENV HTTP_FORWARD_HEADERS_STRATEGY=framework - RUN chmod a+x /tmp/*.sh \ && mv /tmp/start-tb-node.sh /usr/bin && \ (yes | dpkg -i /tmp/${pkg.name}.deb) && \ diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java index e42dee834d..a16b651e15 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java @@ -19,11 +19,11 @@ import io.netty.channel.Channel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.codec.mqtt.MqttVersion; import io.netty.handler.ssl.SslContext; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import lombok.Getter; import lombok.Setter; -import jakarta.annotation.Nonnull; -import jakarta.annotation.Nullable; import java.util.Random; @SuppressWarnings({"WeakerAccess", "unused"}) diff --git a/pom.xml b/pom.xml index 13125c67f9..09cf0393b7 100755 --- a/pom.xml +++ b/pom.xml @@ -83,7 +83,7 @@ 3.9.2 3.25.3 1.63.0 - 1.2.0 + 1.2.2 1.18.32 1.2.5 1.2.5 @@ -105,7 +105,7 @@ org/thingsboard/server/extensions/core/plugin/telemetry/gen/**/* 8.13.2 - 0.4.2 + 0.4.5 15.4 + +

layout.add-new-breakpoint

+ + +
+
+
+ + layout.breakpoint + + {{ getName(this.addBreakpointFormGroup.get('newBreakpointId').value) }} + + {{ getIcon(breakpoint) }} +
{{ getName(breakpoint) }}
+
{{ getSizeDescription(breakpoint) }}
+
+
+
+ + layout.copy-from + + {{ getName(this.addBreakpointFormGroup.get('copyFrom').value) }} + + {{ getIcon(breakpoint) }} +
{{ getName(breakpoint) }}
+
{{ getSizeDescription(breakpoint) }}
+
+
+
+
+
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/add-new-breakpoint-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/add-new-breakpoint-dialog.component.ts new file mode 100644 index 0000000000..a4e8de4b20 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/add-new-breakpoint-dialog.component.ts @@ -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. +/// + +import { Component, Inject } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; +import { BreakpointId } from '@shared/models/dashboard.models'; + +export interface AddNewBreakpointDialogData { + allowBreakpointIds: string[]; + selectedBreakpointIds: string[]; +} + +export interface AddNewBreakpointDialogResult { + newBreakpointId: BreakpointId; + copyFrom: BreakpointId; +} + +@Component({ + selector: 'add-new-breakpoint-dialog', + templateUrl: './add-new-breakpoint-dialog.component.html', +}) +export class AddNewBreakpointDialogComponent extends DialogComponent { + + addBreakpointFormGroup: FormGroup; + + allowBreakpointIds = []; + selectedBreakpointIds = []; + + constructor(protected store: Store, + protected router: Router, + private fb: FormBuilder, + @Inject(MAT_DIALOG_DATA) private data: AddNewBreakpointDialogData, + protected dialogRef: MatDialogRef, + private dashboardUtils: DashboardUtilsService,) { + + super(store, router, dialogRef); + + this.allowBreakpointIds = this.data.allowBreakpointIds; + this.selectedBreakpointIds = this.data.selectedBreakpointIds; + + this.addBreakpointFormGroup = this.fb.group({ + newBreakpointId: [{value: this.allowBreakpointIds[0], disabled: this.allowBreakpointIds.length === 1}], + copyFrom: [{value: 'default', disabled: this.selectedBreakpointIds.length === 1}], + }); + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.dialogRef.close(this.addBreakpointFormGroup.getRawValue()); + } + + getName(breakpointId: BreakpointId): string { + return this.dashboardUtils.getBreakpointName(breakpointId); + } + + getIcon(breakpointId: BreakpointId): string { + return this.dashboardUtils.getBreakpointIcon(breakpointId); + } + + getSizeDescription(breakpointId: BreakpointId): string { + return this.dashboardUtils.getBreakpointSizeDescription(breakpointId); + } +} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.html index 7ded2a399f..0b35de5d9a 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.html @@ -41,22 +41,25 @@ [backgroundImage]="backgroundImage$ | async" [widgets]="layoutCtx.widgets" [widgetLayouts]="layoutCtx.widgetLayouts" - [columns]="layoutCtx.gridSettings.columns" - [outerMargin]="layoutCtx.gridSettings.outerMargin" - [margin]="layoutCtx.gridSettings.margin" + [columns]="columns" + [displayGrid]="displayGrid" + [colWidthInteger]="colWidthInteger" + [outerMargin]="outerMargin" + [margin]="margin" [aliasController]="dashboardCtx.aliasController" [stateController]="dashboardCtx.stateController" [dashboardTimewindow]="dashboardCtx.dashboardTimewindow" [isEdit]="isEdit" - [autofillHeight]="layoutCtx.gridSettings.autoFillHeight && !isEdit" - [mobileAutofillHeight]="layoutCtx.gridSettings.mobileAutoFillHeight && !isEdit" - [mobileRowHeight]="layoutCtx.gridSettings.mobileRowHeight" - [isMobile]="isMobile" - [isMobileDisabled]="widgetEditMode" + [isEditingWidget]="isEditingWidget" + [autofillHeight]="autoFillHeight" + [mobileAutofillHeight]="mobileAutoFillHeight" + [mobileRowHeight]="mobielRowHeigth" + [isMobile]="isMobileValue" + [isMobileDisabled]="isMobileDisabled" [disableWidgetInteraction]="isEdit" - [isEditActionEnabled]="isEdit" - [isExportActionEnabled]="isEdit && !widgetEditMode" - [isRemoveActionEnabled]="isEdit && !widgetEditMode" + [isEditActionEnabled]="isEdit && !isEditingWidget" + [isExportActionEnabled]="isEdit && !widgetEditMode && !isEditingWidget" + [isRemoveActionEnabled]="isEdit && !widgetEditMode && !isEditingWidget" [callbacks]="this" [ignoreLoading]="layoutCtx.ignoreLoading" [parentDashboard]="parentDashboard" diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts index 36fb4d2427..79eef476b3 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts @@ -36,6 +36,9 @@ import { TbCheatSheetComponent } from '@shared/components/cheatsheet.component'; import { TbPopoverComponent } from '@shared/components/popover.component'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { map } from 'rxjs/operators'; +import { displayGrids } from 'angular-gridster2/lib/gridsterConfig.interface'; +import { BreakpointId, LayoutType, ViewFormatType } from '@shared/models/dashboard.models'; +import { isNotEmptyStr } from '@core/utils'; @Component({ selector: 'tb-dashboard-layout', @@ -66,6 +69,58 @@ export class DashboardLayoutComponent extends PageComponent implements ILayoutCo return this.layoutCtxValue; } + get isScada(): boolean { + return this.layoutCtx.gridSettings.layoutType === LayoutType.scada; + } + + get outerMargin(): boolean { + return this.isScada ? false : this.layoutCtx.gridSettings.outerMargin; + } + + get margin(): number { + return this.isScada ? 0 : this.layoutCtx.gridSettings.margin; + } + + get autoFillHeight(): boolean { + return (this.isEdit || this.isScada) ? false : this.layoutCtx.gridSettings.autoFillHeight; + } + + get mobileAutoFillHeight(): boolean { + if (this.isEdit || this.isScada) { + return false; + } else if (this.layoutCtx.breakpoint !== 'default' && this.layoutCtx.gridSettings.viewFormat === ViewFormatType.list) { + return this.layoutCtx.gridSettings.autoFillHeight; + } + return this.layoutCtx.gridSettings.mobileAutoFillHeight; + } + + get isMobileValue(): boolean { + return this.isMobile || (this.layoutCtx.breakpoint !== 'default' && this.layoutCtx.gridSettings.viewFormat === ViewFormatType.list); + } + + get isMobileDisabled(): boolean { + return this.widgetEditMode || this.isScada || (this.layoutCtx.breakpoint !== 'default' && !this.isMobileValue); + } + + get mobielRowHeigth(): number { + if (this.layoutCtx.breakpoint !== 'default' && this.layoutCtx.gridSettings.viewFormat === ViewFormatType.list) { + return this.layoutCtx.gridSettings.rowHeight; + } + return this.layoutCtx.gridSettings.mobileRowHeight; + } + + get colWidthInteger(): boolean { + return this.isScada; + } + + get columns(): number { + return this.layoutCtx.gridSettings.minColumns || this.layoutCtx.gridSettings.columns || 24; + } + + get displayGrid(): displayGrids { + return this.layoutCtx.displayGrid || 'onDrag&Resize'; + } + @Input() dashboardCtx: DashboardContext; @@ -91,11 +146,13 @@ export class DashboardLayoutComponent extends PageComponent implements ILayoutCo private rxSubscriptions = new Array(); - constructor(protected store: Store, - private translate: TranslateService, - private itembuffer: ItemBufferService, - private imagePipe: ImagePipe, - private sanitizer: DomSanitizer) { + constructor( + protected store: Store, + private translate: TranslateService, + private itembuffer: ItemBufferService, + private imagePipe: ImagePipe, + private sanitizer: DomSanitizer, + ) { super(store); this.initHotKeys(); } @@ -161,7 +218,7 @@ export class DashboardLayoutComponent extends PageComponent implements ILayoutCo new Hotkey('ctrl+i', (event: KeyboardEvent) => { if (this.isEdit && !this.isEditingWidget && !this.widgetEditMode) { if (this.itembuffer.canPasteWidgetReference(this.dashboardCtx.getDashboard(), - this.dashboardCtx.state, this.layoutCtx.id)) { + this.dashboardCtx.state, this.layoutCtx.id, this.layoutCtx.breakpoint)) { event.preventDefault(); this.pasteWidgetReference(event); } @@ -226,6 +283,10 @@ export class DashboardLayoutComponent extends PageComponent implements ILayoutCo this.layoutCtx.dashboardCtrl.editWidget($event, this.layoutCtx, widget); } + replaceReferenceWithWidgetCopy($event: Event, widget: Widget): void { + this.layoutCtx.dashboardCtrl.replaceReferenceWithWidgetCopy($event, this.layoutCtx, widget); + } + onExportWidget($event: Event, widget: Widget, widgetTitle: string): void { this.layoutCtx.dashboardCtrl.exportWidget($event, this.layoutCtx, widget, widgetTitle); } @@ -238,16 +299,20 @@ export class DashboardLayoutComponent extends PageComponent implements ILayoutCo this.layoutCtx.dashboardCtrl.widgetMouseDown($event, this.layoutCtx, widget); } + onDashboardMouseDown($event: Event): void { + this.layoutCtx.dashboardCtrl.dashboardMouseDown($event, this.layoutCtx); + } + onWidgetClicked($event: Event, widget: Widget): void { this.layoutCtx.dashboardCtrl.widgetClicked($event, this.layoutCtx, widget); } - prepareDashboardContextMenu($event: Event): Array { + prepareDashboardContextMenu(_: Event): Array { return this.layoutCtx.dashboardCtrl.prepareDashboardContextMenu(this.layoutCtx); } - prepareWidgetContextMenu($event: Event, widget: Widget): Array { - return this.layoutCtx.dashboardCtrl.prepareWidgetContextMenu(this.layoutCtx, widget); + prepareWidgetContextMenu(_: Event, widget: Widget, isReference: boolean): Array { + return this.layoutCtx.dashboardCtrl.prepareWidgetContextMenu(this.layoutCtx, widget, isReference); } copyWidget($event: Event, widget: Widget) { @@ -268,4 +333,24 @@ export class DashboardLayoutComponent extends PageComponent implements ILayoutCo this.layoutCtx.dashboardCtrl.pasteWidgetReference($event, this.layoutCtx, pos); } + updatedCurrentBreakpoint(breakpointId?: BreakpointId, showLayout = true) { + if (!isNotEmptyStr(breakpointId)) { + breakpointId = this.dashboardCtx.breakpoint; + } + if (this.layoutCtx.layoutData[breakpointId]) { + this.layoutCtx.breakpoint = breakpointId; + } else { + this.layoutCtx.breakpoint = 'default'; + } + const layoutInfo = this.layoutCtx.layoutData[this.layoutCtx.breakpoint]; + if (layoutInfo.gridSettings) { + this.layoutCtx.gridSettings = layoutInfo.gridSettings; + } + this.layoutCtx.widgets.setWidgetIds(layoutInfo.widgetIds); + this.layoutCtx.widgetLayouts = layoutInfo.widgetLayouts; + if (showLayout && this.layoutCtx.ctrl) { + this.layoutCtx.ctrl.reload(); + } + this.layoutCtx.ignoreLoading = true; + } } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/layout.models.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/layout.models.ts index 3ea9f553a8..6aa69c51af 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/layout.models.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/layout.models.ts @@ -21,6 +21,7 @@ export interface ILayoutController { selectWidget(widgetId: string, delay?: number); pasteWidget($event: MouseEvent); pasteWidgetReference($event: MouseEvent); + updatedCurrentBreakpoint(breakpoint?: string, showLayout?: boolean); } export enum LayoutWidthType { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/manage-dashboard-layouts-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/manage-dashboard-layouts-dialog.component.html index 0007147712..71adbc91c8 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/manage-dashboard-layouts-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/manage-dashboard-layouts-dialog.component.html @@ -25,16 +25,77 @@ close -
-
- - {{ 'layout.divider' | translate }} - -
+
+
+
+
dashboard.layout
+ + + + {{ layoutTypeTranslations.get(type) | translate }} + + + +
+
+
+
+
+
layout.breakpoints
+
layout.size
+
+
+
+ +
+
+ {{ breakpoint.icon }} +
+
+ {{ breakpoint.name }} +
+
+ {{ breakpoint.descriptionSize }} +
+
+ + +
+
+ +
+
+
+ +
+
+
+ [fxShow]="isDividerLayout"> {{ 'layout.percentage-width' | translate }} @@ -52,7 +113,7 @@
@@ -93,10 +154,10 @@ required>
-
+
-
+
+
+ + + +
+
+
dashboard.move-by
+
+ + + dashboard.cols + + + + dashboard.rows + +
+
+
+
+ + +
+ diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/move-widgets-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/move-widgets-dialog.component.ts new file mode 100644 index 0000000000..ba030c467d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/move-widgets-dialog.component.ts @@ -0,0 +1,62 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; + +export interface MoveWidgetsDialogResult { + cols: number; + rows: number; +} + +@Component({ + selector: 'tb-move-widgets-dialog', + templateUrl: './move-widgets-dialog.component.html', + providers: [], + styleUrls: [] +}) +export class MoveWidgetsDialogComponent extends DialogComponent { + + moveWidgetsFormGroup: UntypedFormGroup; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + private fb: UntypedFormBuilder, + private dialog: MatDialog) { + super(store, router, dialogRef); + + this.moveWidgetsFormGroup = this.fb.group({ + cols: [0, [Validators.required]], + rows: [0, [Validators.required]] + } + ); + } + + cancel(): void { + this.dialogRef.close(null); + } + + move(): void { + const result: MoveWidgetsDialogResult = this.moveWidgetsFormGroup.value; + this.dialogRef.close(result); + } +} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.html new file mode 100644 index 0000000000..417c8d9e26 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.html @@ -0,0 +1,29 @@ + + + {{ getName(selectedBreakpoint) }} + + {{ getIcon(breakpointId) }} +
{{ getName(breakpointId) }}
+
{{ getSizeDescription(breakpointId) }}
+
+
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.scss b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.scss new file mode 100644 index 0000000000..278afa87ad --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.scss @@ -0,0 +1,34 @@ +/** + * 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 { + pointer-events: all; + width: min-content; //for Safari + + .hidden { + display: none; + } +} + +:host ::ng-deep { + .mat-mdc-select.select-dashboard-breakpoint { + .mat-mdc-select-value { + max-width: 200px; + } + .mat-mdc-select-arrow { + width: 24px; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.ts new file mode 100644 index 0000000000..130d0e02b2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.ts @@ -0,0 +1,81 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { DashboardPageComponent } from '@home/components/dashboard-page/dashboard-page.component'; +import { Subscription } from 'rxjs'; +import { BreakpointId } from '@shared/models/dashboard.models'; +import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; + +@Component({ + selector: 'tb-select-dashboard-breakpoint', + templateUrl: './select-dashboard-breakpoint.component.html', + styleUrls: ['./select-dashboard-breakpoint.component.scss'] +}) +export class SelectDashboardBreakpointComponent implements OnInit, OnDestroy { + + @Input() + dashboardCtrl: DashboardPageComponent; + + selectedBreakpoint: BreakpointId = 'default'; + + breakpointIds: Array = ['default']; + + private layoutDataChanged$: Subscription; + + constructor(private dashboardUtils: DashboardUtilsService) { + } + + ngOnInit() { + this.layoutDataChanged$ = this.dashboardCtrl.layouts.main.layoutCtx.layoutDataChanged.subscribe(() => { + if (this.dashboardCtrl.layouts.main.layoutCtx.layoutData) { + this.breakpointIds = Object.keys(this.dashboardCtrl.layouts.main.layoutCtx?.layoutData) as BreakpointId[]; + this.breakpointIds.sort((a, b) => { + const aMaxWidth = this.dashboardUtils.getBreakpointInfoById(a)?.maxWidth || Infinity; + const bMaxWidth = this.dashboardUtils.getBreakpointInfoById(b)?.maxWidth || Infinity; + return bMaxWidth - aMaxWidth; + }); + if (this.breakpointIds.indexOf(this.dashboardCtrl.layouts.main.layoutCtx.breakpoint) > -1) { + this.selectedBreakpoint = this.dashboardCtrl.layouts.main.layoutCtx.breakpoint; + } else { + this.selectedBreakpoint = 'default'; + this.dashboardCtrl.layouts.main.layoutCtx.breakpoint = this.selectedBreakpoint; + } + } + }); + } + + ngOnDestroy() { + this.layoutDataChanged$.unsubscribe(); + } + + selectLayoutChanged() { + this.dashboardCtrl.layouts.main.layoutCtx.ctrl.updatedCurrentBreakpoint(this.selectedBreakpoint); + this.dashboardCtrl.updateLayoutSizes(); + } + + getName(breakpointId: BreakpointId): string { + return this.dashboardUtils.getBreakpointName(breakpointId); + } + + getIcon(breakpointId: BreakpointId): string { + return this.dashboardUtils.getBreakpointIcon(breakpointId); + } + + getSizeDescription(breakpointId: BreakpointId): string { + return this.dashboardUtils.getBreakpointSizeDescription(breakpointId); + } +} diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html index c69a2a4de9..33c8d659f6 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html @@ -23,7 +23,10 @@
{{ item.shortcut | keyboardShortcut }} - {{item.icon}} + {{item.icon}} {{item.value}}
-
+
((index, item) => { - return item; - }), + this.differs.find([]).create((_, item) => item), this.kvDiffers.find([]).create() ); @@ -191,11 +214,9 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo constructor(protected store: Store, public utils: UtilsService, private timeService: TimeService, - private dialogService: DialogService, private breakpointObserver: BreakpointObserver, private differs: IterableDiffers, private kvDiffers: KeyValueDiffers, - private cd: ChangeDetectorRef, private ngZone: NgZone) { super(store); this.authUser = getCurrentAuthUser(store); @@ -208,7 +229,7 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo this.dashboardTimewindow = this.timeService.defaultTimewindow(); } this.gridsterOpts = { - gridType: GridType.ScrollVertical, + gridType: this.gridType || GridType.ScrollVertical, keepFixedHeightInMobile: true, disableWarnings: false, disableAutoPositionOnConflict: false, @@ -216,6 +237,7 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo swap: false, maxRows: 3000, minCols: this.columns ? this.columns : 24, + setGridSize: this.setGridSize, maxCols: 3000, maxItemCols: 1000, maxItemRows: 1000, @@ -226,20 +248,28 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo minItemRows: 1, defaultItemCols: 8, defaultItemRows: 6, - resizable: {enabled: this.isEdit}, - draggable: {enabled: this.isEdit}, - itemChangeCallback: item => this.dashboardWidgets.sortWidgets(), - itemInitCallback: (item, itemComponent) => { + displayGrid: this.displayGrid, + resizable: {enabled: this.isEdit && !this.isEditingWidget, delayStart: 50}, + draggable: {enabled: this.isEdit && !this.isEditingWidget}, + itemChangeCallback: () => this.dashboardWidgets.sortWidgets(), + itemInitCallback: (_, itemComponent) => { (itemComponent.item as DashboardWidget).gridsterItemComponent = itemComponent; + }, + colWidthUpdateCallback: (colWidth) => { + if (this.colWidthInteger) { + return Math.floor(colWidth); + } else { + return colWidth; + } } }; - this.updateMobileOpts(); + this.updateGridOpts(); this.breakpointObserverSubscription = this.breakpointObserver .observe(MediaBreakpoints['gt-sm']).subscribe( () => { - this.updateMobileOpts(); + this.updateGridOpts(); this.notifyGridsterOptionsChanged(); } ); @@ -266,20 +296,24 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo } ngOnChanges(changes: SimpleChanges): void { - let updateMobileOpts = false; - let updateLayoutOpts = false; + let updateGridOpts = false; + let updateColumnOpts = false; let updateEditingOpts = false; + let updateDisplayGridOpts = false; let updateWidgets = false; let updateDashboardTimewindow = false; for (const propName of Object.keys(changes)) { const change = changes[propName]; if (!change.firstChange && change.currentValue !== change.previousValue) { - if (['isMobile', 'isMobileDisabled', 'autofillHeight', 'mobileAutofillHeight', 'mobileRowHeight'].includes(propName)) { - updateMobileOpts = true; + if (['isMobile', 'isMobileDisabled', 'autofillHeight', 'mobileAutofillHeight', + 'mobileRowHeight', 'colWidthInteger'].includes(propName)) { + updateGridOpts = true; } else if (['outerMargin', 'margin', 'columns'].includes(propName)) { - updateLayoutOpts = true; - } else if (propName === 'isEdit') { + updateColumnOpts = true; + } else if (['isEdit', 'isEditingWidget'].includes(propName)) { updateEditingOpts = true; + } else if (propName === 'displayGrid') { + updateDisplayGridOpts = true; } else if (['widgets', 'widgetLayouts'].includes(propName)) { updateWidgets = true; } else if (propName === 'dashboardTimewindow') { @@ -293,16 +327,19 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo this.dashboardTimewindowChangedSubject.next(this.dashboardTimewindow); } - if (updateLayoutOpts) { - this.updateLayoutOpts(); + if (updateColumnOpts) { + this.updateColumnOpts(); } - if (updateMobileOpts) { - this.updateMobileOpts(); + if (updateGridOpts) { + this.updateGridOpts(); } if (updateEditingOpts) { this.updateEditingOpts(); } - if (updateMobileOpts || updateLayoutOpts || updateEditingOpts) { + if (updateDisplayGridOpts) { + this.updateDisplayGridOpts(); + } + if (updateGridOpts || updateColumnOpts || updateEditingOpts || updateDisplayGridOpts) { this.notifyGridsterOptionsChanged(); } } @@ -352,6 +389,15 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo } } + onDashboardMouseDown($event: MouseEvent) { + if (this.callbacks && this.callbacks.onDashboardMouseDown) { + if ($event) { + $event.stopPropagation(); + } + this.callbacks.onDashboardMouseDown($event); + } + } + openDashboardContextMenu($event: MouseEvent) { if (this.callbacks && this.callbacks.prepareDashboardContextMenu) { const items = this.callbacks.prepareDashboardContextMenu($event); @@ -369,7 +415,7 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo private openWidgetContextMenu($event: MouseEvent, widget: DashboardWidget) { if (this.callbacks && this.callbacks.prepareWidgetContextMenu) { - const items = this.callbacks.prepareWidgetContextMenu($event, widget.widget); + const items = this.callbacks.prepareWidgetContextMenu($event, widget.widget, widget.isReference); if (items && items.length) { $event.preventDefault(); $event.stopPropagation(); @@ -407,6 +453,9 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo case WidgetComponentActionType.REMOVE: this.removeWidget($event, widget); break; + case WidgetComponentActionType.REPLACE_REFERENCE_WITH_WIDGET_COPY: + this.replaceReferenceWithWidgetCopy($event, widget); + break; } } @@ -431,6 +480,15 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo } } + private replaceReferenceWithWidgetCopy($event: Event, widget: DashboardWidget) { + if ($event) { + $event.stopPropagation(); + } + if (this.isEditActionEnabled && this.callbacks && this.callbacks.replaceReferenceWithWidgetCopy) { + this.callbacks.replaceReferenceWithWidgetCopy($event, widget.widget); + } + } + private exportWidget($event: Event, widget: DashboardWidget) { if ($event) { $event.stopPropagation(); @@ -506,7 +564,7 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo widget.gridsterItemComponent$().subscribe((gridsterItem) => { const gridsterItemElement = gridsterItem.el as HTMLElement; const offset = (parentElement.clientHeight - gridsterItemElement.clientHeight) / 2; - let scrollTop; + let scrollTop: number; if (this.isMobileSize) { scrollTop = gridsterItemElement.offsetTop; } else { @@ -519,7 +577,15 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo }); } - private updateMobileOpts(parentHeight?: number) { + private onGridsterParentResize() { + const parentHeight = this.gridster.el.offsetHeight; + if (this.isMobileSize && this.mobileAutofillHeight && parentHeight) { + this.updateGridOpts(parentHeight); + } + this.notifyGridsterOptionsChanged(); + } + + private updateGridOpts(parentHeight?: number) { let updateWidgetRowsAndSort = false; const isMobileSize = this.checkIsMobileSize(); if (this.isMobileSize !== isMobileSize) { @@ -530,10 +596,9 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo if (autofillHeight) { this.gridsterOpts.gridType = this.isMobileSize ? GridType.Fixed : GridType.Fit; } else { - this.gridsterOpts.gridType = this.isMobileSize ? GridType.Fixed : GridType.ScrollVertical; + this.gridsterOpts.gridType = this.isMobileSize ? GridType.Fixed : this.gridType || GridType.ScrollVertical; } - const mobileBreakPoint = this.isMobileSize ? 20000 : 0; - this.gridsterOpts.mobileBreakpoint = mobileBreakPoint; + this.gridsterOpts.mobileBreakpoint = this.isMobileSize ? 20000 : 0; const rowSize = this.detectRowSize(this.isMobileSize, autofillHeight, parentHeight); if (this.gridsterOpts.fixedRowHeight !== rowSize) { this.gridsterOpts.fixedRowHeight = rowSize; @@ -543,23 +608,19 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo } } - private onGridsterParentResize() { - const parentHeight = this.gridster.el.offsetHeight; - if (this.isMobileSize && this.mobileAutofillHeight && parentHeight) { - this.updateMobileOpts(parentHeight); - } - this.notifyGridsterOptionsChanged(); - } - - private updateLayoutOpts() { + private updateColumnOpts() { this.gridsterOpts.minCols = this.columns ? this.columns : 24; this.gridsterOpts.outerMargin = isDefined(this.outerMargin) ? this.outerMargin : true; this.gridsterOpts.margin = isDefined(this.margin) ? this.margin : 10; } private updateEditingOpts() { - this.gridsterOpts.resizable.enabled = this.isEdit; - this.gridsterOpts.draggable.enabled = this.isEdit; + this.gridsterOpts.resizable.enabled = this.isEdit && !this.isEditingWidget; + this.gridsterOpts.draggable.enabled = this.isEdit && !this.isEditingWidget; + } + + private updateDisplayGridOpts() { + this.gridsterOpts.displayGrid = this.displayGrid; } public notifyGridsterOptionsChanged() { diff --git a/ui-ngx/src/app/modules/home/components/details-panel.component.html b/ui-ngx/src/app/modules/home/components/details-panel.component.html index d3e50ca1d7..2483a5b5e3 100644 --- a/ui-ngx/src/app/modules/home/components/details-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/details-panel.component.html @@ -40,7 +40,7 @@ search -
diff --git a/ui-ngx/src/app/modules/home/components/details-panel.component.ts b/ui-ngx/src/app/modules/home/components/details-panel.component.ts index bdcfbd636b..3b536c809e 100644 --- a/ui-ngx/src/app/modules/home/components/details-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/details-panel.component.ts @@ -28,6 +28,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { UntypedFormGroup } from '@angular/forms'; import { Subscription } from 'rxjs'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ selector: 'tb-details-panel', @@ -44,6 +45,10 @@ export class DetailsPanelComponent extends PageComponent implements OnDestroy { @Input() isShowSearch = false; @Input() backgroundColor = '#FFF'; + @Input() + @coerceBoolean() + showCloseDetails = true; + private theFormValue: UntypedFormGroup; private formSubscription: Subscription = null; diff --git a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts index dd3e846d92..99b6275dfe 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts @@ -402,7 +402,7 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa this.cd.detectChanges(); } - updateData(closeDetails: boolean = true) { + updateData(closeDetails: boolean = true, reloadEntity: boolean = true) { if (closeDetails) { this.isDetailsOpen = false; } @@ -427,7 +427,7 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa timePageLink.endTime = interval.endTime; } this.dataSource.loadEntities(this.pageLink); - if (this.isDetailsOpen && this.entityDetailsPanel) { + if (reloadEntity && this.isDetailsOpen && this.entityDetailsPanel) { this.entityDetailsPanel.reloadEntity(); } } @@ -511,7 +511,7 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa } onEntityUpdated(entity: BaseData) { - this.updateData(false); + this.updateData(false, false); this.entitiesTableConfig.entityUpdated(entity); } diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts index ea1dfbf46d..1ec56028af 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts @@ -40,11 +40,12 @@ import { UntypedFormGroup } from '@angular/forms'; import { EntityComponent } from './entity.component'; import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; import { EntityAction } from '@home/models/entity/entity-component.models'; -import { Observable, ReplaySubject, Subscription } from 'rxjs'; +import { Observable, ReplaySubject, Subscription, throwError } from 'rxjs'; import { MatTab, MatTabGroup } from '@angular/material/tabs'; import { EntityTabsComponent } from '@home/components/entity/entity-tabs.component'; import { deepClone, mergeDeep } from '@core/utils'; -import { entityIdEquals } from '@shared/models/id/entity-id'; +import { catchError } from 'rxjs/operators'; +import { HttpStatusCode } from '@angular/common/http'; @Component({ selector: 'tb-entity-details-panel', @@ -228,7 +229,7 @@ export class EntityDetailsPanelComponent extends PageComponent implements AfterV } hideDetailsTabs(): boolean { - return this.isEditValue && this.entitiesTableConfig.hideDetailsTabsOnEdit; + return !this.entityTabsComponent || this.isEditValue && this.entitiesTableConfig.hideDetailsTabsOnEdit; } reloadEntity(): Observable> { @@ -288,7 +289,16 @@ export class EntityDetailsPanelComponent extends PageComponent implements AfterV editingEntity.additionalInfo = mergeDeep((this.editingEntity as any).additionalInfo, this.entityComponent.entityFormValue()?.additionalInfo); } - this.entitiesTableConfig.saveEntity(editingEntity, this.editingEntity).subscribe( + this.entitiesTableConfig.saveEntity(editingEntity, this.editingEntity) + .pipe( + catchError((err) => { + if (err.status === HttpStatusCode.Conflict) { + return this.entitiesTableConfig.loadEntity(this.currentEntityId); + } + return throwError(() => err); + }) + ) + .subscribe( (entity) => { this.entity = entity; this.entityComponent.entity = entity; diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 5390a915d3..64d9e816bd 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -114,6 +114,9 @@ import { EditWidgetComponent } from '@home/components/dashboard-page/edit-widget import { DashboardWidgetSelectComponent } from '@home/components/dashboard-page/dashboard-widget-select.component'; import { AddWidgetDialogComponent } from '@home/components/dashboard-page/add-widget-dialog.component'; import { ManageDashboardLayoutsDialogComponent } from '@home/components/dashboard-page/layout/manage-dashboard-layouts-dialog.component'; +import { + AddNewBreakpointDialogComponent +} from '@home/components/dashboard-page/layout/add-new-breakpoint-dialog.component'; import { DashboardSettingsDialogComponent } from '@home/components/dashboard-page/dashboard-settings-dialog.component'; import { ManageDashboardStatesDialogComponent } from '@home/components/dashboard-page/states/manage-dashboard-states-dialog.component'; import { DashboardStateDialogComponent } from '@home/components/dashboard-page/states/dashboard-state-dialog.component'; @@ -124,7 +127,10 @@ import { EdgeDownlinkTableHeaderComponent } from '@home/components/edge/edge-dow import { DisplayWidgetTypesPanelComponent } from '@home/components/dashboard-page/widget-types-panel.component'; import { AlarmDurationPredicateValueComponent } from '@home/components/profile/alarm/alarm-duration-predicate-value.component'; import { DashboardImageDialogComponent } from '@home/components/dashboard-page/dashboard-image-dialog.component'; -import { WidgetContainerComponent } from '@home/components/widget/widget-container.component'; +import { + EditWidgetActionsTooltipComponent, + WidgetContainerComponent +} from '@home/components/widget/widget-container.component'; import { SnmpDeviceProfileTransportModule } from '@home/components/profile/device/snmp/snmp-device-profile-transport.module'; import { DeviceCredentialsModule } from '@home/components/device/device-credentials.module'; import { DeviceProfileCommonModule } from '@home/components/profile/device/common/device-profile-common.module'; @@ -171,6 +177,10 @@ import { import { WidgetConfigComponentsModule } from '@home/components/widget/config/widget-config-components.module'; import { BasicWidgetConfigModule } from '@home/components/widget/config/basic/basic-widget-config.module'; import { DeleteTimeseriesPanelComponent } from '@home/components/attribute/delete-timeseries-panel.component'; +import { MoveWidgetsDialogComponent } from '@home/components/dashboard-page/layout/move-widgets-dialog.component'; +import { + SelectDashboardBreakpointComponent +} from '@home/components/dashboard-page/layout/select-dashboard-breakpoint.component'; @NgModule({ declarations: @@ -207,6 +217,7 @@ import { DeleteTimeseriesPanelComponent } from '@home/components/attribute/delet EntityAliasDialogComponent, DashboardComponent, WidgetContainerComponent, + EditWidgetActionsTooltipComponent, WidgetComponent, WidgetConfigComponent, WidgetPreviewComponent, @@ -280,10 +291,13 @@ import { DeleteTimeseriesPanelComponent } from '@home/components/attribute/delet DashboardPageComponent, DashboardStateComponent, DashboardLayoutComponent, + SelectDashboardBreakpointComponent, EditWidgetComponent, DashboardWidgetSelectComponent, AddWidgetDialogComponent, + MoveWidgetsDialogComponent, ManageDashboardLayoutsDialogComponent, + AddNewBreakpointDialogComponent, DashboardSettingsDialogComponent, ManageDashboardStatesDialogComponent, DashboardStateDialogComponent, @@ -345,6 +359,7 @@ import { DeleteTimeseriesPanelComponent } from '@home/components/attribute/delet EntityAliasDialogComponent, DashboardComponent, WidgetContainerComponent, + EditWidgetActionsTooltipComponent, WidgetComponent, WidgetConfigComponent, WidgetPreviewComponent, @@ -411,10 +426,13 @@ import { DeleteTimeseriesPanelComponent } from '@home/components/attribute/delet DashboardPageComponent, DashboardStateComponent, DashboardLayoutComponent, + SelectDashboardBreakpointComponent, EditWidgetComponent, DashboardWidgetSelectComponent, AddWidgetDialogComponent, + MoveWidgetsDialogComponent, ManageDashboardLayoutsDialogComponent, + AddNewBreakpointDialogComponent, DashboardSettingsDialogComponent, ManageDashboardStatesDialogComponent, DashboardStateDialogComponent, diff --git a/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html index 851eff2375..d96739a5cf 100644 --- a/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html +++ b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html @@ -44,7 +44,11 @@ - empty notification +
+ + + +
notification.no-notifications-yet
diff --git a/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.scss b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.scss new file mode 100644 index 0000000000..577c3ccb72 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.scss @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../scss/constants'; + +.tb-no-notification-svg-color { + color: $tb-primary-color; +} diff --git a/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts index 6020d6586a..32fcf2dc4a 100644 --- a/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts +++ b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts @@ -29,7 +29,7 @@ import { NotificationSubscriber } from '@shared/models/telemetry/telemetry.model @Component({ selector: 'tb-show-notification-popover', templateUrl: './show-notification-popover.component.html', - styleUrls: [] + styleUrls: ['show-notification-popover.component.scss'] }) export class ShowNotificationPopoverComponent extends PageComponent implements OnDestroy, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html index 9d5458f942..3b45e4dcfb 100644 --- a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html @@ -108,8 +108,7 @@ + isAdd="true"> diff --git a/ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.ts index e96f4224ec..f65a44d019 100644 --- a/ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.ts @@ -327,7 +327,7 @@ export class AssetProfileAutocompleteComponent implements ControlValueAccessor, } createAssetProfile($event: Event, profileName: string) { - $event.preventDefault(); + $event.stopPropagation(); const assetProfile: AssetProfile = { name: profileName } as AssetProfile; @@ -337,7 +337,7 @@ export class AssetProfileAutocompleteComponent implements ControlValueAccessor, } editAssetProfile($event: Event) { - $event.preventDefault(); + $event.stopPropagation(); this.assetProfileService.getAssetProfile(this.modelValue.id).subscribe( (assetProfile) => { this.openAssetProfileDialog(assetProfile, false); diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.ts index 3e4501736a..f7538c6524 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.ts @@ -349,7 +349,7 @@ export class DeviceProfileAutocompleteComponent implements ControlValueAccessor, } createDeviceProfile($event: Event, profileName: string) { - $event.preventDefault(); + $event.stopPropagation(); const deviceProfile: DeviceProfile = { name: profileName, transportType: this.transportType @@ -360,7 +360,7 @@ export class DeviceProfileAutocompleteComponent implements ControlValueAccessor, } editDeviceProfile($event: Event) { - $event.preventDefault(); + $event.stopPropagation(); this.deviceProfileService.getDeviceProfile(this.modelValue.id).subscribe( (deviceProfile) => { this.openDeviceProfileDialog(deviceProfile, false); diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html index aed11c185b..10c1349b40 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html @@ -137,8 +137,7 @@ + formControlName="configuration"> @@ -151,8 +150,7 @@ + [isAdd] = "isTransportTypeChanged"> diff --git a/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.ts index fc44a35eda..d772cffc8b 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.ts @@ -28,7 +28,6 @@ import { } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@app/core/core.state'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { CoapDeviceProfileTransportConfiguration, coapDeviceTypeTranslationMap, @@ -74,7 +73,6 @@ export class CoapDeviceProfileTransportConfigurationComponent implements Control coapTransportConfigurationFormGroup: UntypedFormGroup; private destroy$ = new Subject(); - private requiredValue: boolean; private transportPayloadTypeConfiguration = this.fb.group({ transportPayloadType: [TransportPayloadType.JSON, Validators.required], @@ -84,15 +82,6 @@ export class CoapDeviceProfileTransportConfigurationComponent implements Control deviceRpcResponseProtoSchema: [defaultRpcResponseSchema, Validators.required] }); - get required(): boolean { - return this.requiredValue; - } - - @Input() - set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } - @Input() disabled: boolean; diff --git a/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-configuration.component.ts index f24be2a996..ff8e947ba3 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-configuration.component.ts @@ -18,7 +18,6 @@ import { Component, forwardRef, Input, OnInit } from '@angular/core'; import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@app/core/core.state'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { DefaultDeviceProfileConfiguration, DeviceProfileConfiguration, @@ -39,15 +38,6 @@ export class DefaultDeviceProfileConfigurationComponent implements ControlValueA defaultDeviceProfileConfigurationFormGroup: UntypedFormGroup; - private requiredValue: boolean; - get required(): boolean { - return this.requiredValue; - } - @Input() - set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } - @Input() disabled: boolean; diff --git a/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-transport-configuration.component.ts index d2031e55f7..1289d83745 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-transport-configuration.component.ts @@ -15,39 +15,42 @@ /// import { Component, forwardRef, Input, OnInit } from '@angular/core'; -import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@app/core/core.state'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; -import { - DefaultDeviceProfileTransportConfiguration, - DeviceProfileTransportConfiguration, - DeviceTransportType -} from '@shared/models/device.models'; +import { DefaultDeviceProfileTransportConfiguration, DeviceTransportType } from '@shared/models/device.models'; @Component({ selector: 'tb-default-device-profile-transport-configuration', templateUrl: './default-device-profile-transport-configuration.component.html', styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DefaultDeviceProfileTransportConfigurationComponent), - multi: true - }] + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DefaultDeviceProfileTransportConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DefaultDeviceProfileTransportConfigurationComponent), + multi: true + } + ] }) -export class DefaultDeviceProfileTransportConfigurationComponent implements ControlValueAccessor, OnInit { +export class DefaultDeviceProfileTransportConfigurationComponent implements ControlValueAccessor, OnInit, Validator { defaultDeviceProfileTransportConfigurationFormGroup: UntypedFormGroup; - private requiredValue: boolean; - get required(): boolean { - return this.requiredValue; - } - @Input() - set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } - @Input() disabled: boolean; @@ -86,12 +89,17 @@ export class DefaultDeviceProfileTransportConfigurationComponent implements Cont this.defaultDeviceProfileTransportConfigurationFormGroup.patchValue({configuration: value}, {emitEvent: false}); } - private updateModel() { - let configuration: DeviceProfileTransportConfiguration = null; - if (this.defaultDeviceProfileTransportConfigurationFormGroup.valid) { - configuration = this.defaultDeviceProfileTransportConfigurationFormGroup.getRawValue().configuration; - configuration.type = DeviceTransportType.DEFAULT; + validate(c: UntypedFormControl): ValidationErrors | null { + return (this.defaultDeviceProfileTransportConfigurationFormGroup.valid) ? null : { + configuration: { + valid: false + } } + } + + private updateModel() { + const configuration = this.defaultDeviceProfileTransportConfigurationFormGroup.getRawValue().configuration; + configuration.type = DeviceTransportType.DEFAULT; this.propagateChange(configuration); } } diff --git a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.html index 8cba244590..0ede71e49b 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.html @@ -19,7 +19,6 @@
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.ts index 8b246ce7e0..9f51562c72 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.ts @@ -18,7 +18,6 @@ import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@app/core/core.state'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { DeviceProfileConfiguration, DeviceProfileType } from '@shared/models/device.models'; import { deepClone } from '@core/utils'; import { Subject } from 'rxjs'; @@ -42,15 +41,6 @@ export class DeviceProfileConfigurationComponent implements ControlValueAccessor private destroy$ = new Subject(); - private requiredValue: boolean; - get required(): boolean { - return this.requiredValue; - } - @Input() - set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } - @Input() disabled: boolean; diff --git a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.html index e03278f844..24b21f9ff0 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.html @@ -19,32 +19,27 @@
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.ts index 7d1ab27ebd..998235aefd 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.ts @@ -23,12 +23,10 @@ import { UntypedFormControl, UntypedFormGroup, ValidationErrors, - Validator, - Validators + Validator } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@app/core/core.state'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { DeviceProfileTransportConfiguration, DeviceTransportType } from '@shared/models/device.models'; import { deepClone } from '@core/utils'; @@ -55,15 +53,6 @@ export class DeviceProfileTransportConfigurationComponent implements ControlValu deviceProfileTransportConfigurationFormGroup: UntypedFormGroup; - private requiredValue: boolean; - get required(): boolean { - return this.requiredValue; - } - @Input() - set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } - @Input() disabled: boolean; @@ -87,7 +76,7 @@ export class DeviceProfileTransportConfigurationComponent implements ControlValu ngOnInit() { this.deviceProfileTransportConfigurationFormGroup = this.fb.group({ - configuration: [null, Validators.required] + configuration: [null] }); this.deviceProfileTransportConfigurationFormGroup.valueChanges.subscribe(() => { this.updateModel(); @@ -110,7 +99,7 @@ export class DeviceProfileTransportConfigurationComponent implements ControlValu delete configuration.type; } setTimeout(() => { - this.deviceProfileTransportConfigurationFormGroup.patchValue({configuration}, {emitEvent: false}); + this.deviceProfileTransportConfigurationFormGroup.patchValue({configuration}); }, 0); } diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.html index d5665191ce..43ff1078af 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.html @@ -22,7 +22,7 @@
{{ (serverFormGroup.get('bootstrapServerIs').value ? 'device-profile.lwm2m.bootstrap-server' : 'device-profile.lwm2m.lwm2m-server') | translate }}
-
+
{{ ('device-profile.lwm2m.short-id' | translate) + ': ' }} {{ serverFormGroup.get('shortServerId').value }}
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts index a6f6e3a467..0bda4c08c2 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts @@ -25,7 +25,6 @@ import { Validator, Validators } from '@angular/forms'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { ATTRIBUTE, DEFAULT_EDRX_CYCLE, @@ -47,7 +46,7 @@ import { ObjectIDVerTranslationMap } from './lwm2m-profile-config.models'; import { DeviceProfileService } from '@core/http/device-profile.service'; -import { deepClone, isDefinedAndNotNull, isEmpty, isUndefined } from '@core/utils'; +import { deepClone, isDefinedAndNotNull, isEmpty } from '@core/utils'; import { Direction } from '@shared/models/page/sort-order'; import _ from 'lodash'; import { Subject } from 'rxjs'; @@ -77,7 +76,6 @@ export class Lwm2mDeviceProfileTransportConfigurationComponent implements Contro public disabled = false; public isTransportWasRunWithBootstrap = true; public isBootstrapServerUpdateEnable: boolean; - private requiredValue: boolean; private destroy$ = new Subject(); lwm2mDeviceProfileFormGroup: UntypedFormGroup; @@ -88,15 +86,6 @@ export class Lwm2mDeviceProfileTransportConfigurationComponent implements Contro sortFunction: (key: string, value: object) => object; - get required(): boolean { - return this.requiredValue; - } - - @Input() - set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } - @Input() isAdd: boolean; diff --git a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.ts index 1566904258..3cabb2a541 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.ts @@ -29,7 +29,6 @@ import { } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@app/core/core.state'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { defaultAttributesSchema, defaultRpcRequestSchema, @@ -70,16 +69,6 @@ export class MqttDeviceProfileTransportConfigurationComponent implements Control mqttDeviceProfileTransportConfigurationFormGroup: UntypedFormGroup; private destroy$ = new Subject(); - private requiredValue: boolean; - - get required(): boolean { - return this.requiredValue; - } - - @Input() - set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } @Input() disabled: boolean; diff --git a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.ts index f81422943a..202fdca49b 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.ts @@ -25,7 +25,6 @@ import { Validator, Validators } from '@angular/forms'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { DeviceTransportType, SnmpDeviceProfileTransportConfiguration @@ -63,16 +62,6 @@ export class SnmpDeviceProfileTransportConfigurationComponent implements OnInit, snmpDeviceProfileTransportConfigurationFormGroup: UntypedFormGroup; private destroy$ = new Subject(); - private requiredValue: boolean; - - get required(): boolean { - return this.requiredValue; - } - - @Input() - set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } @Input() disabled: boolean; diff --git a/ui-ngx/src/app/modules/home/components/router-tabs.component.ts b/ui-ngx/src/app/modules/home/components/router-tabs.component.ts index 306e1b5af9..681558deb0 100644 --- a/ui-ngx/src/app/modules/home/components/router-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/components/router-tabs.component.ts @@ -107,7 +107,7 @@ export class RouterTabsComponent extends PageComponent implements OnInit { if (found) { const rootPath = sectionPath.substring(0, sectionPath.length - found.path.length); const isRoot = rootPath === ''; - const tabs: Array = found ? found.pages.filter(page => !page.disabled && (!page.rootOnly || isRoot)) : []; + const tabs: Array = found ? found.pages.filter(page => !page.rootOnly || isRoot) : []; return tabs.map((tab) => ({...tab, path: rootPath + tab.path})); } return []; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts index ac8cb25d60..a32d7c2311 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts @@ -139,6 +139,10 @@ import { import { LabelValueCardBasicConfigComponent } from '@home/components/widget/config/basic/cards/label-value-card-basic-config.component'; +import { + UnreadNotificationBasicConfigComponent +} from '@home/components/widget/config/basic/cards/unread-notification-basic-config.component'; +import { ScadaSymbolBasicConfigComponent } from '@home/components/widget/config/basic/scada/scada-symbol-basic-config.component'; @NgModule({ declarations: [ @@ -185,7 +189,9 @@ import { DigitalSimpleGaugeBasicConfigComponent, MobileAppQrCodeBasicConfigComponent, LabelCardBasicConfigComponent, - LabelValueCardBasicConfigComponent + LabelValueCardBasicConfigComponent, + UnreadNotificationBasicConfigComponent, + ScadaSymbolBasicConfigComponent ], imports: [ CommonModule, @@ -231,10 +237,12 @@ import { BarChartBasicConfigComponent, PolarAreaChartBasicConfigComponent, RadarChartBasicConfigComponent, + ScadaSymbolBasicConfigComponent, DigitalSimpleGaugeBasicConfigComponent, MobileAppQrCodeBasicConfigComponent, LabelCardBasicConfigComponent, - LabelValueCardBasicConfigComponent + LabelValueCardBasicConfigComponent, + UnreadNotificationBasicConfigComponent ] }) export class BasicWidgetConfigModule { @@ -277,5 +285,7 @@ export const basicWidgetConfigComponentsMap: {[key: string]: Type
widgets.action-button.on-click
@@ -38,9 +38,9 @@
widgets.button-state.activated-state
widgets.button-state.disabled-state
widgets.command-button.on-click
widgets.button-state.disabled-state
widgets.rpc-state.initial-state
widgets.power-button.power-on
widgets.power-button.power-off
widgets.rpc-state.disabled-state
widgets.rpc-state.initial-state
widgets.toggle-button.check
widgets.toggle-button.uncheck
widgets.rpc-state.disabled-state
+ + +
+
widget-config.appearance
+
+ + {{ 'widget-config.title' | translate }} + +
+ + + + + + + +
+
+
+ + {{ 'widgets.notification.icon' | translate }} + +
+ + + + + + + + +
+
+
+
{{ 'widgets.notification.max-notification-display' | translate }}
+ + + +
+
+ +
+
+ + {{ 'widgets.notification.counter' | translate }} + +
+
+
{{ 'widgets.notification.counter-value' | translate }}
+
+ + + + +
+
+
+
{{ 'widgets.notification.counter-color' | translate }}
+ + +
+
+ +
+
widget-config.card-appearance
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
widget-config.show-card-buttons
+ + {{ 'widgets.notification.button-view-all' | translate }} + {{ 'widgets.notification.button-filter' | translate }} + {{ 'widgets.notification.button-mark-read' | translate }} + {{ 'fullscreen.fullscreen' | translate }} + +
+
+
{{ 'widget-config.card-border-radius' | translate }}
+ + + +
+
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/unread-notification-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/unread-notification-basic-config.component.ts new file mode 100644 index 0000000000..69524ce6ab --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/unread-notification-basic-config.component.ts @@ -0,0 +1,197 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; +import { WidgetConfig, } from '@shared/models/widget.models'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { isUndefined } from '@core/utils'; +import { cssSizeToStrSize, resolveCssSize } from '@shared/models/widget-settings.models'; +import { + unreadNotificationDefaultSettings, + UnreadNotificationWidgetSettings +} from '@home/components/widget/lib/cards/unread-notification-widget.models'; + +@Component({ + selector: 'tb-unread-notification-basic-config', + templateUrl: './unread-notification-basic-config.component.html', + styleUrls: ['../basic-config.scss'] +}) +export class UnreadNotificationBasicConfigComponent extends BasicWidgetConfigComponent { + + unreadNotificationWidgetConfigForm: UntypedFormGroup; + + countPreviewFn = this._countPreviewFn.bind(this); + + constructor(protected store: Store, + protected widgetConfigComponent: WidgetConfigComponent, + private fb: UntypedFormBuilder) { + super(store, widgetConfigComponent); + } + + protected configForm(): UntypedFormGroup { + return this.unreadNotificationWidgetConfigForm; + } + + protected onConfigSet(configData: WidgetConfigComponentData) { + const iconSize = resolveCssSize(configData.config.iconSize); + const settings: UnreadNotificationWidgetSettings = {...unreadNotificationDefaultSettings, ...(configData.config.settings || {})}; + this.unreadNotificationWidgetConfigForm = this.fb.group({ + + showTitle: [configData.config.showTitle, []], + title: [configData.config.title, []], + titleFont: [configData.config.titleFont, []], + titleColor: [configData.config.titleColor, []], + + showIcon: [configData.config.showTitleIcon, []], + iconSize: [iconSize[0], [Validators.min(0)]], + iconSizeUnit: [iconSize[1], []], + icon: [configData.config.titleIcon, []], + iconColor: [configData.config.iconColor, []], + + maxNotificationDisplay: [settings.maxNotificationDisplay, [Validators.required, Validators.min(1)]], + showCounter: [settings.showCounter, []], + counterValueFont: [settings.counterValueFont, []], + counterValueColor: [settings.counterValueColor, []], + counterColor: [settings.counterColor, []], + + background: [settings.background, []], + padding: [settings.padding, []], + + cardButtons: [this.getCardButtons(configData.config), []], + borderRadius: [configData.config.borderRadius, []], + actions: [configData.config.actions || {}, []] + }); + } + protected validatorTriggers(): string[] { + return ['showCounter', 'showTitle', 'showIcon']; + } + + protected updateValidators(emitEvent: boolean, trigger?: string) { + const showCounter: boolean = this.unreadNotificationWidgetConfigForm.get('showCounter').value; + const showTitle: boolean = this.unreadNotificationWidgetConfigForm.get('showTitle').value; + const showIcon: boolean = this.unreadNotificationWidgetConfigForm.get('showIcon').value; + + if (showIcon) { + this.unreadNotificationWidgetConfigForm.get('iconSize').enable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('iconSizeUnit').enable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('icon').enable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('iconColor').enable({emitEvent}); + } else { + this.unreadNotificationWidgetConfigForm.get('iconSize').disable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('iconSizeUnit').disable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('icon').disable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('iconColor').disable({emitEvent}); + } + + if (showCounter) { + this.unreadNotificationWidgetConfigForm.get('counterValueFont').enable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('counterValueColor').enable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('counterColor').enable({emitEvent}); + } else { + this.unreadNotificationWidgetConfigForm.get('counterValueFont').disable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('counterValueColor').disable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('counterColor').disable({emitEvent}); + } + + if (showTitle) { + this.unreadNotificationWidgetConfigForm.get('title').enable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('titleFont').enable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('titleColor').enable({emitEvent}); + + this.unreadNotificationWidgetConfigForm.get('showCounter').enable({emitEvent: false}); + this.unreadNotificationWidgetConfigForm.get('showIcon').enable({emitEvent: false}); + } else { + this.unreadNotificationWidgetConfigForm.get('title').disable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('titleFont').disable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('titleColor').disable({emitEvent}); + + this.unreadNotificationWidgetConfigForm.get('showIcon').disable({emitEvent: false}); + this.unreadNotificationWidgetConfigForm.get('iconSize').disable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('iconSizeUnit').disable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('icon').disable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('iconColor').disable({emitEvent}); + + this.unreadNotificationWidgetConfigForm.get('showCounter').disable({emitEvent: false}); + this.unreadNotificationWidgetConfigForm.get('counterValueFont').disable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('counterValueColor').disable({emitEvent}); + this.unreadNotificationWidgetConfigForm.get('counterColor').disable({emitEvent}); + } + } + + protected prepareOutputConfig(config: any): WidgetConfigComponentData { + + this.widgetConfig.config.showTitle = config.showTitle; + this.widgetConfig.config.title = config.title; + this.widgetConfig.config.titleFont = config.titleFont; + this.widgetConfig.config.titleColor = config.titleColor; + + this.widgetConfig.config.showTitleIcon = config.showIcon; + this.widgetConfig.config.iconSize = cssSizeToStrSize(config.iconSize, config.iconSizeUnit); + this.widgetConfig.config.titleIcon = config.icon; + this.widgetConfig.config.iconColor = config.iconColor; + + this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; + + this.widgetConfig.config.settings.maxNotificationDisplay = config.maxNotificationDisplay; + this.widgetConfig.config.settings.showCounter = config.showCounter; + this.widgetConfig.config.settings.counterValueFont = config.counterValueFont; + this.widgetConfig.config.settings.counterValueColor = config.counterValueColor; + this.widgetConfig.config.settings.counterColor = config.counterColor; + + this.widgetConfig.config.settings.background = config.background; + this.widgetConfig.config.settings.padding = config.padding; + + this.widgetConfig.config.actions = config.actions; + this.setCardButtons(config.cardButtons, this.widgetConfig.config); + this.widgetConfig.config.borderRadius = config.borderRadius; + return this.widgetConfig; + } + + private getCardButtons(config: WidgetConfig): string[] { + const buttons: string[] = []; + if (isUndefined(config.settings?.enableViewAll) || config.settings?.enableViewAll) { + buttons.push('viewAll'); + } + if (isUndefined(config.settings?.enableFilter) || config.settings?.enableFilter) { + buttons.push('filter'); + } + if (isUndefined(config.settings?.enableMarkAsRead) || config.settings?.enableMarkAsRead) { + buttons.push('markAsRead'); + } + if (isUndefined(config.enableFullscreen) || config.enableFullscreen) { + buttons.push('fullscreen'); + } + return buttons; + } + + private setCardButtons(buttons: string[], config: WidgetConfig) { + config.settings.enableViewAll = buttons.includes('viewAll'); + config.settings.enableFilter = buttons.includes('filter'); + config.settings.enableMarkAsRead = buttons.includes('markAsRead'); + + config.enableFullscreen = buttons.includes('fullscreen'); + } + + private _countPreviewFn(): string { + return this.unreadNotificationWidgetConfigForm.get('maxNotificationDisplay').value?.toString() || '6'; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.html index 0d8537a3a6..76b458e4e4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.html @@ -160,7 +160,7 @@
({} as RangeChartWidgetSettings, + const settings: RangeChartWidgetSettings = mergeDeepIgnoreArray({} as RangeChartWidgetSettings, rangeChartDefaultSettings, configData.config.settings as RangeChartWidgetSettings); const iconSize = resolveCssSize(configData.config.iconSize); this.rangeChartWidgetConfigForm = this.fb.group({ diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts index 9a8cb6a7b3..18a2e833f6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts @@ -46,12 +46,12 @@ import { TimeSeriesChartWidgetSettings } from '@home/components/widget/lib/chart/time-series-chart-widget.models'; import { - TimeSeriesChartTooltipTrigger, TimeSeriesChartKeySettings, TimeSeriesChartType, TimeSeriesChartYAxes, TimeSeriesChartYAxisId } from '@home/components/widget/lib/chart/time-series-chart.models'; +import { TimeSeriesChartTooltipTrigger } from '@home/components/widget/lib/chart/time-series-chart-tooltip.models'; @Component({ selector: 'tb-time-series-chart-basic-config', diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts index 72eedc21a0..fdd7b292fa 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts @@ -19,9 +19,11 @@ import { Component, EventEmitter, forwardRef, - Input, OnChanges, + Input, + OnChanges, OnInit, - Output, SimpleChanges, + Output, + SimpleChanges, ViewEncapsulation } from '@angular/core'; import { @@ -45,7 +47,7 @@ import { widgetType } from '@shared/models/widget.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; -import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; import { merge } from 'rxjs'; import { DataKeyConfigDialogComponent, @@ -60,8 +62,10 @@ import { TimeSeriesChartSeriesType, timeSeriesChartSeriesTypeIcons, timeSeriesChartSeriesTypes, - timeSeriesChartSeriesTypeTranslations, TimeSeriesChartYAxisId + timeSeriesChartSeriesTypeTranslations, + TimeSeriesChartYAxisId } from '@home/components/widget/lib/chart/time-series-chart.models'; +import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; export const dataKeyValid = (key: DataKey): boolean => !!key && !!key.type && !!key.name; @@ -177,7 +181,7 @@ export class DataKeyRowComponent implements ControlValueAccessor, OnInit, OnChan return this.widgetConfigComponent.widgetType; } - get callbacks(): DataKeysCallbacks { + get callbacks(): WidgetConfigCallbacks { return this.widgetConfigComponent.widgetConfigCallbacks; } diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/digital-simple-gauge-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/digital-simple-gauge-basic-config.component.html index 9f170dff8c..11e0d6abc2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/digital-simple-gauge-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/digital-simple-gauge-basic-config.component.html @@ -76,6 +76,14 @@
widgets.gauge.max-value-short
+ + warning +
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/digital-simple-gauge-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/digital-simple-gauge-basic-config.component.ts index c3850db5ee..bb0eae4faf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/digital-simple-gauge-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/digital-simple-gauge-basic-config.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { AbstractControl, UntypedFormBuilder, UntypedFormGroup, ValidationErrors, ValidatorFn } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; @@ -109,7 +109,7 @@ export class DigitalSimpleGaugeBasicConfigComponent extends BasicWidgetConfigCom showMinMax: [settings.showMinMax, []], minValue: [settings.minValue, []], - maxValue: [settings.maxValue, []], + maxValue: [settings.maxValue, [this.maxValueValidation()]], minMaxFont: [settings.minMaxFont, []], minMaxColor: [settings.minMaxFont?.color, []], @@ -182,11 +182,29 @@ export class DigitalSimpleGaugeBasicConfigComponent extends BasicWidgetConfigCom return this.widgetConfig; } + private maxValueValidation(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value: string = control.value; + if (value) { + if (value < control.parent?.get('minValue').value) { + return {maxValue: true}; + } + } + return null; + }; + } + protected validatorTriggers(): string[] { - return ['gaugeType', 'showValue', 'showTitle', 'showMinMax']; + return ['gaugeType', 'showValue', 'showTitle', 'showMinMax', 'minValue']; } protected updateValidators(emitEvent: boolean, trigger?: string) { + if (trigger === 'minValue') { + this.simpleGaugeWidgetConfigForm.get('maxValue').updateValueAndValidity({emitEvent: true}); + this.simpleGaugeWidgetConfigForm.get('maxValue').markAsTouched({onlySelf: true}); + return; + } + const isDonut = this.simpleGaugeWidgetConfigForm.get('gaugeType').value === this.digitalGaugeType.donut; if (isDonut) { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html index 57eb184009..52563a1b46 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html @@ -22,11 +22,11 @@
widgets.rpc-state.initial-state
widgets.rpc-state.disabled-state
widgets.rpc-state.initial-state
widgets.rpc-state.turn-on
widgets.rpc-state.turn-off
widgets.rpc-state.disabled-state
widgets.slider.initial-value
widgets.slider.on-value-change
widgets.rpc-state.disabled-state
+ + +
+
scada.symbol.symbol
+ +
+ +
+
+ + {{ 'widget-config.title' | translate }} + +
+ + + + + + + +
+
+
+ + {{ 'widget-config.card-icon' | translate }} + +
+ + + + + + + + +
+
+
+
+
+
widget-config.card-appearance
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
widget-config.show-card-buttons
+ + {{ 'fullscreen.fullscreen' | translate }} + +
+
+
{{ 'widget-config.card-border-radius' | translate }}
+ + + +
+
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.ts new file mode 100644 index 0000000000..42b0dae3d8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.ts @@ -0,0 +1,157 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; +import { TargetDevice, WidgetConfig, } from '@shared/models/widget.models'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { + scadaSymbolWidgetDefaultSettings, + ScadaSymbolWidgetSettings +} from '@home/components/widget/lib/scada/scada-symbol-widget.models'; +import { isUndefined } from '@core/utils'; +import { cssSizeToStrSize, resolveCssSize } from '@shared/models/widget-settings.models'; + +@Component({ + selector: 'tb-scada-symbol-basic-config', + templateUrl: './scada-symbol-basic-config.component.html', + styleUrls: ['../basic-config.scss'] +}) +export class ScadaSymbolBasicConfigComponent extends BasicWidgetConfigComponent { + + get targetDevice(): TargetDevice { + return this.scadaSymbolWidgetConfigForm.get('targetDevice').value; + } + + scadaSymbolWidgetConfigForm: UntypedFormGroup; + + constructor(protected store: Store, + protected widgetConfigComponent: WidgetConfigComponent, + private fb: UntypedFormBuilder) { + super(store, widgetConfigComponent); + } + + protected configForm(): UntypedFormGroup { + return this.scadaSymbolWidgetConfigForm; + } + + protected onConfigSet(configData: WidgetConfigComponentData) { + const settings: ScadaSymbolWidgetSettings = {...scadaSymbolWidgetDefaultSettings, ...(configData.config.settings || {})}; + const iconSize = resolveCssSize(configData.config.iconSize); + this.scadaSymbolWidgetConfigForm = this.fb.group({ + targetDevice: [configData.config.targetDevice, []], + scadaSymbolUrl: [settings.scadaSymbolUrl, []], + scadaSymbolObjectSettings: [settings.scadaSymbolObjectSettings, []], + + showTitle: [configData.config.showTitle, []], + title: [configData.config.title, []], + titleFont: [configData.config.titleFont, []], + titleColor: [configData.config.titleColor, []], + + showIcon: [configData.config.showTitleIcon, []], + iconSize: [iconSize[0], [Validators.min(0)]], + iconSizeUnit: [iconSize[1], []], + icon: [configData.config.titleIcon, []], + iconColor: [configData.config.iconColor, []], + + background: [settings.background, []], + + cardButtons: [this.getCardButtons(configData.config), []], + borderRadius: [configData.config.borderRadius, []], + padding: [settings.padding, []], + + actions: [configData.config.actions || {}, []] + }); + } + + protected prepareOutputConfig(config: any): WidgetConfigComponentData { + this.widgetConfig.config.targetDevice = config.targetDevice; + + this.widgetConfig.config.showTitle = config.showTitle; + this.widgetConfig.config.title = config.title; + this.widgetConfig.config.titleFont = config.titleFont; + this.widgetConfig.config.titleColor = config.titleColor; + + this.widgetConfig.config.showTitleIcon = config.showIcon; + this.widgetConfig.config.iconSize = cssSizeToStrSize(config.iconSize, config.iconSizeUnit); + this.widgetConfig.config.titleIcon = config.icon; + this.widgetConfig.config.iconColor = config.iconColor; + + this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; + this.widgetConfig.config.settings.scadaSymbolUrl = config.scadaSymbolUrl; + this.widgetConfig.config.settings.scadaSymbolObjectSettings = config.scadaSymbolObjectSettings; + this.widgetConfig.config.settings.background = config.background; + + this.setCardButtons(config.cardButtons, this.widgetConfig.config); + this.widgetConfig.config.borderRadius = config.borderRadius; + this.widgetConfig.config.settings.padding = config.padding; + + this.widgetConfig.config.actions = config.actions; + return this.widgetConfig; + } + + protected validatorTriggers(): string[] { + return ['showTitle', 'showIcon']; + } + + protected updateValidators(emitEvent: boolean, trigger?: string) { + const showTitle: boolean = this.scadaSymbolWidgetConfigForm.get('showTitle').value; + const showIcon: boolean = this.scadaSymbolWidgetConfigForm.get('showIcon').value; + + if (showTitle) { + this.scadaSymbolWidgetConfigForm.get('title').enable(); + this.scadaSymbolWidgetConfigForm.get('titleFont').enable(); + this.scadaSymbolWidgetConfigForm.get('titleColor').enable(); + this.scadaSymbolWidgetConfigForm.get('showIcon').enable({emitEvent: false}); + if (showIcon) { + this.scadaSymbolWidgetConfigForm.get('iconSize').enable(); + this.scadaSymbolWidgetConfigForm.get('iconSizeUnit').enable(); + this.scadaSymbolWidgetConfigForm.get('icon').enable(); + this.scadaSymbolWidgetConfigForm.get('iconColor').enable(); + } else { + this.scadaSymbolWidgetConfigForm.get('iconSize').disable(); + this.scadaSymbolWidgetConfigForm.get('iconSizeUnit').disable(); + this.scadaSymbolWidgetConfigForm.get('icon').disable(); + this.scadaSymbolWidgetConfigForm.get('iconColor').disable(); + } + } else { + this.scadaSymbolWidgetConfigForm.get('title').disable(); + this.scadaSymbolWidgetConfigForm.get('titleFont').disable(); + this.scadaSymbolWidgetConfigForm.get('titleColor').disable(); + this.scadaSymbolWidgetConfigForm.get('showIcon').disable({emitEvent: false}); + this.scadaSymbolWidgetConfigForm.get('iconSize').disable(); + this.scadaSymbolWidgetConfigForm.get('iconSizeUnit').disable(); + this.scadaSymbolWidgetConfigForm.get('icon').disable(); + this.scadaSymbolWidgetConfigForm.get('iconColor').disable(); + } + } + + private getCardButtons(config: WidgetConfig): string[] { + const buttons: string[] = []; + if (isUndefined(config.enableFullscreen) || config.enableFullscreen) { + buttons.push('fullscreen'); + } + return buttons; + } + + private setCardButtons(buttons: string[], config: WidgetConfig) { + config.enableFullscreen = buttons.includes('fullscreen'); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/data-key-config-dialog.component.ts index e63dc28f35..3237962134 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/data-key-config-dialog.component.ts @@ -30,12 +30,12 @@ import { import { Router } from '@angular/router'; import { DialogComponent } from '@shared/components/dialog.component'; import { DataKey, DataKeyConfigMode, Widget, widgetType } from '@shared/models/widget.models'; -import { DataKeysCallbacks } from './data-keys.component.models'; import { DataKeyConfigComponent } from '@home/components/widget/config/data-key-config.component'; import { Dashboard } from '@shared/models/dashboard.models'; import { IAliasController } from '@core/api/widget-api.models'; import { ToggleHeaderOption } from '@shared/components/toggle-header.component'; import { TranslateService } from '@ngx-translate/core'; +import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; export interface DataKeyConfigDialogData { dataKey: DataKey; @@ -49,7 +49,7 @@ export interface DataKeyConfigDialogData { deviceId?: string; entityAliasId?: string; showPostProcessing?: boolean; - callbacks?: DataKeysCallbacks; + callbacks?: WidgetConfigCallbacks; hideDataKeyName?: boolean; hideDataKeyLabel?: boolean; hideDataKeyColor?: boolean; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.html index f472325d8d..f4b1272812 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.html @@ -196,7 +196,7 @@ diff --git a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.ts index 9ebe8821cf..4db2aaa8bf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.ts @@ -22,17 +22,18 @@ import { ComparisonResultType, comparisonResultTypeTranslationMap, DataKey, - dataKeyAggregationTypeHintTranslationMap, DataKeyConfigMode, + dataKeyAggregationTypeHintTranslationMap, + DataKeyConfigMode, Widget, widgetType } from '@shared/models/widget.models'; import { ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, - NG_VALIDATORS, - NG_VALUE_ACCESSOR, Validator, Validators } from '@angular/forms'; @@ -40,7 +41,7 @@ import { UtilsService } from '@core/services/utils.service'; import { TranslateService } from '@ngx-translate/core'; import { MatDialog } from '@angular/material/dialog'; import { EntityService } from '@core/http/entity.service'; -import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { Observable, of } from 'rxjs'; import { map, mergeMap, publishReplay, refCount, tap } from 'rxjs/operators'; @@ -55,6 +56,7 @@ import { genNextLabel, isDefinedAndNotNull } from '@core/utils'; import { coerceBoolean } from '@shared/decorators/coercion'; import { WidgetConfigComponentData } from '@home/models/widget-component.models'; import { WidgetComponentService } from '@home/components/widget/widget-component.service'; +import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; @Component({ selector: 'tb-data-key-config', @@ -105,7 +107,7 @@ export class DataKeyConfigComponent extends PageComponent implements OnInit, Con entityAliasId: string; @Input() - callbacks: DataKeysCallbacks; + callbacks: WidgetConfigCallbacks; @Input() dashboard: Dashboard; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.ts index 2b0ed0657e..e01d7e808a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.ts @@ -33,13 +33,15 @@ import { import { AbstractControl, ControlValueAccessor, - FormGroupDirective, NG_VALIDATORS, + FormGroupDirective, + NG_VALIDATORS, NG_VALUE_ACCESSOR, NgForm, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, - ValidationErrors, Validator + ValidationErrors, + Validator } from '@angular/forms'; import { Observable, of } from 'rxjs'; import { filter, map, mergeMap, publishReplay, refCount, share, tap } from 'rxjs/operators'; @@ -52,7 +54,7 @@ import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { DataKey, DatasourceType, JsonSettingsSchema, Widget, widgetType } from '@shared/models/widget.models'; import { IAliasController } from '@core/api/widget-api.models'; -import { DataKeysCallbacks, DataKeySettingsFunction } from './data-keys.component.models'; +import { DataKeySettingsFunction } from './data-keys.component.models'; import { alarmFields } from '@shared/models/alarm.models'; import { UtilsService } from '@core/services/utils.service'; import { ErrorStateMatcher } from '@angular/material/core'; @@ -71,6 +73,7 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { DatasourceComponent } from '@home/components/widget/config/datasource.component'; import { ColorPickerPanelComponent } from '@shared/components/color-picker/color-picker-panel.component'; import { TbPopoverService } from '@shared/components/popover.service'; +import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; @Component({ selector: 'tb-data-keys', @@ -166,7 +169,7 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, OnChange widget: Widget; @Input() - callbacks: DataKeysCallbacks; + callbacks: WidgetConfigCallbacks; @Input() entityAliasId: string; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html index 13a52a295b..a5fb98a1ab 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html @@ -75,7 +75,7 @@ [datakeySettingsFunction]="dataKeySettingsFunction" [dashboard]="dashboard" [widget]="widget" - [callbacks]="dataKeysCallbacks" + [callbacks]="callbacks" [entityAliasId]="datasourceFormGroup.get('entityAliasId').value" [deviceId]="datasourceFormGroup.get('deviceId').value" formControlName="dataKeys"> @@ -91,7 +91,7 @@ [datakeySettingsFunction]="dataKeySettingsFunction" [dashboard]="dashboard" [widget]="widget" - [callbacks]="dataKeysCallbacks" + [callbacks]="callbacks" [entityAliasId]="datasourceFormGroup.get('entityAliasId').value" [deviceId]="datasourceFormGroup.get('deviceId').value" formControlName="latestDataKeys"> diff --git a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts index c134b5580c..6038982d48 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts @@ -42,6 +42,7 @@ import { FilterSelectCallbacks } from '@home/components/filter/filter-select.com import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; import { EntityType } from '@shared/models/entity-type.models'; import { DatasourcesComponent } from '@home/components/widget/config/datasources.component'; +import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; @Component({ selector: 'tb-datasource', @@ -82,6 +83,10 @@ export class DatasourceComponent implements ControlValueAccessor, OnInit, Valida return this.widgetConfigComponent.widgetConfigCallbacks; } + public get callbacks(): WidgetConfigCallbacks { + return this.widgetConfigComponent.widgetConfigCallbacks; + } + public get dataKeysCallbacks(): DataKeysCallbacks { return this.widgetConfigComponent.widgetConfigCallbacks; } diff --git a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html index ad2728484c..7adea38000 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html @@ -24,13 +24,13 @@
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts index c1d09f50b6..58893cb2a7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts @@ -60,6 +60,10 @@ export class TargetDeviceComponent implements ControlValueAccessor, OnInit, Vali return this.widgetConfigComponent.widgetConfigCallbacks; } + public get targetDeviceOptional(): boolean { + return this.widgetConfigComponent.modelValue?.typeParameters?.targetDeviceOptional; + } + targetDeviceType = TargetDeviceType; entityType = EntityType; @@ -103,9 +107,9 @@ export class TargetDeviceComponent implements ControlValueAccessor, OnInit, Vali ngOnInit() { this.targetDeviceFormGroup = this.fb.group({ - type: [null, !this.widgetEditMode ? [Validators.required] : []], - deviceId: [null, !this.widgetEditMode ? [Validators.required] : []], - entityAliasId: [null, !this.widgetEditMode ? [Validators.required] : []] + type: [null, (!this.widgetEditMode && !this.targetDeviceOptional) ? [Validators.required] : []], + deviceId: [null, (!this.widgetEditMode && !this.targetDeviceOptional) ? [Validators.required] : []], + entityAliasId: [null, (!this.widgetEditMode && !this.targetDeviceOptional) ? [Validators.required] : []] }); this.targetDeviceFormGroup.get('type').valueChanges.subscribe(() => { this.updateValidators(); diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts index 8636db1f77..e1ff910ab2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts @@ -50,6 +50,7 @@ import { WidgetService } from '@core/http/widget.service'; import { IAliasController } from '@core/api/widget-api.models'; import { WidgetConfigComponentData } from '@home/models/widget-component.models'; import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; @Component({ selector: 'tb-widget-settings', @@ -79,7 +80,7 @@ export class WidgetSettingsComponent implements ControlValueAccessor, OnInit, On aliasController: IAliasController; @Input() - dataKeyCallbacks: DataKeysCallbacks; + callbacks: WidgetConfigCallbacks; @Input() dashboard: Dashboard; @@ -142,9 +143,10 @@ export class WidgetSettingsComponent implements ControlValueAccessor, OnInit, On this.definedSettingsComponent.aliasController = this.aliasController; } } - if (propName === 'dataKeyCallbacks') { + if (propName === 'callbacks') { if (this.definedSettingsComponent) { - this.definedSettingsComponent.dataKeyCallbacks = this.dataKeyCallbacks; + this.definedSettingsComponent.callbacks = this.callbacks; + this.definedSettingsComponent.dataKeyCallbacks = this.callbacks; } } if (propName === 'widgetConfig') { @@ -234,7 +236,8 @@ export class WidgetSettingsComponent implements ControlValueAccessor, OnInit, On this.definedSettingsComponentRef = this.definedSettingsContainer.createComponent(factory); this.definedSettingsComponent = this.definedSettingsComponentRef.instance; this.definedSettingsComponent.aliasController = this.aliasController; - this.definedSettingsComponent.dataKeyCallbacks = this.dataKeyCallbacks; + this.definedSettingsComponent.callbacks = this.callbacks; + this.definedSettingsComponent.dataKeyCallbacks = this.callbacks; this.definedSettingsComponent.dashboard = this.dashboard; this.definedSettingsComponent.widget = this.widget; this.definedSettingsComponent.widgetConfig = this.widgetConfig; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/action/action-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/action/action-widget.models.ts index 050637468d..b1eb79f2bb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/action/action-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/action/action-widget.models.ts @@ -24,7 +24,7 @@ import { } from '@shared/models/telemetry/telemetry.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { BehaviorSubject, forkJoin, Observable, Observer, of, Subscription, throwError } from 'rxjs'; -import { catchError, delay, map, share, take, tap } from 'rxjs/operators'; +import { catchError, delay, map, share, take } from 'rxjs/operators'; import { UtilsService } from '@core/services/utils.service'; import { AfterViewInit, ChangeDetectorRef, Directive, Input, OnDestroy, OnInit, TemplateRef } from '@angular/core'; import { @@ -45,6 +45,7 @@ import { import { ValueType } from '@shared/models/constants'; import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; import { EntityId } from '@shared/models/id/entity-id'; +import { isDefinedAndNotNull } from '@core/utils'; @Directive() // eslint-disable-next-line @angular-eslint/directive-class-suffix @@ -117,14 +118,16 @@ export abstract class BasicActionWidgetComponent implements OnInit, OnDestroy, A } } }; - const valueGetter = ValueGetter.fromSettings(this.ctx, getValueSettings, valueType, observer); + const simulated = this.ctx.utilsService.widgetEditMode || this.ctx.isPreview; + const valueGetter = ValueGetter.fromSettings(this.ctx, getValueSettings, valueType, observer, simulated); this.valueGetters.push(valueGetter); this.valueActions.push(valueGetter); return valueGetter; } protected createValueSetter(setValueSettings: SetValueSettings): ValueSetter { - const valueSetter = ValueSetter.fromSettings(this.ctx, setValueSettings); + const simulated = this.ctx.utilsService.widgetEditMode || this.ctx.isPreview; + const valueSetter = ValueSetter.fromSettings(this.ctx, setValueSettings, simulated); this.valueActions.push(valueSetter); return valueSetter; } @@ -229,22 +232,22 @@ export abstract class ValueGetter extends ValueAction { static fromSettings(ctx: WidgetContext, settings: GetValueSettings, valueType: ValueType, - valueObserver: Partial>): ValueGetter { + valueObserver: Partial>, + simulated: boolean): ValueGetter { switch (settings.action) { case GetValueAction.DO_NOTHING: - return new DefaultValueGetter(ctx, settings, valueType, valueObserver); + return new DefaultValueGetter(ctx, settings, valueType, valueObserver, simulated); case GetValueAction.EXECUTE_RPC: - return new ExecuteRpcValueGetter(ctx, settings, valueType, valueObserver); + return new ExecuteRpcValueGetter(ctx, settings, valueType, valueObserver, simulated); case GetValueAction.GET_ATTRIBUTE: - return new AttributeValueGetter(ctx, settings, valueType, valueObserver); + return new AttributeValueGetter(ctx, settings, valueType, valueObserver, simulated); case GetValueAction.GET_TIME_SERIES: - return new TimeSeriesValueGetter(ctx, settings, valueType, valueObserver); + return new TimeSeriesValueGetter(ctx, settings, valueType, valueObserver, simulated); case GetValueAction.GET_DASHBOARD_STATE: - return new DashboardStateGetter(ctx, settings, valueType, valueObserver); + return new DashboardStateGetter(ctx, settings, valueType, valueObserver, simulated); } } - private readonly isSimulated: boolean; private readonly dataConverter: DataToValueConverter; private getValueSubscription: Subscription; @@ -252,16 +255,16 @@ export abstract class ValueGetter extends ValueAction { protected constructor(protected ctx: WidgetContext, protected settings: GetValueSettings, protected valueType: ValueType, - protected valueObserver: Partial>) { + protected valueObserver: Partial>, + protected simulated: boolean) { super(ctx, settings); - this.isSimulated = this.ctx.$injector.get(UtilsService).widgetEditMode; if (this.settings.action !== GetValueAction.DO_NOTHING) { this.dataConverter = new DataToValueConverter(settings.dataToValue, valueType); } } getValue(): Observable { - const valueObservable: Observable = (this.isSimulated ? of(null).pipe(delay(500)) : this.doGetValue()).pipe( + const valueObservable: Observable = this.doGetValue().pipe( map((data) => { if (this.dataConverter) { return this.dataConverter.dataToValue(data); @@ -346,29 +349,29 @@ export class ValueToDataConverter { export abstract class ValueSetter extends ValueAction { static fromSettings(ctx: WidgetContext, - settings: SetValueSettings): ValueSetter { + settings: SetValueSettings, + simulated: boolean): ValueSetter { switch (settings.action) { case SetValueAction.EXECUTE_RPC: - return new ExecuteRpcValueSetter(ctx, settings); + return new ExecuteRpcValueSetter(ctx, settings, simulated); case SetValueAction.SET_ATTRIBUTE: - return new AttributeValueSetter(ctx, settings); + return new AttributeValueSetter(ctx, settings, simulated); case SetValueAction.ADD_TIME_SERIES: - return new TimeSeriesValueSetter(ctx, settings); + return new TimeSeriesValueSetter(ctx, settings, simulated); } } - private readonly isSimulated: boolean; private readonly valueToDataConverter: ValueToDataConverter; protected constructor(protected ctx: WidgetContext, - protected settings: SetValueSettings) { + protected settings: SetValueSettings, + protected simulated: boolean) { super(ctx, settings); - this.isSimulated = this.ctx.$injector.get(UtilsService).widgetEditMode; this.valueToDataConverter = new ValueToDataConverter(settings.valueToData); } setValue(value: V): Observable { - if (this.isSimulated) { + if (this.simulated) { return of(null).pipe(delay(500)); } else { return this.doSetValue(this.valueToDataConverter.valueToData(value)).pipe( @@ -389,8 +392,9 @@ export class DefaultValueGetter extends ValueGetter { constructor(protected ctx: WidgetContext, protected settings: GetValueSettings, protected valueType: ValueType, - protected valueObserver: Partial>) { - super(ctx, settings, valueType, valueObserver); + protected valueObserver: Partial>, + protected simulated: boolean) { + super(ctx, settings, valueType, valueObserver, simulated); this.defaultValue = settings.defaultValue; } @@ -406,20 +410,26 @@ export class ExecuteRpcValueGetter extends ValueGetter { constructor(protected ctx: WidgetContext, protected settings: GetValueSettings, protected valueType: ValueType, - protected valueObserver: Partial>) { - super(ctx, settings, valueType, valueObserver); + protected valueObserver: Partial>, + protected simulated: boolean) { + super(ctx, settings, valueType, valueObserver, simulated); this.executeRpcSettings = settings.executeRpc; } protected doGetValue(): Observable { - return this.ctx.controlApi.sendTwoWayCommand(this.executeRpcSettings.method, null, - this.executeRpcSettings.requestTimeout, - this.executeRpcSettings.requestPersistent, - this.executeRpcSettings.persistentPollingInterval).pipe( + if (this.simulated) { + const defaultValue = isDefinedAndNotNull(this.settings.defaultValue) ? this.settings.defaultValue : null; + return of(defaultValue).pipe(delay(500)); + } else { + return this.ctx.controlApi.sendTwoWayCommand(this.executeRpcSettings.method, null, + this.executeRpcSettings.requestTimeout, + this.executeRpcSettings.requestPersistent, + this.executeRpcSettings.persistentPollingInterval).pipe( catchError((err) => { throw handleRpcError(this.ctx, err); }) - ); + ); + } } } @@ -431,24 +441,30 @@ export abstract class TelemetryValueGetter protected constructor(protected ctx: WidgetContext, protected settings: GetValueSettings, protected valueType: ValueType, - protected valueObserver: Partial>) { - super(ctx, settings, valueType, valueObserver); + protected valueObserver: Partial>, + protected simulated: boolean) { + super(ctx, settings, valueType, valueObserver, simulated); const entityInfo = this.ctx.defaultSubscription.getFirstEntityInfo(); this.targetEntityId = entityInfo?.entityId; } protected doGetValue(): Observable { - if (!this.targetEntityId && !this.ctx.defaultSubscription.rpcEnabled) { - return throwError(() => new Error(this.ctx.translate.instant('widgets.value-action.error.target-entity-is-not-set'))); - } - if (this.targetEntityId) { - const err = validateAttributeScope(this.ctx, this.targetEntityId, this.scope()); - if (err) { - return throwError(() => err); - } - return this.subscribeForTelemetryValue(); + if (this.simulated) { + const defaultValue = isDefinedAndNotNull(this.settings.defaultValue) ? this.settings.defaultValue : null; + return of(defaultValue).pipe(delay(100)); } else { - return of(null); + if (!this.targetEntityId && !this.ctx.defaultSubscription.rpcEnabled) { + return throwError(() => new Error(this.ctx.translate.instant('widgets.value-action.error.target-entity-is-not-set'))); + } + if (this.targetEntityId) { + const err = validateAttributeScope(this.ctx, this.targetEntityId, this.scope()); + if (err) { + return throwError(() => err); + } + return this.subscribeForTelemetryValue(); + } else { + return of(null); + } } } @@ -492,8 +508,9 @@ export class AttributeValueGetter extends TelemetryValueGetter, protected valueType: ValueType, - protected valueObserver: Partial>) { - super(ctx, settings, valueType, valueObserver); + protected valueObserver: Partial>, + protected simulated: boolean) { + super(ctx, settings, valueType, valueObserver, simulated); } protected getTelemetryValueSettings(): GetAttributeValueSettings { @@ -511,8 +528,9 @@ export class TimeSeriesValueGetter extends TelemetryValueGetter, protected valueType: ValueType, - protected valueObserver: Partial>) { - super(ctx, settings, valueType, valueObserver); + protected valueObserver: Partial>, + protected simulated: boolean) { + super(ctx, settings, valueType, valueObserver, simulated); } protected getTelemetryValueSettings(): TelemetryValueSettings { @@ -524,12 +542,17 @@ export class DashboardStateGetter extends ValueGetter { constructor(protected ctx: WidgetContext, protected settings: GetValueSettings, protected valueType: ValueType, - protected valueObserver: Partial>) { - super(ctx, settings, valueType, valueObserver); + protected valueObserver: Partial>, + protected simulated: boolean) { + super(ctx, settings, valueType, valueObserver, simulated); } protected doGetValue(): Observable { - return this.ctx.stateController.dashboardCtrl.dashboardCtx.stateId; + if (this.simulated) { + return of('default'); + } else { + return this.ctx.stateController.dashboardCtrl.dashboardCtx.stateId; + } } } @@ -538,8 +561,9 @@ export class ExecuteRpcValueSetter extends ValueSetter { private readonly executeRpcSettings: RpcSettings; constructor(protected ctx: WidgetContext, - protected settings: SetValueSettings) { - super(ctx, settings); + protected settings: SetValueSettings, + protected simulated: boolean) { + super(ctx, settings, simulated); this.executeRpcSettings = settings.executeRpc; } @@ -560,8 +584,9 @@ export abstract class TelemetryValueSetter extends ValueSetter { protected targetEntityId: EntityId; protected constructor(protected ctx: WidgetContext, - protected settings: SetValueSettings) { - super(ctx, settings); + protected settings: SetValueSettings, + protected simulated: boolean) { + super(ctx, settings, simulated); const entityInfo = this.ctx.defaultSubscription.getFirstEntityInfo(); this.targetEntityId = entityInfo?.entityId; } @@ -594,8 +619,9 @@ export class AttributeValueSetter extends TelemetryValueSetter { private readonly setAttributeValueSettings: SetAttributeValueSettings; constructor(protected ctx: WidgetContext, - protected settings: SetValueSettings) { - super(ctx, settings); + protected settings: SetValueSettings, + protected simulated: boolean) { + super(ctx, settings, simulated); this.setAttributeValueSettings = settings.setAttribute; } @@ -616,8 +642,9 @@ export class TimeSeriesValueSetter extends TelemetryValueSetter { private readonly putTimeSeriesValueSettings: TelemetryValueSettings; constructor(protected ctx: WidgetContext, - protected settings: SetValueSettings) { - super(ctx, settings); + protected settings: SetValueSettings, + protected simulated: boolean) { + super(ctx, settings, simulated); this.putTimeSeriesValueSettings = settings.putTimeSeries; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.html new file mode 100644 index 0000000000..00ec196552 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.html @@ -0,0 +1,77 @@ + +
+
+
+
widgets.notification.notification-types
+ + + + {{ notificationTypesTranslateMap.get(type).name | translate }} + close + + + + + + + + + +
+
+ +
+ + + + +
+
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.scss new file mode 100644 index 0000000000..77a1e610d3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.scss @@ -0,0 +1,25 @@ +/** + * 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 { + display: flex; + width: 100%; + max-width: 100%; + + .mdc-button { + max-width: 100%; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.ts new file mode 100644 index 0000000000..ba52310c4b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.ts @@ -0,0 +1,140 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, Inject, InjectionToken, OnInit, ViewChild } from '@angular/core'; +import { NotificationTemplateTypeTranslateMap, NotificationType } from '@shared/models/notification.models'; +import { MatChipInputEvent } from '@angular/material/chips'; +import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; +import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { Observable } from 'rxjs'; +import { FormControl } from '@angular/forms'; +import { debounceTime, map } from 'rxjs/operators'; +import { OverlayRef } from '@angular/cdk/overlay'; + +export const NOTIFICATION_TYPE_FILTER_PANEL_DATA = new InjectionToken('NotificationTypeFilterPanelData'); + +export interface NotificationTypeFilterPanelData { + notificationTypes: Array; + notificationTypesUpdated: (notificationTypes: Array) => void; +} + +@Component({ + selector: 'tb-notification-type-filter-panel', + templateUrl: './notification-type-filter-panel.component.html', + styleUrls: ['notification-type-filter-panel.component.scss'] +}) +export class NotificationTypeFilterPanelComponent implements OnInit{ + + @ViewChild('searchInput') searchInputField: ElementRef; + + searchText = ''; + searchControlName = new FormControl(''); + + filteredNotificationTypesList: Observable>; + selectedNotificationTypes: Array = []; + notificationTypesTranslateMap = NotificationTemplateTypeTranslateMap; + + separatorKeysCodes: number[] = [ENTER, COMMA, SEMICOLON]; + + private notificationType = NotificationType; + private notificationTypes = Object.keys(NotificationType) as Array; + + private dirty = false; + + @ViewChild('notificationTypeInput') notificationTypeInput: ElementRef; + + constructor(@Inject(NOTIFICATION_TYPE_FILTER_PANEL_DATA) public data: NotificationTypeFilterPanelData, + private overlayRef: OverlayRef) { + this.selectedNotificationTypes = this.data.notificationTypes; + this.dirty = true; + } + + ngOnInit() { + this.filteredNotificationTypesList = this.searchControlName.valueChanges.pipe( + debounceTime(150), + map(value => { + this.searchText = value; + return this.notificationTypes.filter(type => !this.selectedNotificationTypes.includes(type)) + .filter(type => value ? type.toUpperCase().startsWith(value.toUpperCase()) : true); + }) + ); + } + + public update() { + this.data.notificationTypesUpdated(this.selectedNotificationTypes); + if (this.overlayRef) { + this.overlayRef.dispose(); + } + } + + cancel() { + if (this.overlayRef) { + this.overlayRef.dispose(); + } + } + + public reset() { + this.selectedNotificationTypes.length = 0; + this.searchControlName.updateValueAndValidity({emitEvent: true}); + } + + remove(type: NotificationType) { + const index = this.selectedNotificationTypes.indexOf(type); + if (index >= 0) { + this.selectedNotificationTypes.splice(index, 1); + this.searchControlName.updateValueAndValidity({emitEvent: true}); + } + } + + onFocus() { + if (this.dirty) { + this.searchControlName.updateValueAndValidity({emitEvent: true}); + this.dirty = false; + } + } + + private add(type: NotificationType): void { + this.selectedNotificationTypes.push(type); + } + + chipAdd(event: MatChipInputEvent): void { + const value = (event.value || '').trim(); + if (value && this.notificationType[value]) { + this.add(this.notificationType[value]); + this.clear(''); + } + } + + selected(event: MatAutocompleteSelectedEvent): void { + if (this.notificationType[event.option.value]) { + this.add(this.notificationType[event.option.value]); + } + this.clear(''); + } + + clear(value: string = '') { + this.notificationTypeInput.nativeElement.value = value; + this.searchControlName.patchValue(value, {emitEvent: true}); + setTimeout(() => { + this.notificationTypeInput.nativeElement.blur(); + this.notificationTypeInput.nativeElement.focus(); + }, 0); + } + + displayTypeFn(type?: string): string | undefined { + return type ? type : undefined; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.html new file mode 100644 index 0000000000..7da0738e59 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.html @@ -0,0 +1,55 @@ + +
+
+ +
+ {{ count$ | async }} +
+
+ + +
+ +
+
+
+ + + +
+
+
+
+ +
+ + + +
+ notification.no-notifications-yet +
+ +
+ +
notification.loading-notifications
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.scss new file mode 100644 index 0000000000..456660c332 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.scss @@ -0,0 +1,65 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import "../../../../../../../scss/constants"; + +.tb-no-notification-svg-color { + color: $tb-primary-color; +} + +.tb-unread-notification-panel { + width: 100%; + height: 100%; + position: relative; + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px 24px 24px 24px; + > div:not(.tb-unread-notification-overlay) { + z-index: 1; + } + div.tb-widget-title { + padding: 0; + } + .tb-unread-notification-overlay { + position: absolute; + top: 12px; + left: 12px; + bottom: 12px; + right: 12px; + } + .tb-unread-notification-content { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + .tb-no-notification-text { + text-align: center; + margin-bottom: 12px; + color: rgba(0, 0, 0, 0.38); + } + } + .notification-counter { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 22px; + background-color: green; + border-radius: 5px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.ts new file mode 100644 index 0000000000..dea274d118 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.ts @@ -0,0 +1,292 @@ +/// +/// 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, + Injector, + Input, + NgZone, + OnDestroy, + OnInit, + StaticProvider, + TemplateRef, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { WidgetAction, WidgetContext } from '@home/models/widget-component.models'; +import { isDefined } from '@core/utils'; +import { backgroundStyle, ComponentStyle, overlayStyle, textStyle } from '@shared/models/widget-settings.models'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { BehaviorSubject, fromEvent, Observable, ReplaySubject, Subscription } from 'rxjs'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; +import { + unreadNotificationDefaultSettings, + UnreadNotificationWidgetSettings +} from '@home/components/widget/lib/cards/unread-notification-widget.models'; +import { Notification, NotificationRequest, NotificationType } from '@shared/models/notification.models'; +import { NotificationSubscriber } from '@shared/models/telemetry/telemetry.models'; +import { NotificationWebsocketService } from '@core/ws/notification-websocket.service'; +import { distinctUntilChanged, map, share, skip, take, tap } from 'rxjs/operators'; +import { Router } from '@angular/router'; +import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { DEFAULT_OVERLAY_POSITIONS } from '@shared/models/overlay.models'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { + NOTIFICATION_TYPE_FILTER_PANEL_DATA, + NotificationTypeFilterPanelComponent +} from '@home/components/widget/lib/cards/notification-type-filter-panel.component'; +import { selectUserDetails } from '@core/auth/auth.selectors'; +import { select, Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; + +@Component({ + selector: 'tb-unread-notification-widget', + templateUrl: './unread-notification-widget.component.html', + styleUrls: ['unread-notification-widget.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class UnreadNotificationWidgetComponent implements OnInit, OnDestroy { + + settings: UnreadNotificationWidgetSettings; + + @Input() + ctx: WidgetContext; + + @Input() + widgetTitlePanel: TemplateRef; + + showCounter = true; + counterValueStyle: ComponentStyle; + counterBackground: string; + + notifications: Notification[]; + loadNotification = false; + + backgroundStyle$: Observable; + overlayStyle: ComponentStyle = {}; + padding: string; + + private counterValue: BehaviorSubject = new BehaviorSubject(0); + + count$ = this.counterValue.asObservable().pipe( + distinctUntilChanged(), + map((value) => value >= 100 ? '99+' : value), + tap(() => Promise.resolve().then(() => this.cd.markForCheck())), + share({ + connector: () => new ReplaySubject(1) + }) + ); + + + private notificationTypes: Array = []; + + private notificationSubscriber: NotificationSubscriber; + private notificationCountSubscriber: Subscription; + private notification: Subscription; + + private contentResize$: ResizeObserver; + + private defaultDashboardFullscreen = false; + + private viewAllAction: WidgetAction = { + name: 'widgets.notification.button-view-all', + show: !this.defaultDashboardFullscreen, + icon: 'open_in_new', + onAction: ($event) => { + this.viewAll($event); + } + }; + + private filterAction: WidgetAction = { + name: 'widgets.notification.button-filter', + show: true, + icon: 'filter_list', + onAction: ($event) => { + this.editNotificationTypeFilter($event); + } + }; + + private markAsReadAction: WidgetAction = { + name: 'widgets.notification.button-mark-read', + show: true, + icon: 'done_all', + onAction: ($event) => { + this.markAsAllRead($event); + } + }; + + constructor(private store: Store, + private imagePipe: ImagePipe, + private notificationWsService: NotificationWebsocketService, + private sanitizer: DomSanitizer, + private router: Router, + private zone: NgZone, + private overlay: Overlay, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef) { + } + + ngOnInit(): void { + this.ctx.$scope.unreadNotificationWidget = this; + this.settings = {...unreadNotificationDefaultSettings, ...this.ctx.settings}; + + this.showCounter = this.settings.showCounter; + this.counterValueStyle = textStyle(this.settings.counterValueFont); + this.counterValueStyle.color = this.settings.counterValueColor; + this.counterBackground = this.settings.counterColor; + + this.ctx.widgetActions = [this.viewAllAction, this.filterAction, this.markAsReadAction]; + + this.viewAllAction.show = isDefined(this.settings.enableViewAll) ? this.settings.enableViewAll : true; + if (this.viewAllAction.show) { + this.store.pipe(select(selectUserDetails), take(1)).subscribe( + user => this.viewAllAction.show = !user.additionalInfo?.defaultDashboardFullscreen + ); + } + this.filterAction.show = isDefined(this.settings.enableFilter) ? this.settings.enableFilter : true; + this.markAsReadAction.show = isDefined(this.settings.enableMarkAsRead) ? this.settings.enableMarkAsRead : true; + + this.initSubscription(); + + this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); + this.overlayStyle = overlayStyle(this.settings.background.overlay); + this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; + } + + + ngOnDestroy() { + if (this.contentResize$) { + this.contentResize$.disconnect(); + } + this.unsubscribeSubscription(); + } + + private initSubscription() { + this.notificationSubscriber = NotificationSubscriber.createNotificationsSubscription( + this.notificationWsService, this.zone, this.settings.maxNotificationDisplay, this.notificationTypes); + this.notification = this.notificationSubscriber.notifications$.subscribe(value => { + if (Array.isArray(value)) { + this.loadNotification = true; + this.notifications = value; + this.cd.markForCheck(); + } + }); + this.notificationCountSubscriber = this.notificationSubscriber.notificationCount$.pipe( + skip(1), + ).subscribe(value => this.counterValue.next(value)); + this.notificationSubscriber.subscribe(); + } + + private unsubscribeSubscription() { + this.notificationSubscriber.unsubscribe(); + this.notificationCountSubscriber.unsubscribe(); + this.notification.unsubscribe(); + } + + public onInit() { + const borderRadius = this.ctx.$widgetElement.css('borderRadius'); + this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; + this.cd.detectChanges(); + } + + markAsRead(id: string) { + if (!this.ctx.isEdit && !this.ctx.isPreview) { + const cmd = NotificationSubscriber.createMarkAsReadCommand(this.notificationWsService, [id]); + cmd.subscribe(); + } + } + + markAsAllRead($event: Event) { + if (!this.ctx.isEdit && !this.ctx.isPreview) { + if ($event) { + $event.stopPropagation(); + } + const cmd = NotificationSubscriber.createMarkAllAsReadCommand(this.notificationWsService); + cmd.subscribe(); + } + } + + viewAll($event: Event) { + if (!this.ctx.isEdit && !this.ctx.isPreview) { + if ($event) { + $event.stopPropagation(); + } + this.router.navigateByUrl(this.router.parseUrl('/notification/inbox')).then(() => {}); + } + } + + trackById(index: number, item: NotificationRequest): string { + return item.id.id; + } + + private editNotificationTypeFilter($event: Event) { + if ($event) { + $event.stopPropagation(); + } + const target = $event.target || $event.srcElement || $event.currentTarget; + const config = new OverlayConfig({ + panelClass: 'tb-panel-container', + backdropClass: 'cdk-overlay-transparent-backdrop', + hasBackdrop: true, + height: 'fit-content', + maxHeight: '75vh', + width: '100%', + maxWidth: 700 + }); + config.positionStrategy = this.overlay.position() + .flexibleConnectedTo(target as HTMLElement) + .withPositions(DEFAULT_OVERLAY_POSITIONS); + + const overlayRef = this.overlay.create(config); + overlayRef.backdropClick().subscribe(() => { + overlayRef.dispose(); + }); + + const providers: StaticProvider[] = [ + { + provide: NOTIFICATION_TYPE_FILTER_PANEL_DATA, + useValue: { + notificationTypes: this.notificationTypes, + notificationTypesUpdated: (notificationTypes: Array) => { + this.notificationTypes = notificationTypes; + this.unsubscribeSubscription(); + this.initSubscription(); + } + } + }, + { + provide: OverlayRef, + useValue: overlayRef + } + ]; + + const injector = Injector.create({parent: this.viewContainerRef.injector, providers}); + const componentRef = overlayRef.attach(new ComponentPortal(NotificationTypeFilterPanelComponent, + this.viewContainerRef, injector)); + + const resizeWindows$ = fromEvent(window, 'resize').subscribe(() => { + overlayRef.updatePosition(); + }); + componentRef.onDestroy(() => { + resizeWindows$.unsubscribe(); + }); + + this.ctx.detectChanges(); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.models.ts new file mode 100644 index 0000000000..507ed9d6a3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.models.ts @@ -0,0 +1,59 @@ +/// +/// 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 { BackgroundSettings, BackgroundType, Font } from '@shared/models/widget-settings.models'; + +export interface UnreadNotificationWidgetSettings { + maxNotificationDisplay: number; + showCounter: boolean; + counterValueFont: Font; + counterValueColor: string; + counterColor: string; + + enableViewAll: boolean; + enableFilter: boolean; + enableMarkAsRead: boolean; + background: BackgroundSettings; + padding: string; +} + +export const unreadNotificationDefaultSettings: UnreadNotificationWidgetSettings = { + maxNotificationDisplay: 6, + showCounter: true, + counterValueFont: { + family: 'Roboto', + size: 12, + sizeUnit: 'px', + style: 'normal', + weight: '600', + lineHeight: '' + }, + counterValueColor: '#fff', + counterColor: '#305680', + enableViewAll: true, + enableFilter: true, + enableMarkAsRead: true, + background: { + type: BackgroundType.color, + color: '#fff', + overlay: { + enabled: false, + color: 'rgba(255,255,255,0.72)', + blur: 3 + } + }, + padding: '12px' +}; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.models.ts index 185922a3eb..244d8e3ef1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.models.ts @@ -35,7 +35,6 @@ import { TimeSeriesChartSeriesType, TimeSeriesChartSettings, TimeSeriesChartThreshold, - TimeSeriesChartTooltipWidgetSettings, TimeSeriesChartXAxisSettings, TimeSeriesChartYAxisSettings } from '@home/components/widget/lib/chart/time-series-chart.models'; @@ -48,6 +47,9 @@ import { ChartFillSettings, ChartFillType } from '@home/components/widget/lib/chart/chart.models'; +import { + TimeSeriesChartTooltipWidgetSettings +} from '@home/components/widget/lib/chart/time-series-chart-tooltip.models'; export interface BarChartWithLabelsWidgetSettings extends TimeSeriesChartTooltipWidgetSettings { dataZoom: boolean; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.models.ts index 5b3fb52689..147be339df 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.models.ts @@ -38,7 +38,6 @@ import { TimeSeriesChartSettings, TimeSeriesChartThreshold, timeSeriesChartThresholdDefaultSettings, - TimeSeriesChartTooltipWidgetSettings, TimeSeriesChartVisualMapPiece, TimeSeriesChartXAxisSettings, TimeSeriesChartYAxisSettings @@ -54,6 +53,9 @@ import { ChartLineType, ChartShape } from '@home/components/widget/lib/chart/chart.models'; +import { + TimeSeriesChartTooltipWidgetSettings +} from '@home/components/widget/lib/chart/time-series-chart-tooltip.models'; export interface RangeItem { index: number; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-state.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-state.models.ts index 35159ba944..adde10d78f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-state.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-state.models.ts @@ -18,13 +18,15 @@ import { TimeSeriesChartStateSettings, TimeSeriesChartStateSourceType, TimeSeriesChartTicksFormatter, - TimeSeriesChartTicksGenerator, - TimeSeriesChartTooltipValueFormatFunction + TimeSeriesChartTicksGenerator } from '@home/components/widget/lib/chart/time-series-chart.models'; import { UtilsService } from '@core/services/utils.service'; import { FormattedData } from '@shared/models/widget.models'; import { formatValue, isDefinedAndNotNull, isNumber, isNumeric } from '@core/utils'; import { LabelFormatterCallback } from 'echarts'; +import { + TimeSeriesChartTooltipValueFormatFunction +} from '@home/components/widget/lib/chart/time-series-chart-tooltip.models'; export class TimeSeriesChartStateValueConverter { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-tooltip.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-tooltip.models.ts new file mode 100644 index 0000000000..99fc0ffc69 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-tooltip.models.ts @@ -0,0 +1,263 @@ +/// +/// 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 { isFunction } from '@core/utils'; +import { FormattedData } from '@shared/models/widget.models'; +import { DateFormatProcessor, DateFormatSettings, Font } from '@shared/models/widget-settings.models'; +import { + TimeSeriesChartDataItem, +} from '@home/components/widget/lib/chart/time-series-chart.models'; +import { Renderer2, SecurityContext } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { CallbackDataParams } from 'echarts/types/dist/shared'; +import { Interval } from '@shared/models/time/time.models'; + +export type TimeSeriesChartTooltipValueFormatFunction = + (value: any, latestData: FormattedData, units?: string, decimals?: number) => string; + +export interface TimeSeriesChartTooltipWidgetSettings { + showTooltip: boolean; + tooltipTrigger?: TimeSeriesChartTooltipTrigger; + tooltipShowFocusedSeries?: boolean; + tooltipLabelFont: Font; + tooltipLabelColor: string; + tooltipValueFont: Font; + tooltipValueColor: string; + tooltipValueFormatter?: string | TimeSeriesChartTooltipValueFormatFunction; + tooltipShowDate: boolean; + tooltipDateInterval?: boolean; + tooltipDateFormat: DateFormatSettings; + tooltipDateFont: Font; + tooltipDateColor: string; + tooltipBackgroundColor: string; + tooltipBackgroundBlur: number; +} + +export enum TimeSeriesChartTooltipTrigger { + point = 'point', + axis = 'axis' +} + +export const tooltipTriggerTranslationMap = new Map( + [ + [TimeSeriesChartTooltipTrigger.point, 'tooltip.trigger-point'], + [TimeSeriesChartTooltipTrigger.axis, 'tooltip.trigger-axis'] + ] +); + +interface TooltipItem { + param: CallbackDataParams; + dataItem: TimeSeriesChartDataItem; +} + +interface TooltipParams { + items: TooltipItem[]; + comparisonItems: TooltipItem[]; +} + +export const createTooltipValueFormatFunction = + (tooltipValueFormatter: string | TimeSeriesChartTooltipValueFormatFunction): TimeSeriesChartTooltipValueFormatFunction => { + let tooltipValueFormatFunction: TimeSeriesChartTooltipValueFormatFunction; + if (isFunction(tooltipValueFormatter)) { + tooltipValueFormatFunction = tooltipValueFormatter as TimeSeriesChartTooltipValueFormatFunction; + } else if (typeof tooltipValueFormatter === 'string' && tooltipValueFormatter.length) { + try { + tooltipValueFormatFunction = + new Function('value', 'latestData', tooltipValueFormatter) as TimeSeriesChartTooltipValueFormatFunction; + } catch (e) { + } + } + return tooltipValueFormatFunction; + }; + +export class TimeSeriesChartTooltip { + + constructor(private renderer: Renderer2, + private sanitizer: DomSanitizer, + private settings: TimeSeriesChartTooltipWidgetSettings, + private tooltipDateFormat: DateFormatProcessor, + private valueFormatFunction: TimeSeriesChartTooltipValueFormatFunction) { + + } + + formatted(params: CallbackDataParams[] | CallbackDataParams, focusedSeriesIndex: number, + series?: TimeSeriesChartDataItem[], interval?: Interval): HTMLElement { + if (!this.settings.showTooltip) { + return undefined; + } + + const tooltipParams = TimeSeriesChartTooltip.mapTooltipParams(params, series, focusedSeriesIndex); + if (!tooltipParams.items.length && !tooltipParams.comparisonItems.length) { + return null; + } + + const tooltipElement: HTMLElement = this.renderer.createElement('div'); + this.renderer.setStyle(tooltipElement, 'display', 'flex'); + this.renderer.setStyle(tooltipElement, 'flex-direction', 'column'); + this.renderer.setStyle(tooltipElement, 'align-items', 'flex-start'); + this.renderer.setStyle(tooltipElement, 'gap', '16px'); + + this.buildItemsTooltip(tooltipElement, tooltipParams.items, interval); + this.buildItemsTooltip(tooltipElement, tooltipParams.comparisonItems, interval); + + return tooltipElement; + } + + private buildItemsTooltip(tooltipElement: HTMLElement, + items: TooltipItem[], interval?: Interval) { + if (items.length) { + const tooltipItemsElement: HTMLElement = this.renderer.createElement('div'); + this.renderer.setStyle(tooltipItemsElement, 'display', 'flex'); + this.renderer.setStyle(tooltipItemsElement, 'flex-direction', 'column'); + this.renderer.setStyle(tooltipItemsElement, 'align-items', 'flex-start'); + this.renderer.setStyle(tooltipItemsElement, 'gap', '4px'); + this.renderer.appendChild(tooltipElement, tooltipItemsElement); + if (this.settings.tooltipShowDate) { + this.renderer.appendChild(tooltipItemsElement, this.constructTooltipDateElement(items[0].param, interval)); + } + for (const item of items) { + this.renderer.appendChild(tooltipItemsElement, this.constructTooltipSeriesElement(item)); + } + } + } + + private constructTooltipDateElement(param: CallbackDataParams, interval?: Interval): HTMLElement { + const dateElement: HTMLElement = this.renderer.createElement('div'); + let dateText: string; + const startTs = param.value[2]; + const endTs = param.value[3]; + if (this.settings.tooltipDateInterval && startTs && endTs && (endTs - 1) > startTs) { + const startDateText = this.tooltipDateFormat.update(startTs, interval); + const endDateText = this.tooltipDateFormat.update(endTs - 1, interval); + if (startDateText === endDateText) { + dateText = startDateText; + } else { + dateText = startDateText + ' - ' + endDateText; + } + } else { + const ts = param.value[0]; + dateText = this.tooltipDateFormat.update(ts, interval); + } + this.renderer.appendChild(dateElement, this.renderer.createText(dateText)); + this.renderer.setStyle(dateElement, 'font-family', this.settings.tooltipDateFont.family); + this.renderer.setStyle(dateElement, 'font-size', this.settings.tooltipDateFont.size + this.settings.tooltipDateFont.sizeUnit); + this.renderer.setStyle(dateElement, 'font-style', this.settings.tooltipDateFont.style); + this.renderer.setStyle(dateElement, 'font-weight', this.settings.tooltipDateFont.weight); + this.renderer.setStyle(dateElement, 'line-height', this.settings.tooltipDateFont.lineHeight); + this.renderer.setStyle(dateElement, 'color', this.settings.tooltipDateColor); + return dateElement; + } + + private constructTooltipSeriesElement(item: TooltipItem): HTMLElement { + const labelValueElement: HTMLElement = this.renderer.createElement('div'); + this.renderer.setStyle(labelValueElement, 'display', 'flex'); + this.renderer.setStyle(labelValueElement, 'flex-direction', 'row'); + this.renderer.setStyle(labelValueElement, 'align-items', 'center'); + this.renderer.setStyle(labelValueElement, 'align-self', 'stretch'); + this.renderer.setStyle(labelValueElement, 'gap', '12px'); + const labelElement: HTMLElement = this.renderer.createElement('div'); + this.renderer.setStyle(labelElement, 'display', 'flex'); + this.renderer.setStyle(labelElement, 'align-items', 'center'); + this.renderer.setStyle(labelElement, 'gap', '8px'); + this.renderer.appendChild(labelValueElement, labelElement); + const circleElement: HTMLElement = this.renderer.createElement('div'); + this.renderer.setStyle(circleElement, 'width', '8px'); + this.renderer.setStyle(circleElement, 'height', '8px'); + this.renderer.setStyle(circleElement, 'border-radius', '50%'); + this.renderer.setStyle(circleElement, 'background', item.param.color); + this.renderer.appendChild(labelElement, circleElement); + const labelTextElement: HTMLElement = this.renderer.createElement('div'); + this.renderer.setProperty(labelTextElement, 'innerHTML', this.sanitizer.sanitize(SecurityContext.HTML, item.param.seriesName)); + this.renderer.setStyle(labelTextElement, 'font-family', this.settings.tooltipLabelFont.family); + this.renderer.setStyle(labelTextElement, 'font-size', this.settings.tooltipLabelFont.size + this.settings.tooltipLabelFont.sizeUnit); + this.renderer.setStyle(labelTextElement, 'font-style', this.settings.tooltipLabelFont.style); + this.renderer.setStyle(labelTextElement, 'font-weight', this.settings.tooltipLabelFont.weight); + this.renderer.setStyle(labelTextElement, 'line-height', this.settings.tooltipLabelFont.lineHeight); + this.renderer.setStyle(labelTextElement, 'color', this.settings.tooltipLabelColor); + this.renderer.appendChild(labelElement, labelTextElement); + const valueElement: HTMLElement = this.renderer.createElement('div'); + let formatFunction = this.valueFormatFunction; + let latestData: FormattedData; + let units = ''; + let decimals = 0; + if (item.dataItem) { + if (item.dataItem.tooltipValueFormatFunction) { + formatFunction = item.dataItem.tooltipValueFormatFunction; + } + latestData = item.dataItem.latestData; + units = item.dataItem.units; + decimals = item.dataItem.decimals; + } + if (!latestData) { + latestData = {} as FormattedData; + } + const value = formatFunction(item.param.value[1], latestData, units, decimals); + this.renderer.setProperty(valueElement, 'innerHTML', this.sanitizer.sanitize(SecurityContext.HTML, value)); + this.renderer.setStyle(valueElement, 'flex', '1'); + this.renderer.setStyle(valueElement, 'text-align', 'end'); + this.renderer.setStyle(valueElement, 'font-family', this.settings.tooltipValueFont.family); + this.renderer.setStyle(valueElement, 'font-size', this.settings.tooltipValueFont.size + this.settings.tooltipValueFont.sizeUnit); + this.renderer.setStyle(valueElement, 'font-style', this.settings.tooltipValueFont.style); + this.renderer.setStyle(valueElement, 'font-weight', this.settings.tooltipValueFont.weight); + this.renderer.setStyle(valueElement, 'line-height', this.settings.tooltipValueFont.lineHeight); + this.renderer.setStyle(valueElement, 'color', this.settings.tooltipValueColor); + this.renderer.appendChild(labelValueElement, valueElement); + return labelValueElement; + } + + private static mapTooltipParams(params: CallbackDataParams[] | CallbackDataParams, + series?: TimeSeriesChartDataItem[], + focusedSeriesIndex?: number): TooltipParams { + const result: TooltipParams = { + items: [], + comparisonItems: [] + }; + if (!params || Array.isArray(params) && !params[0]) { + return result; + } + const firstParam = Array.isArray(params) ? params[0] : params; + if (!firstParam.value) { + return result; + } + let seriesParams: CallbackDataParams = null; + if (Array.isArray(params) && focusedSeriesIndex > -1) { + seriesParams = params.find(param => param.seriesIndex === focusedSeriesIndex); + } else if (!Array.isArray(params)) { + seriesParams = params; + } + if (seriesParams) { + TimeSeriesChartTooltip.appendTooltipItem(result, seriesParams, series); + } else if (Array.isArray(params)) { + for (seriesParams of params) { + TimeSeriesChartTooltip.appendTooltipItem(result, seriesParams, series); + } + } + return result; + } + + private static appendTooltipItem(tooltipParams: TooltipParams, seriesParams: CallbackDataParams, series?: TimeSeriesChartDataItem[]) { + const dataItem = series?.find(s => s.id === seriesParams.seriesId); + const tooltipItem: TooltipItem = { + param: seriesParams, + dataItem + }; + if (dataItem?.comparisonItem) { + tooltipParams.comparisonItems.push(tooltipItem); + } else { + tooltipParams.items.push(tooltipItem); + } + }; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html index 092b0c2783..aabab512e1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html @@ -17,7 +17,9 @@ -->
- + + +
@@ -74,20 +76,20 @@ - - {{ legendData.data[legendKey.dataIndex].min }} + - - {{ legendData.data[legendKey.dataIndex].max }} + - - {{ legendData.data[legendKey.dataIndex].avg }} + - - {{ legendData.data[legendKey.dataIndex].total }} + - - {{ legendData.data[legendKey.dataIndex].latest }} + @@ -106,15 +108,15 @@ (mouseleave)="onLegendKeyLeave(legendKey)" (click)="toggleLegendKey(legendKey)">
-
{{ legendKey.dataKey.label }}
+
{{ label | translate }} - - {{ legendData.data[legendKey.dataIndex][type] }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts index 0a242eba36..ed023c1a0d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts @@ -43,6 +43,7 @@ import { TimeSeriesChartWidgetSettings } from '@home/components/widget/lib/chart/time-series-chart-widget.models'; import { mergeDeep } from '@core/utils'; +import { WidgetComponent } from '@home/components/widget/widget.component'; @Component({ selector: 'tb-time-series-chart-widget', @@ -84,7 +85,8 @@ export class TimeSeriesChartWidgetComponent implements OnInit, OnDestroy, AfterV private timeSeriesChart: TbTimeSeriesChart; - constructor(private imagePipe: ImagePipe, + constructor(public widgetComponent: WidgetComponent, + private imagePipe: ImagePipe, private sanitizer: DomSanitizer, private renderer: Renderer2, private cd: ChangeDetectorRef) { @@ -169,6 +171,6 @@ export class TimeSeriesChartWidgetComponent implements OnInit, OnDestroy, AfterV } public toggleLegendKey(legendKey: LegendKey) { - this.timeSeriesChart.toggleKey(legendKey.dataKey); + this.timeSeriesChart.toggleKey(legendKey.dataKey, legendKey.dataIndex); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts index 76a3f84899..d90f7c9b10 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts @@ -23,15 +23,12 @@ import { autoDateFormat, AutoDateFormatSettings, ComponentStyle, - DateFormatProcessor, - DateFormatSettings, Font, tsToFormatTimeUnit, ValueSourceConfig, ValueSourceType } from '@shared/models/widget-settings.models'; import { - CallbackDataParams, TimeAxisBandWidthCalculator, VisualMapComponentOption, XAXisOption, @@ -75,12 +72,10 @@ import { BuiltinTextPosition } from 'zrender/src/core/types'; import { CartesianAxisOption } from 'echarts/types/src/coord/cartesian/AxisModel'; import { calculateAggIntervalWithWidgetTimeWindow, - Interval, IntervalMath, WidgetTimewindow } from '@shared/models/time/time.models'; import { UtilsService } from '@core/services/utils.service'; -import { Renderer2 } from '@angular/core'; import { chartAnimationDefaultSettings, ChartAnimationSettings, @@ -98,6 +93,11 @@ import { prepareChartThemeColor } from '@home/components/widget/lib/chart/chart.models'; import { BarSeriesLabelOption } from 'echarts/types/src/chart/bar/BarSeries'; +import { + TimeSeriesChartTooltipTrigger, + TimeSeriesChartTooltipValueFormatFunction, + TimeSeriesChartTooltipWidgetSettings +} from '@home/components/widget/lib/chart/time-series-chart-tooltip.models'; type TimeSeriesChartDataEntry = [number, any, number, number]; @@ -127,9 +127,6 @@ const toTimeSeriesChartDataEntry = (entry: DataEntry, valueConverter?: (value: a return item; }; -export type TimeSeriesChartTooltipValueFormatFunction = - (value: any, latestData: FormattedData, units?: string, decimals?: number) => string; - export interface TimeSeriesChartDataItem { id: string; datasource: Datasource; @@ -216,245 +213,6 @@ export const adjustTimeAxisExtentToData = (timeAxisOption: TimeAxisBaseOption, timeAxisOption.max = (typeof max !== 'undefined' && Math.abs(max - defaultMax) < 1000) ? max : defaultMax; }; -export enum TimeSeriesChartTooltipTrigger { - point = 'point', - axis = 'axis' -} - -export const tooltipTriggerTranslationMap = new Map( - [ - [ TimeSeriesChartTooltipTrigger.point, 'tooltip.trigger-point' ], - [ TimeSeriesChartTooltipTrigger.axis, 'tooltip.trigger-axis' ] - ] -); - -export interface TimeSeriesChartTooltipWidgetSettings { - showTooltip: boolean; - tooltipTrigger?: TimeSeriesChartTooltipTrigger; - tooltipShowFocusedSeries?: boolean; - tooltipLabelFont: Font; - tooltipLabelColor: string; - tooltipValueFont: Font; - tooltipValueColor: string; - tooltipValueFormatter?: string | TimeSeriesChartTooltipValueFormatFunction; - tooltipShowDate: boolean; - tooltipDateInterval?: boolean; - tooltipDateFormat: DateFormatSettings; - tooltipDateFont: Font; - tooltipDateColor: string; - tooltipBackgroundColor: string; - tooltipBackgroundBlur: number; -} - -export const createTooltipValueFormatFunction = - (tooltipValueFormatter: string | TimeSeriesChartTooltipValueFormatFunction): TimeSeriesChartTooltipValueFormatFunction => { - let tooltipValueFormatFunction: TimeSeriesChartTooltipValueFormatFunction; - if (isFunction(tooltipValueFormatter)) { - tooltipValueFormatFunction = tooltipValueFormatter as TimeSeriesChartTooltipValueFormatFunction; - } else if (typeof tooltipValueFormatter === 'string' && tooltipValueFormatter.length) { - try { - tooltipValueFormatFunction = - new Function('value', 'latestData', tooltipValueFormatter) as TimeSeriesChartTooltipValueFormatFunction; - } catch (e) {} - } - return tooltipValueFormatFunction; - }; - -export const timeSeriesChartTooltipFormatter = (renderer: Renderer2, - tooltipDateFormat: DateFormatProcessor, - settings: TimeSeriesChartTooltipWidgetSettings, - params: CallbackDataParams[] | CallbackDataParams, - valueFormatFunction: TimeSeriesChartTooltipValueFormatFunction, - focusedSeriesIndex: number, - series?: TimeSeriesChartDataItem[], - interval?: Interval): null | HTMLElement => { - - const tooltipParams = mapTooltipParams(params, series, focusedSeriesIndex); - if (!tooltipParams.items.length && !tooltipParams.comparisonItems.length) { - return null; - } - - const tooltipElement: HTMLElement = renderer.createElement('div'); - renderer.setStyle(tooltipElement, 'display', 'flex'); - renderer.setStyle(tooltipElement, 'flex-direction', 'column'); - renderer.setStyle(tooltipElement, 'align-items', 'flex-start'); - renderer.setStyle(tooltipElement, 'gap', '16px'); - - buildItemsTooltip(tooltipElement, tooltipParams.items, renderer, tooltipDateFormat, settings, valueFormatFunction, interval); - buildItemsTooltip(tooltipElement, tooltipParams.comparisonItems, renderer, tooltipDateFormat, settings, valueFormatFunction, interval); - - return tooltipElement; -}; - -interface TooltipItem { - param: CallbackDataParams; - dataItem: TimeSeriesChartDataItem; -} - -interface TooltipParams { - items: TooltipItem[]; - comparisonItems: TooltipItem[]; -} - -const buildItemsTooltip = (tooltipElement: HTMLElement, - items: TooltipItem[], - renderer: Renderer2, - tooltipDateFormat: DateFormatProcessor, - settings: TimeSeriesChartTooltipWidgetSettings, - valueFormatFunction: TimeSeriesChartTooltipValueFormatFunction, - interval?: Interval) => { - if (items.length) { - const tooltipItemsElement: HTMLElement = renderer.createElement('div'); - renderer.setStyle(tooltipItemsElement, 'display', 'flex'); - renderer.setStyle(tooltipItemsElement, 'flex-direction', 'column'); - renderer.setStyle(tooltipItemsElement, 'align-items', 'flex-start'); - renderer.setStyle(tooltipItemsElement, 'gap', '4px'); - renderer.appendChild(tooltipElement, tooltipItemsElement); - if (settings.tooltipShowDate) { - renderer.appendChild(tooltipItemsElement, - constructTooltipDateElement(renderer, tooltipDateFormat, settings, items[0].param, interval)); - } - for (const item of items) { - renderer.appendChild(tooltipItemsElement, - constructTooltipSeriesElement(renderer, settings, item, valueFormatFunction)); - } - } -}; - -const mapTooltipParams = (params: CallbackDataParams[] | CallbackDataParams, - series?: TimeSeriesChartDataItem[], - focusedSeriesIndex?: number): TooltipParams => { - const result: TooltipParams = { - items: [], - comparisonItems: [] - }; - if (!params || Array.isArray(params) && !params[0]) { - return result; - } - const firstParam = Array.isArray(params) ? params[0] : params; - if (!firstParam.value) { - return result; - } - let seriesParams: CallbackDataParams = null; - if (Array.isArray(params) && focusedSeriesIndex > -1) { - seriesParams = params.find(param => param.seriesIndex === focusedSeriesIndex); - } else if (!Array.isArray(params)) { - seriesParams = params; - } - if (seriesParams) { - appendTooltipItem(result, seriesParams, series); - } else if (Array.isArray(params)) { - for (seriesParams of params) { - appendTooltipItem(result, seriesParams, series); - } - } - return result; -}; - -const appendTooltipItem = (tooltipParams: TooltipParams, seriesParams: CallbackDataParams, series?: TimeSeriesChartDataItem[]) => { - const dataItem = series?.find(s => s.id === seriesParams.seriesId); - const tooltipItem: TooltipItem = { - param: seriesParams, - dataItem - }; - if (dataItem?.comparisonItem) { - tooltipParams.comparisonItems.push(tooltipItem); - } else { - tooltipParams.items.push(tooltipItem); - } -}; - -const constructTooltipDateElement = (renderer: Renderer2, - tooltipDateFormat: DateFormatProcessor, - settings: TimeSeriesChartTooltipWidgetSettings, - param: CallbackDataParams, - interval?: Interval): HTMLElement => { - const dateElement: HTMLElement = renderer.createElement('div'); - let dateText: string; - const startTs = param.value[2]; - const endTs = param.value[3]; - if (settings.tooltipDateInterval && startTs && endTs && (endTs - 1) > startTs) { - const startDateText = tooltipDateFormat.update(startTs, interval); - const endDateText = tooltipDateFormat.update(endTs - 1, interval); - if (startDateText === endDateText) { - dateText = startDateText; - } else { - dateText = startDateText + ' - ' + endDateText; - } - } else { - const ts = param.value[0]; - dateText = tooltipDateFormat.update(ts, interval); - } - renderer.appendChild(dateElement, renderer.createText(dateText)); - renderer.setStyle(dateElement, 'font-family', settings.tooltipDateFont.family); - renderer.setStyle(dateElement, 'font-size', settings.tooltipDateFont.size + settings.tooltipDateFont.sizeUnit); - renderer.setStyle(dateElement, 'font-style', settings.tooltipDateFont.style); - renderer.setStyle(dateElement, 'font-weight', settings.tooltipDateFont.weight); - renderer.setStyle(dateElement, 'line-height', settings.tooltipDateFont.lineHeight); - renderer.setStyle(dateElement, 'color', settings.tooltipDateColor); - return dateElement; -}; - -const constructTooltipSeriesElement = (renderer: Renderer2, - settings: TimeSeriesChartTooltipWidgetSettings, - item: TooltipItem, - valueFormatFunction: TimeSeriesChartTooltipValueFormatFunction): HTMLElement => { - const labelValueElement: HTMLElement = renderer.createElement('div'); - renderer.setStyle(labelValueElement, 'display', 'flex'); - renderer.setStyle(labelValueElement, 'flex-direction', 'row'); - renderer.setStyle(labelValueElement, 'align-items', 'center'); - renderer.setStyle(labelValueElement, 'align-self', 'stretch'); - renderer.setStyle(labelValueElement, 'gap', '12px'); - const labelElement: HTMLElement = renderer.createElement('div'); - renderer.setStyle(labelElement, 'display', 'flex'); - renderer.setStyle(labelElement, 'align-items', 'center'); - renderer.setStyle(labelElement, 'gap', '8px'); - renderer.appendChild(labelValueElement, labelElement); - const circleElement: HTMLElement = renderer.createElement('div'); - renderer.setStyle(circleElement, 'width', '8px'); - renderer.setStyle(circleElement, 'height', '8px'); - renderer.setStyle(circleElement, 'border-radius', '50%'); - renderer.setStyle(circleElement, 'background', item.param.color); - renderer.appendChild(labelElement, circleElement); - const labelTextElement: HTMLElement = renderer.createElement('div'); - renderer.appendChild(labelTextElement, renderer.createText(item.param.seriesName)); - renderer.setStyle(labelTextElement, 'font-family', settings.tooltipLabelFont.family); - renderer.setStyle(labelTextElement, 'font-size', settings.tooltipLabelFont.size + settings.tooltipLabelFont.sizeUnit); - renderer.setStyle(labelTextElement, 'font-style', settings.tooltipLabelFont.style); - renderer.setStyle(labelTextElement, 'font-weight', settings.tooltipLabelFont.weight); - renderer.setStyle(labelTextElement, 'line-height', settings.tooltipLabelFont.lineHeight); - renderer.setStyle(labelTextElement, 'color', settings.tooltipLabelColor); - renderer.appendChild(labelElement, labelTextElement); - const valueElement: HTMLElement = renderer.createElement('div'); - let formatFunction = valueFormatFunction; - let latestData: FormattedData; - let units = ''; - let decimals = 0; - if (item.dataItem) { - if (item.dataItem.tooltipValueFormatFunction) { - formatFunction = item.dataItem.tooltipValueFormatFunction; - } - latestData = item.dataItem.latestData; - units = item.dataItem.units; - decimals = item.dataItem.decimals; - } - if (!latestData) { - latestData = {} as FormattedData; - } - const value = formatFunction(item.param.value[1], latestData, units, decimals); - renderer.appendChild(valueElement, renderer.createText(value)); - renderer.setStyle(valueElement, 'flex', '1'); - renderer.setStyle(valueElement, 'text-align', 'end'); - renderer.setStyle(valueElement, 'font-family', settings.tooltipValueFont.family); - renderer.setStyle(valueElement, 'font-size', settings.tooltipValueFont.size + settings.tooltipValueFont.sizeUnit); - renderer.setStyle(valueElement, 'font-style', settings.tooltipValueFont.style); - renderer.setStyle(valueElement, 'font-weight', settings.tooltipValueFont.weight); - renderer.setStyle(valueElement, 'line-height', settings.tooltipValueFont.lineHeight); - renderer.setStyle(valueElement, 'color', settings.tooltipValueColor); - renderer.appendChild(labelValueElement, valueElement); - return labelValueElement; -}; - export enum TimeSeriesChartType { default = 'default', diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts index 913c346712..c5d09cfa6e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts @@ -21,7 +21,6 @@ import { createTimeSeriesVisualMapOption, createTimeSeriesXAxis, createTimeSeriesYAxis, - createTooltipValueFormatFunction, defaultTimeSeriesChartYAxisSettings, generateChartData, LineSeriesStepType, @@ -37,9 +36,6 @@ import { TimeSeriesChartThreshold, timeSeriesChartThresholdDefaultSettings, TimeSeriesChartThresholdItem, - timeSeriesChartTooltipFormatter, - TimeSeriesChartTooltipTrigger, - TimeSeriesChartTooltipValueFormatFunction, TimeSeriesChartType, TimeSeriesChartXAxis, TimeSeriesChartYAxis, @@ -60,7 +56,14 @@ import { measureAxisNameSize } from '@home/components/widget/lib/chart/echarts-widget.models'; import { DateFormatProcessor, ValueSourceType } from '@shared/models/widget-settings.models'; -import { formattedDataFormDatasourceData, formatValue, isDefinedAndNotNull, isEqual, mergeDeep } from '@core/utils'; +import { + formattedDataFormDatasourceData, + formatValue, + isDefined, + isDefinedAndNotNull, + isEqual, + mergeDeep +} from '@core/utils'; import { DataKey, Datasource, DatasourceType, FormattedData, widgetType } from '@shared/models/widget.models'; import * as echarts from 'echarts/core'; import { CallbackDataParams, PiecewiseVisualMapOption } from 'echarts/types/dist/shared'; @@ -74,6 +77,12 @@ import { DeepPartial } from '@shared/models/common'; import { BarRenderSharedContext } from '@home/components/widget/lib/chart/time-series-chart-bar.models'; import { TimeSeriesChartStateValueConverter } from '@home/components/widget/lib/chart/time-series-chart-state.models'; import { ChartLabelPosition, ChartShape, toAnimationOption } from '@home/components/widget/lib/chart/chart.models'; +import { + createTooltipValueFormatFunction, + TimeSeriesChartTooltip, + TimeSeriesChartTooltipTrigger, + TimeSeriesChartTooltipValueFormatFunction +} from '@home/components/widget/lib/chart/time-series-chart-tooltip.models'; export class TbTimeSeriesChart { @@ -133,7 +142,7 @@ export class TbTimeSeriesChart { private timeSeriesChartOptions: EChartsOption; private readonly tooltipDateFormat: DateFormatProcessor; - private readonly tooltipValueFormatFunction: TimeSeriesChartTooltipValueFormatFunction; + private readonly timeSeriesChartTooltip: TimeSeriesChartTooltip; private readonly stateValueConverter: TimeSeriesChartStateValueConverter; private yMinSubject = new BehaviorSubject(-1); @@ -162,6 +171,8 @@ export class TbTimeSeriesChart { private renderer: Renderer2, private autoResize = true) { + let tooltipValueFormatFunction: TimeSeriesChartTooltipValueFormatFunction; + this.settings = mergeDeep({} as TimeSeriesChartSettings, timeSeriesChartDefaultSettings, this.inputSettings as TimeSeriesChartSettings); @@ -169,7 +180,7 @@ export class TbTimeSeriesChart { this.stackMode = !this.comparisonEnabled && this.settings.stack; if (this.settings.states && this.settings.states.length) { this.stateValueConverter = new TimeSeriesChartStateValueConverter(this.ctx.utilsService, this.settings.states); - this.tooltipValueFormatFunction = this.stateValueConverter.tooltipFormatter; + tooltipValueFormatFunction = this.stateValueConverter.tooltipFormatter; } const $dashboardPageElement = this.ctx.$containerParent.parents('.tb-dashboard-page'); const dashboardPageElement = $dashboardPageElement.length ? $($dashboardPageElement[$dashboardPageElement.length-1]) : null; @@ -183,14 +194,20 @@ export class TbTimeSeriesChart { if (this.settings.tooltipShowDate) { this.tooltipDateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.tooltipDateFormat); } - if (!this.tooltipValueFormatFunction) { - this.tooltipValueFormatFunction = - createTooltipValueFormatFunction(this.settings.tooltipValueFormatter); - if (!this.tooltipValueFormatFunction) { - this.tooltipValueFormatFunction = (value, _latestData, units, decimals) => formatValue(value, decimals, units, false); + if (!tooltipValueFormatFunction) { + tooltipValueFormatFunction = createTooltipValueFormatFunction(this.settings.tooltipValueFormatter); + if (!tooltipValueFormatFunction) { + tooltipValueFormatFunction = (value, _latestData, units, decimals) => formatValue(value, decimals, units, false); } } } + this.timeSeriesChartTooltip = new TimeSeriesChartTooltip( + this.renderer, + this.ctx.sanitizer, + this.settings, + this.tooltipDateFormat, + tooltipValueFormatFunction + ); this.onResize(); if (this.autoResize) { this.shapeResize$ = new ResizeObserver(() => { @@ -290,7 +307,7 @@ export class TbTimeSeriesChart { } } - public toggleKey(dataKey: DataKey): void { + public toggleKey(dataKey: DataKey, dataIndex?: number): void { const enable = dataKey.hidden; const dataItem = this.dataItems.find(d => d.dataKey === dataKey); if (dataItem) { @@ -310,6 +327,9 @@ export class TbTimeSeriesChart { this.timeSeriesChart.setOption(this.timeSeriesChartOptions, this.stackMode ? {notMerge: true} : {replaceMerge: mergeList}); this.updateAxes(); dataKey.hidden = !enable; + if (isDefined(dataIndex)) { + this.ctx.defaultSubscription.updateDataVisibility(dataIndex); + } if (enable) { this.timeSeriesChart.dispatchAction({ type: 'highlight', @@ -603,10 +623,12 @@ export class TbTimeSeriesChart { type: this.noAggregation ? 'line' : 'shadow' }, formatter: (params: CallbackDataParams[]) => - this.settings.showTooltip ? timeSeriesChartTooltipFormatter(this.renderer, this.tooltipDateFormat, - this.settings, params, this.tooltipValueFormatFunction, + this.timeSeriesChartTooltip.formatted( + params, this.settings.tooltipShowFocusedSeries ? getFocusedSeriesIndex(this.timeSeriesChart) : -1, - this.dataItems, this.noAggregation ? null : this.ctx.timeWindow.interval) : undefined, + this.dataItems, + this.noAggregation ? null : this.ctx.timeWindow.interval, + ), padding: [8, 12], backgroundColor: this.settings.tooltipBackgroundColor, borderWidth: 0, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component.html new file mode 100644 index 0000000000..b5ef346300 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component.html @@ -0,0 +1,86 @@ + +
+
+
gateway.host
+
+ + + + warning + + +
+
+
+
gateway.port
+
+ + + + warning + + +
+
+
+
gateway.mqtt-version
+
+ + + {{ version.name }} + + +
+
+
+
gateway.client-id
+
+ + + + +
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component.ts new file mode 100644 index 0000000000..d4bfdd4694 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component.ts @@ -0,0 +1,127 @@ +/// +/// 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 { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, OnDestroy } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormGroup, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { + BrokerConfig, + MqttVersions, + noLeadTrailSpacesRegex, + PortLimits, +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { SharedModule } from '@shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { TranslateService } from '@ngx-translate/core'; +import { generateSecret } from '@core/utils'; +import { Subject } from 'rxjs'; +import { GatewayPortTooltipPipe } from '@home/components/widget/lib/gateway/pipes/gateway-port-tooltip.pipe'; +import { SecurityConfigComponent } from '../security-config/security-config.component'; + +@Component({ + selector: 'tb-broker-config-control', + templateUrl: './broker-config-control.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + CommonModule, + SharedModule, + SecurityConfigComponent, + GatewayPortTooltipPipe, + ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => BrokerConfigControlComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => BrokerConfigControlComponent), + multi: true + } + ] +}) +export class BrokerConfigControlComponent implements ControlValueAccessor, Validator, OnDestroy { + brokerConfigFormGroup: UntypedFormGroup; + mqttVersions = MqttVersions; + portLimits = PortLimits; + + private onChange: (value: string) => void; + private onTouched: () => void; + + private destroy$ = new Subject(); + + constructor(private fb: FormBuilder, + private cdr: ChangeDetectorRef, + private translate: TranslateService) { + this.brokerConfigFormGroup = this.fb.group({ + name: ['', []], + host: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + port: [null, [Validators.required, Validators.min(PortLimits.MIN), Validators.max(PortLimits.MAX)]], + version: [5, []], + clientId: ['tb_gw_' + generateSecret(5), [Validators.pattern(noLeadTrailSpacesRegex)]], + security: [] + }); + + this.brokerConfigFormGroup.valueChanges.subscribe(value => { + this.onChange(value); + this.onTouched(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + generate(formControlName: string): void { + this.brokerConfigFormGroup.get(formControlName)?.patchValue('tb_gw_' + generateSecret(5)); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + writeValue(brokerConfig: BrokerConfig): void { + const { + version = 5, + clientId = `tb_gw_${generateSecret(5)}`, + security = {}, + } = brokerConfig; + + this.brokerConfigFormGroup.reset({ ...brokerConfig, version, clientId, security }, { emitEvent: false }); + this.cdr.markForCheck(); + } + + validate(): ValidationErrors | null { + return this.brokerConfigFormGroup.valid ? null : { + brokerConfigFormGroup: {valid: false} + }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table/device-info-table.component.html similarity index 88% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table/device-info-table.component.html index c3fe478ef2..bbaf616471 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table/device-info-table.component.html @@ -50,9 +50,10 @@ class="tb-error"> warning -
@@ -83,9 +84,10 @@ class="tb-error"> warning -
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table/device-info-table.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table/device-info-table.component.scss diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table/device-info-table.component.ts similarity index 83% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table/device-info-table.component.ts index 8a40f25430..569f40b185 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table/device-info-table.component.ts @@ -16,27 +16,19 @@ import { ChangeDetectionStrategy, - ChangeDetectorRef, Component, - ElementRef, forwardRef, Input, - NgZone, OnDestroy, OnInit, - ViewContainerRef } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; import { MatDialog } from '@angular/material/dialog'; -import { DialogService } from '@core/services/dialog.service'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; -import { Overlay } from '@angular/cdk/overlay'; -import { UtilsService } from '@core/services/utils.service'; -import { EntityService } from '@core/http/entity.service'; import { ControlValueAccessor, FormBuilder, @@ -50,6 +42,7 @@ import { import { DeviceInfoType, noLeadTrailSpacesRegex, + OPCUaSourceTypes, SourceTypes, SourceTypeTranslationsMap } from '@home/components/widget/lib/gateway/gateway-widget.models'; @@ -88,7 +81,7 @@ export class DeviceInfoTableComponent extends PageComponent implements ControlVa required = false; @Input() - sourceTypes: Array = Object.values(SourceTypes); + sourceTypes: Array = Object.values(SourceTypes); deviceInfoTypeValue: any; @@ -111,14 +104,6 @@ export class DeviceInfoTableComponent extends PageComponent implements ControlVa constructor(protected store: Store, public translate: TranslateService, public dialog: MatDialog, - private overlay: Overlay, - private viewContainerRef: ViewContainerRef, - private dialogService: DialogService, - private entityService: EntityService, - private utils: UtilsService, - private zone: NgZone, - private cd: ChangeDetectorRef, - private elementRef: ElementRef, private fb: FormBuilder) { super(store); } @@ -131,13 +116,13 @@ export class DeviceInfoTableComponent extends PageComponent implements ControlVa if (this.useSource) { this.mappingFormGroup.addControl('deviceNameExpressionSource', - this.fb.control(SourceTypes.MSG, [])); + this.fb.control(this.sourceTypes[0], [])); } if (this.deviceInfoType === DeviceInfoType.FULL) { if (this.useSource) { this.mappingFormGroup.addControl('deviceProfileExpressionSource', - this.fb.control(SourceTypes.MSG, [])); + this.fb.control(this.sourceTypes[0], [])); } this.mappingFormGroup.addControl('deviceProfileExpression', this.fb.control('', this.required ? @@ -154,6 +139,7 @@ export class DeviceInfoTableComponent extends PageComponent implements ControlVa ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); + super.ngOnDestroy(); } registerOnChange(fn: any): void { @@ -175,5 +161,4 @@ export class DeviceInfoTableComponent extends PageComponent implements ControlVa updateView(value: any) { this.propagateChange(value); } - } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel/mapping-data-keys-panel.component.html similarity index 69% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel/mapping-data-keys-panel.component.html index 4c3a6434fd..31bed344b7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel/mapping-data-keys-panel.component.html @@ -26,20 +26,21 @@ -
{{ keyControl.get('key').value }}
- {{ '-' }} -
{{ valueTitle(keyControl.get('value').value) }}
+
+ {{ keyControl.get('key').value }}{{ '-' }} +
+
{{ valueTitle(keyControl) }}
+ *ngIf="keysType !== MappingKeysType.CUSTOM && keysType !== MappingKeysType.RPC_METHODS">
gateway.platform-side
- gateway.key + tb-hint-tooltip-icon="{{ 'gateway.JSONPath-hint' | translate }}"> + {{ 'gateway.key' | translate }}
@@ -53,12 +54,6 @@ class="tb-error"> warning -
-
@@ -71,18 +66,25 @@
- + - - {{ (rawData ? 'gateway.raw' : valueTypes.get(keyControl.get('type').value)?.name) | translate}} + + {{ (valueTypes.get(keyControl.get('type').value)?.name || valueTypes.get(keyControl.get('type').value)) | translate }} + + {{ 'gateway.raw' | translate }} +
- + - {{ valueTypes.get(valueType).name | translate }} + + {{ valueTypes.get(valueType).name || valueTypes.get(valueType) | translate }} + @@ -95,8 +97,8 @@
- gateway.value + tb-hint-tooltip-icon="{{ 'gateway.JSONPath-hint' | translate }}"> + {{ 'gateway.value' | translate }}
@@ -120,7 +123,7 @@
- +
gateway.key
@@ -152,7 +155,41 @@
- +
+
+
+
+ {{ 'gateway.method-name' | translate }} +
+
+ + + + warning + + +
+
+
+ + + +
+ {{ 'gateway.arguments' | translate }}{{' (' + keyControl.get('arguments').value?.length + ')'}} +
+
+
+ + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel/mapping-data-keys-panel.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel/mapping-data-keys-panel.component.scss diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel/mapping-data-keys-panel.component.ts similarity index 64% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel/mapping-data-keys-panel.component.ts index 40ee2728bd..7867a7213f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel/mapping-data-keys-panel.component.ts @@ -23,6 +23,8 @@ import { } from '@angular/core'; import { AbstractControl, + FormControl, + FormGroup, UntypedFormArray, UntypedFormBuilder, Validators @@ -39,7 +41,9 @@ import { MappingKeysType, MappingValueType, mappingValueTypesMap, - noLeadTrailSpacesRegex + noLeadTrailSpacesRegex, + OPCUaSourceTypes, + RpcMethodsMapping, } from '@home/components/widget/lib/gateway/gateway-widget.models'; @Component({ @@ -66,7 +70,16 @@ export class MappingDataKeysPanelComponent extends PageComponent implements OnIn keys: Array | {[key: string]: any}; @Input() - keysType: string; + keysType: MappingKeysType; + + @Input() + valueTypeKeys: Array = Object.values(MappingValueType); + + @Input() + valueTypeEnum = MappingValueType; + + @Input() + valueTypes: Map = mappingValueTypesMap; @Input() @coerceBoolean() @@ -76,16 +89,10 @@ export class MappingDataKeysPanelComponent extends PageComponent implements OnIn popover: TbPopoverComponent; @Output() - keysDataApplied = new EventEmitter | {[key: string]: any}>(); - - valueTypeKeys = Object.values(MappingValueType); + keysDataApplied = new EventEmitter | {[key: string]: unknown}>(); MappingKeysType = MappingKeysType; - valueTypeEnum = MappingValueType; - - valueTypes = mappingValueTypesMap; - dataKeyType: DataKeyType; keysListFormArray: UntypedFormArray; @@ -97,8 +104,8 @@ export class MappingDataKeysPanelComponent extends PageComponent implements OnIn super(store); } - ngOnInit() { - this.keysListFormArray = this.prepareKeysFormArray(this.keys) + ngOnInit(): void { + this.keysListFormArray = this.prepareKeysFormArray(this.keys); } trackByKey(index: number, keyControl: AbstractControl): any { @@ -106,12 +113,21 @@ export class MappingDataKeysPanelComponent extends PageComponent implements OnIn } addKey(): void { - const dataKeyFormGroup = this.fb.group({ - key: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - value: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]] - }); - if (this.keysType !== MappingKeysType.CUSTOM) { - dataKeyFormGroup.addControl('type', this.fb.control(this.rawData ? 'raw' : MappingValueType.STRING)); + let dataKeyFormGroup: FormGroup; + if (this.keysType === MappingKeysType.RPC_METHODS) { + dataKeyFormGroup = this.fb.group({ + method: ['', [Validators.required]], + arguments: [[], []] + }); + } else { + dataKeyFormGroup = this.fb.group({ + key: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + value: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]] + }); + } + if (this.keysType !== MappingKeysType.CUSTOM && this.keysType !== MappingKeysType.RPC_METHODS) { + const controlValue = this.rawData ? 'raw' : this.valueTypeKeys[0]; + dataKeyFormGroup.addControl('type', this.fb.control(controlValue)); } this.keysListFormArray.push(dataKeyFormGroup); } @@ -124,11 +140,11 @@ export class MappingDataKeysPanelComponent extends PageComponent implements OnIn this.keysListFormArray.markAsDirty(); } - cancel() { + cancel(): void { this.popover?.hide(); } - applyKeysData() { + applyKeysData(): void { let keys = this.keysListFormArray.value; if (this.keysType === MappingKeysType.CUSTOM) { keys = {}; @@ -139,7 +155,7 @@ export class MappingDataKeysPanelComponent extends PageComponent implements OnIn this.keysDataApplied.emit(keys); } - private prepareKeysFormArray(keys: Array | {[key: string]: any}): UntypedFormArray { + private prepareKeysFormArray(keys: Array | {[key: string]: any}): UntypedFormArray { const keysControlGroups: Array = []; if (keys) { if (this.keysType === MappingKeysType.CUSTOM) { @@ -148,19 +164,28 @@ export class MappingDataKeysPanelComponent extends PageComponent implements OnIn }); } keys.forEach((keyData) => { - const { key, value, type } = keyData; - const dataKeyFormGroup = this.fb.group({ - key: [key, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - value: [value, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - type: [type, []] - }); + let dataKeyFormGroup: FormGroup; + if (this.keysType === MappingKeysType.RPC_METHODS) { + dataKeyFormGroup = this.fb.group({ + method: [keyData.method, [Validators.required]], + arguments: [[...keyData.arguments], []] + }); + } else { + const { key, value, type } = keyData; + dataKeyFormGroup = this.fb.group({ + key: [key, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + value: [value, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + type: [type, []] + }); + } keysControlGroups.push(dataKeyFormGroup); }); } return this.fb.array(keysControlGroups); } - valueTitle(value: any): string { + valueTitle(keyControl: FormControl): string { + const value = keyControl.get(this.keysType === MappingKeysType.RPC_METHODS ? 'method' : 'value').value; if (isDefinedAndNotNull(value)) { if (typeof value === 'object') { return JSON.stringify(value); @@ -169,5 +194,4 @@ export class MappingDataKeysPanelComponent extends PageComponent implements OnIn } return ''; } - } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.ts deleted file mode 100644 index 4ef9695c65..0000000000 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.ts +++ /dev/null @@ -1,339 +0,0 @@ -/// -/// Copyright © 2016-2024 The Thingsboard Authors -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// - -import { - AfterViewInit, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - forwardRef, - Input, - NgZone, - OnDestroy, - OnInit, - ViewChild, - ViewContainerRef -} from '@angular/core'; -import { PageComponent } from '@shared/components/page.component'; -import { PageLink } from '@shared/models/page/page-link'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { TranslateService } from '@ngx-translate/core'; -import { MatDialog } from '@angular/material/dialog'; -import { DialogService } from '@core/services/dialog.service'; -import { BehaviorSubject, Observable, Subject } from 'rxjs'; -import { debounceTime, distinctUntilChanged, map, takeUntil } from 'rxjs/operators'; -import { Overlay } from '@angular/cdk/overlay'; -import { UtilsService } from '@core/services/utils.service'; -import { EntityService } from '@core/http/entity.service'; -import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR, UntypedFormArray } from '@angular/forms'; -import { - ConvertorTypeTranslationsMap, - MappingInfo, - MappingType, - MappingTypeTranslationsMap, - RequestType, - RequestTypesTranslationsMap -} from '@home/components/widget/lib/gateway/gateway-widget.models'; -import { CollectionViewer, DataSource } from '@angular/cdk/collections'; -import { MappingDialogComponent } from '@home/components/widget/lib/gateway/dialog/mapping-dialog.component'; -import { isDefinedAndNotNull, isUndefinedOrNull } from '@core/utils'; - -@Component({ - selector: 'tb-mapping-table', - templateUrl: './mapping-table.component.html', - styleUrls: ['./mapping-table.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MappingTableComponent), - multi: true - } - ] -}) -export class MappingTableComponent extends PageComponent implements ControlValueAccessor, AfterViewInit, OnInit, OnDestroy { - - mappingTypeTranslationsMap = MappingTypeTranslationsMap; - mappingTypeEnum = MappingType; - displayedColumns = []; - mappingColumns = []; - textSearchMode = false; - dataSource: MappingDatasource; - hidePageSize = false; - - activeValue = false; - dirtyValue = false; - - viewsInited = false; - - mappingTypeValue: MappingType; - - get mappingType(): MappingType { - return this.mappingTypeValue; - } - - @Input() - set mappingType(value: MappingType) { - if (this.mappingTypeValue !== value) { - this.mappingTypeValue = value; - } - } - - @ViewChild('searchInput') searchInputField: ElementRef; - - mappingFormGroup: UntypedFormArray; - textSearch = this.fb.control('', {nonNullable: true}); - - private destroy$ = new Subject(); - private propagateChange = (v: any) => {}; - - constructor(protected store: Store, - public translate: TranslateService, - public dialog: MatDialog, - private overlay: Overlay, - private viewContainerRef: ViewContainerRef, - private dialogService: DialogService, - private entityService: EntityService, - private utils: UtilsService, - private zone: NgZone, - private cd: ChangeDetectorRef, - private elementRef: ElementRef, - private fb: FormBuilder) { - super(store); - this.mappingFormGroup = this.fb.array([]); - this.dirtyValue = !this.activeValue; - this.dataSource = new MappingDatasource(); - } - - ngOnInit() { - if (this.mappingType === MappingType.DATA) { - this.mappingColumns.push( - {def: 'topicFilter', title: 'gateway.topic-filter'}, - {def: 'QoS', title: 'gateway.mqtt-qos'}, - {def: 'converter', title: 'gateway.payload-type'} - ) - } else { - this.mappingColumns.push( - {def: 'type', title: 'gateway.type'}, - {def: 'details', title: 'gateway.details'} - ); - } - this.displayedColumns.push(...this.mappingColumns.map(column => column.def), 'actions'); - this.mappingFormGroup.valueChanges.pipe( - takeUntil(this.destroy$) - ).subscribe((value) => { - this.updateTableData(value); - this.updateView(value); - }); - } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - - ngAfterViewInit() { - this.textSearch.valueChanges.pipe( - debounceTime(150), - distinctUntilChanged((prev, current) => (prev ?? '') === current.trim()), - takeUntil(this.destroy$) - ).subscribe((text) => { - const searchText = text.trim(); - this.updateTableData(this.mappingFormGroup.value, searchText.trim()) - }); - } - - registerOnChange(fn: any): void { - this.propagateChange = fn; - } - - registerOnTouched(fn: any): void {} - - writeValue(config: any) { - if (isUndefinedOrNull(config)) { - config = this.mappingType === MappingType.REQUESTS ? {} : []; - } - let mappingConfigs = config; - if (this.mappingType === MappingType.REQUESTS) { - mappingConfigs = []; - - Object.keys(config).forEach((configKey) => { - for (let mapping of config[configKey]) { - mappingConfigs.push({ - requestType: configKey, - requestValue: mapping - }); - } - }); - } - this.mappingFormGroup.clear({emitEvent: false}); - for (let mapping of mappingConfigs) { - this.mappingFormGroup.push(this.fb.group(mapping), {emitEvent: false}); - } - this.updateTableData(mappingConfigs); - } - - updateView(mappingConfigs: Array<{[key: string]: any}>) { - let config; - if (this.mappingType === MappingType.REQUESTS) { - config = {}; - for (let mappingConfig of mappingConfigs) { - if (config[mappingConfig.requestType]) { - config[mappingConfig.requestType].push(mappingConfig.requestValue); - } else { - config[mappingConfig.requestType] = [mappingConfig.requestValue]; - } - } - } else { - config = mappingConfigs; - } - - this.propagateChange(config); - } - - enterFilterMode() { - this.textSearchMode = true; - setTimeout(() => { - this.searchInputField.nativeElement.focus(); - this.searchInputField.nativeElement.setSelectionRange(0, 0); - }, 10); - } - - exitFilterMode() { - this.updateTableData(this.mappingFormGroup.value); - this.textSearchMode = false; - this.textSearch.reset(); - } - - manageMapping($event: Event, index?: number) { - if ($event) { - $event.stopPropagation(); - } - const value = isDefinedAndNotNull(index) ? this.mappingFormGroup.at(index).value : {}; - this.dialog.open(MappingDialogComponent, { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], - data: { - mappingType: this.mappingType, - value, - buttonTitle: isUndefinedOrNull(index) ? 'action.add' : 'action.apply' - } - }).afterClosed().subscribe( - (res) => { - if (res) { - if (isDefinedAndNotNull(index)) { - this.mappingFormGroup.at(index).patchValue(res); - } else { - this.mappingFormGroup.push(this.fb.group(res)); - } - } - } - ); - } - - updateTableData(value: Array<{[key: string]: any}>, textSearch?: string): void { - let tableValue = value; - if (this.mappingType === MappingType.DATA) { - tableValue = tableValue.map((value) => { - return { - topicFilter: value.topicFilter, - QoS: value.subscriptionQos, - converter: this.translate.instant(ConvertorTypeTranslationsMap.get(value.converter.type)) - }; - }); - } else { - tableValue = tableValue.map((value) => { - let details; - if (value.requestType === RequestType.ATTRIBUTE_UPDATE) { - details = value.requestValue.attributeFilter; - } else if (value.requestType === RequestType.SERVER_SIDE_RPC) { - details = value.requestValue.methodFilter; - } else { - details = value.requestValue.topicFilter; - } - return { - type: this.translate.instant(RequestTypesTranslationsMap.get(value.requestType)), - details - }; - }); - } - if (textSearch) { - tableValue = tableValue.filter(value => - Object.values(value).some(val => - val.toString().toLowerCase().includes(textSearch.toLowerCase()) - ) - ); - } - this.dataSource.loadMappings(tableValue); - } - - deleteMapping($event: Event, index: number) { - if ($event) { - $event.stopPropagation(); - } - this.dialogService.confirm( - this.translate.instant('gateway.delete-mapping-title'), - '', - this.translate.instant('action.no'), - this.translate.instant('action.yes'), - true - ).subscribe((result) => { - if (result) { - this.mappingFormGroup.removeAt(index); - } - }); - } - -} - -export class MappingDatasource implements DataSource<{[key: string]: any}> { - - private mappingSubject = new BehaviorSubject>([]); - - private allMappings: Observable>; - - constructor() {} - - connect(collectionViewer: CollectionViewer): Observable> { - return this.mappingSubject.asObservable(); - } - - disconnect(collectionViewer: CollectionViewer): void { - this.mappingSubject.complete(); - } - - loadMappings(mappings: Array<{[key: string]: any}>, pageLink?: PageLink, reload: boolean = false): void { - if (reload) { - this.allMappings = null; - } - this.mappingSubject.next(mappings); - } - - isEmpty(): Observable { - return this.mappingSubject.pipe( - map((mappings) => !mappings.length) - ); - } - - total(): Observable { - return this.mappingSubject.pipe( - map((mappings) => mappings.length) - ); - } - -} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component.html similarity index 85% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component.html index e5c703d8d0..7e1ab2bb62 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component.html @@ -18,7 +18,7 @@
-
+
{{mappingTypeTranslationsMap.get(mappingType) | translate}}
@@ -60,10 +60,12 @@
- + {{ column.title | translate }} - + {{ mapping[column.def] }} @@ -73,15 +75,22 @@ -
+ + +
+
- - +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component.scss diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component.ts new file mode 100644 index 0000000000..05dcfa88ba --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component.ts @@ -0,0 +1,317 @@ +/// +/// 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 { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + forwardRef, + Input, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, take, takeUntil } from 'rxjs/operators'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + ValidationErrors, + Validator, +} from '@angular/forms'; +import { + ConnectorMapping, + ConverterConnectorMapping, + ConvertorTypeTranslationsMap, + DeviceConnectorMapping, + MappingInfo, + MappingType, + MappingTypeTranslationsMap, + MappingValue, + RequestMappingData, + RequestType, + RequestTypesTranslationsMap +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { MappingDialogComponent } from '@home/components/widget/lib/gateway/dialog/mapping-dialog.component'; +import { isDefinedAndNotNull, isUndefinedOrNull } from '@core/utils'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { SharedModule } from '@shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract'; + +@Component({ + selector: 'tb-mapping-table', + templateUrl: './mapping-table.component.html', + styleUrls: ['./mapping-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MappingTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MappingTableComponent), + multi: true + } + ], + standalone: true, + imports: [CommonModule, SharedModule] +}) +export class MappingTableComponent implements ControlValueAccessor, Validator, AfterViewInit, OnInit, OnDestroy { + + @Input() + @coerceBoolean() + required = false; + + @Input() + set mappingType(value: MappingType) { + if (this.mappingTypeValue !== value) { + this.mappingTypeValue = value; + } + } + + get mappingType(): MappingType { + return this.mappingTypeValue; + } + + @ViewChild('searchInput') searchInputField: ElementRef; + + mappingTypeTranslationsMap = MappingTypeTranslationsMap; + mappingTypeEnum = MappingType; + displayedColumns = []; + mappingColumns = []; + textSearchMode = false; + dataSource: MappingDatasource; + hidePageSize = false; + activeValue = false; + dirtyValue = false; + mappingTypeValue: MappingType; + mappingFormGroup: UntypedFormArray; + textSearch = this.fb.control('', {nonNullable: true}); + + private onChange: (value: string) => void = () => {}; + private onTouched: () => void = () => {}; + + private destroy$ = new Subject(); + + constructor(public translate: TranslateService, + public dialog: MatDialog, + private dialogService: DialogService, + private fb: FormBuilder) { + this.mappingFormGroup = this.fb.array([]); + this.dirtyValue = !this.activeValue; + this.dataSource = new MappingDatasource(); + } + + ngOnInit(): void { + this.setMappingColumns(); + this.displayedColumns.push(...this.mappingColumns.map(column => column.def), 'actions'); + this.mappingFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + this.updateTableData(value); + this.onChange(value); + this.onTouched(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + ngAfterViewInit(): void { + this.textSearch.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged((prev, current) => (prev ?? '') === current.trim()), + takeUntil(this.destroy$) + ).subscribe((text) => { + const searchText = text.trim(); + this.updateTableData(this.mappingFormGroup.value, searchText.trim()); + }); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + writeValue(connectorMappings: ConnectorMapping[]): void { + this.mappingFormGroup.clear(); + this.pushDataAsFormArrays(connectorMappings); + } + + validate(): ValidationErrors | null { + return !this.required || this.mappingFormGroup.controls.length ? null : { + mappingFormGroup: {valid: false} + }; + } + + enterFilterMode(): void { + this.textSearchMode = true; + setTimeout(() => { + this.searchInputField.nativeElement.focus(); + this.searchInputField.nativeElement.setSelectionRange(0, 0); + }, 10); + } + + exitFilterMode(): void { + this.updateTableData(this.mappingFormGroup.value); + this.textSearchMode = false; + this.textSearch.reset(); + } + + manageMapping($event: Event, index?: number): void { + if ($event) { + $event.stopPropagation(); + } + const value = isDefinedAndNotNull(index) ? this.mappingFormGroup.at(index).value : {}; + this.dialog.open(MappingDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + mappingType: this.mappingType, + value, + buttonTitle: isUndefinedOrNull(index) ? 'action.add' : 'action.apply' + } + }).afterClosed() + .pipe(take(1), takeUntil(this.destroy$)) + .subscribe(res => { + if (res) { + if (isDefinedAndNotNull(index)) { + this.mappingFormGroup.at(index).patchValue(res); + } else { + this.pushDataAsFormArrays([res]); + } + this.mappingFormGroup.markAsDirty(); + } + }); + } + + private updateTableData(value: ConnectorMapping[], textSearch?: string): void { + let tableValue = value.map(mappingValue => this.getMappingValue(mappingValue)); + if (textSearch) { + tableValue = tableValue.filter(mappingValue => + Object.values(mappingValue).some(val => + val.toString().toLowerCase().includes(textSearch.toLowerCase()) + ) + ); + } + this.dataSource.loadData(tableValue); + } + + deleteMapping($event: Event, index: number): void { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('gateway.delete-mapping-title'), + '', + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((result) => { + if (result) { + this.mappingFormGroup.removeAt(index); + this.mappingFormGroup.markAsDirty(); + } + }); + } + + private pushDataAsFormArrays(data: ConnectorMapping[]): void { + if (data?.length) { + data.forEach((mapping: ConnectorMapping) => this.mappingFormGroup.push(this.fb.control(mapping))); + } + } + + private getMappingValue(value: ConnectorMapping): MappingValue { + switch (this.mappingType) { + case MappingType.DATA: + return { + topicFilter: (value as ConverterConnectorMapping).topicFilter, + QoS: (value as ConverterConnectorMapping).subscriptionQos, + converter: this.translate.instant(ConvertorTypeTranslationsMap.get((value as ConverterConnectorMapping).converter?.type) || '') + }; + case MappingType.REQUESTS: + let details: string; + if ((value as RequestMappingData).requestType === RequestType.ATTRIBUTE_UPDATE) { + details = (value as RequestMappingData).requestValue.attributeFilter; + } else if ((value as RequestMappingData).requestType === RequestType.SERVER_SIDE_RPC) { + details = (value as RequestMappingData).requestValue.methodFilter; + } else { + details = (value as RequestMappingData).requestValue.topicFilter; + } + return { + requestType: (value as RequestMappingData).requestType, + type: this.translate.instant(RequestTypesTranslationsMap.get((value as RequestMappingData).requestType)), + details + }; + case MappingType.OPCUA: + const deviceNamePattern = (value as DeviceConnectorMapping).deviceInfo?.deviceNameExpression; + const deviceProfileExpression = (value as DeviceConnectorMapping).deviceInfo?.deviceProfileExpression; + const { deviceNodePattern } = value as DeviceConnectorMapping; + return { + deviceNodePattern, + deviceNamePattern, + deviceProfileExpression + }; + default: + return {} as MappingValue; + } + } + + private setMappingColumns(): void { + switch (this.mappingType) { + case MappingType.DATA: + this.mappingColumns.push( + { def: 'topicFilter', title: 'gateway.topic-filter' }, + { def: 'QoS', title: 'gateway.mqtt-qos' }, + { def: 'converter', title: 'gateway.payload-type' } + ); + break; + case MappingType.REQUESTS: + this.mappingColumns.push( + { def: 'type', title: 'gateway.type' }, + { def: 'details', title: 'gateway.details' } + ); + break; + case MappingType.OPCUA: + this.mappingColumns.push( + { def: 'deviceNodePattern', title: 'gateway.device-node' }, + { def: 'deviceNamePattern', title: 'gateway.device-name' }, + { def: 'deviceProfileExpression', title: 'gateway.device-profile' } + ); + } + } +} + +export class MappingDatasource extends TbTableDatasource { + constructor() { + super(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.component.html new file mode 100644 index 0000000000..30233cddcf --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.component.html @@ -0,0 +1,38 @@ + + + + + + + + + +
+
{{ 'gateway.hints.modbus-server' | translate }}
+
+ + + {{ 'gateway.enable' | translate }} + + +
+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.component.scss new file mode 100644 index 0000000000..3b7e7288c8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.component.scss @@ -0,0 +1,18 @@ +/** + * 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 { + height: 100%; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.component.ts new file mode 100644 index 0000000000..aa0424da93 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.component.ts @@ -0,0 +1,142 @@ +/// +/// 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 { ChangeDetectionStrategy, Component, forwardRef, Input, OnDestroy, TemplateRef } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormControl, + ValidationErrors, + Validator, +} from '@angular/forms'; +import { ModbusBasicConfig } from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { SharedModule } from '@shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; + +import { EllipsisChipListDirective } from '@shared/directives/ellipsis-chip-list.directive'; +import { ModbusSlaveConfigComponent } from '../modbus-slave-config/modbus-slave-config.component'; +import { ModbusMasterTableComponent } from '../modbus-master-table/modbus-master-table.component'; +import { isEqual } from '@core/utils'; + +@Component({ + selector: 'tb-modbus-basic-config', + templateUrl: './modbus-basic-config.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ModbusBasicConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ModbusBasicConfigComponent), + multi: true + } + ], + standalone: true, + imports: [ + CommonModule, + SharedModule, + ModbusSlaveConfigComponent, + ModbusMasterTableComponent, + EllipsisChipListDirective, + ], + styleUrls: ['./modbus-basic-config.component.scss'], +}) + +export class ModbusBasicConfigComponent implements ControlValueAccessor, Validator, OnDestroy { + + @Input() generalTabContent: TemplateRef; + + basicFormGroup: FormGroup; + enableSlaveControl: FormControl; + + onChange: (value: ModbusBasicConfig) => void; + onTouched: () => void; + + private destroy$ = new Subject(); + + constructor(private fb: FormBuilder) { + this.basicFormGroup = this.fb.group({ + master: [], + slave: [], + }); + this.enableSlaveControl = new FormControl(false); + + this.basicFormGroup.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(({ master, slave }) => { + this.onChange({ master, slave: slave ?? {} }); + this.onTouched(); + }); + + this.enableSlaveControl.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(enable => { + this.updateSlaveEnabling(enable); + this.basicFormGroup.get('slave').updateValueAndValidity({emitEvent: !!this.onChange}); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + registerOnChange(fn: (value: ModbusBasicConfig) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + writeValue(basicConfig: ModbusBasicConfig): void { + const editedBase = { + slave: basicConfig.slave ?? {}, + master: basicConfig.master ?? {}, + }; + + this.basicFormGroup.setValue(editedBase, {emitEvent: false}); + this.enableSlaveControl.setValue(!!basicConfig.slave && !isEqual(basicConfig.slave, {})); + } + + validate(basicFormControl: UntypedFormControl): ValidationErrors | null { + const { master, slave } = basicFormControl.value; + const isEmpty = !master?.slaves?.length && (isEqual(slave, {}) || !slave); + if (!this.basicFormGroup.valid || isEmpty) { + return { + basicFormGroup: {valid: false} + }; + } + return null; + } + + private updateSlaveEnabling(isEnabled: boolean): void { + if (isEnabled) { + this.basicFormGroup.get('slave').enable({emitEvent: false}); + } else { + this.basicFormGroup.get('slave').disable({emitEvent: false}); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.html new file mode 100644 index 0000000000..cfb97f674d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.html @@ -0,0 +1,202 @@ + +
+
+
{{ panelTitle | translate }}{{' (' + keysListFormArray.controls.length + ')'}}
+
+
+
+ + + + +
+ {{ keyControl.get('tag').value }}{{ '-' }}{{ keyControl.get('value').value }} +
+ +
+
{{ 'gateway.key' | translate }}: {{ keyControl.get('tag').value }}
+
{{ 'gateway.address' | translate }}: {{ keyControl.get('address').value }}
+
{{ 'gateway.type' | translate }}: {{ keyControl.get('type').value }}
+
+
+
+
+ +
+ {{ 'gateway.hints.modbus.data-keys' | translate }} +
+
+
+
+
gateway.platform-side
+
+
+ gateway.key +
+
+ + + + warning + + +
+
+
+
+
gateway.connector-side
+
+
+ gateway.type +
+
+ + + {{ type }} + + +
+
+
+
gateway.function-code
+
+ + + + {{ ModbusFunctionCodeTranslationsMap.get(code) | translate }} + + + +
+
+
+
gateway.objects-count
+
+ + + + warning + + +
+
+
+
gateway.address
+
+ + + + warning + + +
+
+
+
gateway.value
+
+ + + + warning + + +
+
+
+
+
+
+
+ +
+
+
+ +
+
+ +
+ {{ noKeysText }} +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.scss new file mode 100644 index 0000000000..445ac6c0ae --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.scss @@ -0,0 +1,41 @@ +/** + * 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-modbus-keys-panel { + width: 77vw; + max-width: 700px; + + .title-container { + width: 180px; + } + + .key-panel { + height: 500px; + overflow: auto; + } + + .tb-form-panel { + .mat-mdc-icon-button { + width: 56px; + height: 56px; + padding: 16px; + color: rgba(0, 0, 0, 0.54); + } + } + } +} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.ts new file mode 100644 index 0000000000..3c61d351b7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.ts @@ -0,0 +1,210 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { + AbstractControl, + FormArray, + FormGroup, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { + ModbusDataType, + ModbusEditableDataTypes, + ModbusFunctionCodeTranslationsMap, + ModbusObjectCountByDataType, + ModbusValue, + ModbusValueKey, + noLeadTrailSpacesRegex, +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { GatewayHelpLinkPipe } from '@home/components/widget/lib/gateway/pipes/gateway-help-link.pipe'; +import { generateSecret } from '@core/utils'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; + +@Component({ + selector: 'tb-modbus-data-keys-panel', + templateUrl: './modbus-data-keys-panel.component.html', + styleUrls: ['./modbus-data-keys-panel.component.scss'], + standalone: true, + imports: [ + CommonModule, + SharedModule, + GatewayHelpLinkPipe, + ] +}) +export class ModbusDataKeysPanelComponent implements OnInit, OnDestroy { + + @coerceBoolean() + @Input() isMaster = false; + @Input() panelTitle: string; + @Input() addKeyTitle: string; + @Input() deleteKeyTitle: string; + @Input() noKeysText: string; + @Input() keysType: ModbusValueKey; + @Input() values: ModbusValue[]; + @Input() popover: TbPopoverComponent; + + @Output() keysDataApplied = new EventEmitter>(); + + keysListFormArray: FormArray; + modbusDataTypes = Object.values(ModbusDataType); + withFunctionCode = true; + functionCodesMap = new Map(); + defaultFunctionCodes = []; + + readonly ModbusEditableDataTypes = ModbusEditableDataTypes; + readonly ModbusFunctionCodeTranslationsMap = ModbusFunctionCodeTranslationsMap; + + private destroy$ = new Subject(); + + private readonly defaultReadFunctionCodes = [3, 4]; + private readonly bitsReadFunctionCodes = [1, 2]; + private readonly defaultWriteFunctionCodes = [6, 16]; + private readonly bitsWriteFunctionCodes = [5, 15]; + + constructor(private fb: UntypedFormBuilder) {} + + ngOnInit(): void { + this.withFunctionCode = !this.isMaster || (this.keysType !== ModbusValueKey.ATTRIBUTES && this.keysType !== ModbusValueKey.TIMESERIES); + this.keysListFormArray = this.prepareKeysFormArray(this.values); + this.defaultFunctionCodes = this.getDefaultFunctionCodes(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + trackByControlId(_: number, keyControl: AbstractControl): string { + return keyControl.value.id; + } + + addKey(): void { + const dataKeyFormGroup = this.fb.group({ + tag: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + value: [{value: '', disabled: !this.isMaster}, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + type: [ModbusDataType.BYTES, [Validators.required]], + address: [null, [Validators.required]], + objectsCount: [1, [Validators.required]], + functionCode: [{ value: this.getDefaultFunctionCodes()[0], disabled: !this.withFunctionCode }, [Validators.required]], + id: [{value: generateSecret(5), disabled: true}], + }); + this.observeKeyDataType(dataKeyFormGroup); + + this.keysListFormArray.push(dataKeyFormGroup); + } + + deleteKey($event: Event, index: number): void { + if ($event) { + $event.stopPropagation(); + } + this.keysListFormArray.removeAt(index); + this.keysListFormArray.markAsDirty(); + } + + cancel(): void { + this.popover.hide(); + } + + applyKeysData(): void { + this.keysDataApplied.emit(this.keysListFormArray.value); + } + + private prepareKeysFormArray(values: ModbusValue[]): UntypedFormArray { + const keysControlGroups: Array = []; + + if (values) { + values.forEach(value => { + const dataKeyFormGroup = this.createDataKeyFormGroup(value); + this.observeKeyDataType(dataKeyFormGroup); + this.functionCodesMap.set(dataKeyFormGroup.get('id').value, this.getFunctionCodes(value.type)); + + keysControlGroups.push(dataKeyFormGroup); + }); + } + + return this.fb.array(keysControlGroups); + } + + private createDataKeyFormGroup(modbusValue: ModbusValue): FormGroup { + const { tag, value, type, address, objectsCount, functionCode } = modbusValue; + + return this.fb.group({ + tag: [tag, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + value: [{ value, disabled: !this.isMaster }, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + type: [type, [Validators.required]], + address: [address, [Validators.required]], + objectsCount: [objectsCount, [Validators.required]], + functionCode: [{ value: functionCode, disabled: !this.withFunctionCode }, [Validators.required]], + id: [{ value: generateSecret(5), disabled: true }], + }); + } + + private observeKeyDataType(keyFormGroup: FormGroup): void { + keyFormGroup.get('type').valueChanges.pipe(takeUntil(this.destroy$)).subscribe(dataType => { + if (!this.ModbusEditableDataTypes.includes(dataType)) { + keyFormGroup.get('objectsCount').patchValue(ModbusObjectCountByDataType[dataType], {emitEvent: false}); + } + this.updateFunctionCodes(keyFormGroup, dataType); + }); + } + + private updateFunctionCodes(keyFormGroup: FormGroup, dataType: ModbusDataType): void { + const functionCodes = this.getFunctionCodes(dataType); + this.functionCodesMap.set(keyFormGroup.get('id').value, functionCodes); + if (!functionCodes.includes(keyFormGroup.get('functionCode').value)) { + keyFormGroup.get('functionCode').patchValue(functionCodes[0], {emitEvent: false}); + } + } + + private getFunctionCodes(dataType: ModbusDataType): number[] { + const writeFunctionCodes = [ + ...(dataType === ModbusDataType.BITS ? this.bitsWriteFunctionCodes : []), ...this.defaultWriteFunctionCodes + ]; + + if (this.keysType === ModbusValueKey.ATTRIBUTES_UPDATES) { + return writeFunctionCodes.sort((a, b) => a - b); + } + + const functionCodes = [...this.defaultReadFunctionCodes]; + if (dataType === ModbusDataType.BITS) { + functionCodes.push(...this.bitsReadFunctionCodes); + } + if (this.keysType === ModbusValueKey.RPC_REQUESTS) { + functionCodes.push(...writeFunctionCodes); + } + + return functionCodes.sort((a, b) => a - b); + } + + private getDefaultFunctionCodes(): number[] { + if (this.keysType === ModbusValueKey.ATTRIBUTES_UPDATES) { + return this.defaultWriteFunctionCodes; + } + if (this.keysType === ModbusValueKey.RPC_REQUESTS) { + return [...this.defaultReadFunctionCodes, ...this.defaultWriteFunctionCodes]; + } + return this.defaultReadFunctionCodes; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-master-table/modbus-master-table.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-master-table/modbus-master-table.component.html new file mode 100644 index 0000000000..8f8aeaeef7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-master-table/modbus-master-table.component.html @@ -0,0 +1,134 @@ + +
+
+
{{ 'gateway.hints.modbus-master' | translate }}
+
+
+ +
+
+ {{ 'gateway.servers-slaves' | translate}} +
+ + + +
+
+ +
+ + +   + + + +
+
+
+
+ + + {{ 'gateway.name' | translate }} + + + {{ slave['name'] }} + + + + + {{ 'gateway.client-communication-type' | translate }} + + + {{ ModbusProtocolLabelsMap.get(slave['type']) }} + + + + + + + + + + +
+ +
+
+ + + + +
+
+
+ + +
+
+ +
+
+ + widget.no-data-found + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-master-table/modbus-master-table.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-master-table/modbus-master-table.component.scss new file mode 100644 index 0000000000..e9a5d3ebcd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-master-table/modbus-master-table.component.scss @@ -0,0 +1,90 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../scss/constants'; + +:host { + width: 100%; + height: 100%; + display: block; + + .tb-master-table { + + .tb-master-table-content { + width: 100%; + height: 100%; + background: #fff; + overflow: hidden; + + .mat-toolbar-tools{ + min-height: auto; + } + + .title-container{ + overflow: hidden; + } + + .tb-master-table-title { + padding-right: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .table-container { + overflow: auto; + + .mat-mdc-table { + table-layout: fixed; + min-width: 450px; + + .table-value-column { + padding: 0 12px; + width: 38%; + } + } + } + } + } + + .no-data-found { + height: calc(100% - 120px); + } + + @media #{$mat-xs} { + .mat-toolbar { + height: auto; + min-height: 100px; + + .tb-master-table-title{ + padding-bottom: 5px; + width: 100%; + } + } + } +} + +:host ::ng-deep { + mat-cell.tb-value-cell { + cursor: pointer; + + .mat-icon { + height: 24px; + width: 24px; + font-size: 24px; + color: #757575 + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-master-table/modbus-master-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-master-table/modbus-master-table.component.ts new file mode 100644 index 0000000000..4787523b40 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-master-table/modbus-master-table.component.ts @@ -0,0 +1,216 @@ +/// +/// 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 { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + forwardRef, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, take, takeUntil } from 'rxjs/operators'; +import { + ControlValueAccessor, + FormArray, + FormBuilder, + NG_VALUE_ACCESSOR, + UntypedFormGroup, +} from '@angular/forms'; +import { + ModbusMasterConfig, + ModbusProtocolLabelsMap, + ModbusSlaveInfo, + ModbusValues, + SlaveConfig +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { isDefinedAndNotNull } from '@core/utils'; +import { SharedModule } from '@shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { ModbusSlaveDialogComponent } from '../modbus-slave-dialog/modbus-slave-dialog.component'; +import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract'; + +@Component({ + selector: 'tb-modbus-master-table', + templateUrl: './modbus-master-table.component.html', + styleUrls: ['./modbus-master-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ModbusMasterTableComponent), + multi: true + }, + ], + standalone: true, + imports: [CommonModule, SharedModule] +}) +export class ModbusMasterTableComponent implements ControlValueAccessor, AfterViewInit, OnInit, OnDestroy { + + @ViewChild('searchInput') searchInputField: ElementRef; + + textSearchMode = false; + dataSource: SlavesDatasource; + masterFormGroup: UntypedFormGroup; + textSearch = this.fb.control('', {nonNullable: true}); + + readonly ModbusProtocolLabelsMap = ModbusProtocolLabelsMap; + + private onChange: (value: ModbusMasterConfig) => void = () => {}; + private onTouched: () => void = () => {}; + + private destroy$ = new Subject(); + + constructor( + public translate: TranslateService, + public dialog: MatDialog, + private dialogService: DialogService, + private fb: FormBuilder, + private cdr: ChangeDetectorRef, + ) { + this.masterFormGroup = this.fb.group({ slaves: this.fb.array([]) }); + this.dataSource = new SlavesDatasource(); + } + + get slaves(): FormArray { + return this.masterFormGroup.get('slaves') as FormArray; + } + + ngOnInit(): void { + this.masterFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + this.updateTableData(value.slaves); + this.onChange(value); + this.onTouched(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + ngAfterViewInit(): void { + this.textSearch.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged((prev, current) => (prev ?? '') === current.trim()), + takeUntil(this.destroy$) + ).subscribe(text => this.updateTableData(this.slaves.value, text.trim())); + } + + registerOnChange(fn: (value: ModbusMasterConfig) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + writeValue(master: ModbusMasterConfig): void { + this.slaves.clear(); + this.pushDataAsFormArrays(master.slaves); + } + + enterFilterMode(): void { + this.textSearchMode = true; + this.cdr.detectChanges(); + const searchInput = this.searchInputField.nativeElement; + searchInput.focus(); + searchInput.setSelectionRange(0, 0); + } + + exitFilterMode(): void { + this.updateTableData(this.slaves.value); + this.textSearchMode = false; + this.textSearch.reset(); + } + + manageSlave($event: Event, index?: number): void { + if ($event) { + $event.stopPropagation(); + } + const withIndex = isDefinedAndNotNull(index); + const value = withIndex ? this.slaves.at(index).value : {}; + this.dialog.open(ModbusSlaveDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + value, + buttonTitle: withIndex ? 'action.apply' : 'action.add' + } + }).afterClosed() + .pipe(take(1), takeUntil(this.destroy$)) + .subscribe(res => { + if (res) { + if (withIndex) { + this.slaves.at(index).patchValue(res); + } else { + this.slaves.push(this.fb.control(res)); + } + this.masterFormGroup.markAsDirty(); + } + }); + } + + deleteSlave($event: Event, index: number): void { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('gateway.delete-slave-title'), + '', + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).pipe(take(1), takeUntil(this.destroy$)).subscribe((result) => { + if (result) { + this.slaves.removeAt(index); + this.masterFormGroup.markAsDirty(); + } + }); + } + + private updateTableData(data: SlaveConfig[], textSearch?: string): void { + if (textSearch) { + data = data.filter(item => + Object.values(item).some(value => + value.toString().toLowerCase().includes(textSearch.toLowerCase()) + ) + ); + } + this.dataSource.loadData(data); + } + + private pushDataAsFormArrays(slaves: SlaveConfig[]): void { + if (slaves?.length) { + slaves.forEach((slave: SlaveConfig) => this.slaves.push(this.fb.control(slave))); + } + } +} + +export class SlavesDatasource extends TbTableDatasource { + constructor() { + super(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-rpc-parameters/modbus-rpc-parameters.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-rpc-parameters/modbus-rpc-parameters.component.html new file mode 100644 index 0000000000..c2dead1abc --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-rpc-parameters/modbus-rpc-parameters.component.html @@ -0,0 +1,92 @@ + + +
+ + {{ 'gateway.key' | translate }} + + + warning + + +
+
+ + {{ 'gateway.rpc.type' | translate }} + + {{ type }} + + + + {{ 'gateway.rpc.functionCode' | translate }} + + {{ ModbusFunctionCodeTranslationsMap.get(code) | translate}} + + +
+
+ + {{ 'gateway.rpc.value' | translate }} + + + warning + + +
+
+ + {{ 'gateway.rpc.address' | translate }} + + + warning + + + + {{ 'gateway.rpc.objectsCount' | translate }} + + +
+
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-rpc-parameters/modbus-rpc-parameters.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-rpc-parameters/modbus-rpc-parameters.component.ts new file mode 100644 index 0000000000..17c48cc595 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-rpc-parameters/modbus-rpc-parameters.component.ts @@ -0,0 +1,166 @@ +/// +/// 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 { + ChangeDetectionStrategy, + Component, + forwardRef, + OnDestroy, +} from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormGroup, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { SharedModule } from '@shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { + ModbusDataType, + ModbusEditableDataTypes, + ModbusFunctionCodeTranslationsMap, + ModbusObjectCountByDataType, + ModbusValue, + noLeadTrailSpacesRegex, +} from '@home/components/widget/lib/gateway/gateway-widget.models'; + +@Component({ + selector: 'tb-modbus-rpc-parameters', + templateUrl: './modbus-rpc-parameters.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ModbusRpcParametersComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ModbusRpcParametersComponent), + multi: true + } + ], + standalone: true, + imports: [ + CommonModule, + SharedModule, + ], +}) +export class ModbusRpcParametersComponent implements ControlValueAccessor, Validator, OnDestroy { + + rpcParametersFormGroup: UntypedFormGroup; + functionCodes: Array; + + readonly ModbusEditableDataTypes = ModbusEditableDataTypes; + readonly ModbusFunctionCodeTranslationsMap = ModbusFunctionCodeTranslationsMap; + + readonly modbusDataTypes = Object.values(ModbusDataType) as ModbusDataType[]; + readonly writeFunctionCodes = [5, 6, 15, 16]; + + private readonly defaultFunctionCodes = [3, 4, 6, 16]; + private readonly readFunctionCodes = [1, 2, 3, 4]; + private readonly bitsFunctionCodes = [...this.readFunctionCodes, ...this.writeFunctionCodes]; + + private onChange: (value: ModbusValue) => void; + private onTouched: () => void; + + private destroy$ = new Subject(); + + constructor(private fb: FormBuilder) { + this.rpcParametersFormGroup = this.fb.group({ + tag: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + type: [ModbusDataType.BYTES, [Validators.required]], + functionCode: [this.defaultFunctionCodes[0], [Validators.required]], + value: [{value: '', disabled: true}, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + address: [null, [Validators.required]], + objectsCount: [1, [Validators.required]], + }); + + this.updateFunctionCodes(this.rpcParametersFormGroup.get('type').value); + this.observeValueChanges(); + this.observeKeyDataType(); + this.observeFunctionCode(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + registerOnChange(fn: (value: ModbusValue) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + validate(): ValidationErrors | null { + return this.rpcParametersFormGroup.valid ? null : { + rpcParametersFormGroup: { valid: false } + }; + } + + writeValue(value: ModbusValue): void { + this.rpcParametersFormGroup.patchValue(value, {emitEvent: false}); + } + + private observeValueChanges(): void { + this.rpcParametersFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + this.onChange(value); + this.onTouched(); + }); + } + + private observeKeyDataType(): void { + this.rpcParametersFormGroup.get('type').valueChanges.pipe(takeUntil(this.destroy$)).subscribe(dataType => { + if (!this.ModbusEditableDataTypes.includes(dataType)) { + this.rpcParametersFormGroup.get('objectsCount').patchValue(ModbusObjectCountByDataType[dataType], {emitEvent: false}); + } + this.updateFunctionCodes(dataType); + }); + } + + private observeFunctionCode(): void { + this.rpcParametersFormGroup.get('functionCode').valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(code => this.updateValueEnabling(code)); + } + + private updateValueEnabling(code: number): void { + if (this.writeFunctionCodes.includes(code)) { + this.rpcParametersFormGroup.get('value').enable({emitEvent: false}); + } else { + this.rpcParametersFormGroup.get('value').setValue(null); + this.rpcParametersFormGroup.get('value').disable({emitEvent: false}); + } + } + + private updateFunctionCodes(dataType: ModbusDataType): void { + this.functionCodes = dataType === ModbusDataType.BITS ? this.bitsFunctionCodes : this.defaultFunctionCodes; + if (!this.functionCodes.includes(this.rpcParametersFormGroup.get('functionCode').value)) { + this.rpcParametersFormGroup.get('functionCode').patchValue(this.functionCodes[0], {emitEvent: false}); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-security-config/modbus-security-config.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-security-config/modbus-security-config.component.html new file mode 100644 index 0000000000..3fc3e33ad0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-security-config/modbus-security-config.component.html @@ -0,0 +1,66 @@ + +
+
{{ 'gateway.hints.path-in-os' | translate }}
+
+
+ gateway.client-cert-path +
+
+ + + +
+
+
+
+ gateway.private-key-path +
+
+ + + +
+
+
+
gateway.password
+
+ + +
+ +
+
+
+
+
+
gateway.server-hostname
+
+ + + +
+
+
+ + + {{ 'gateway.request-client-certificate' | translate }} + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-security-config/modbus-security-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-security-config/modbus-security-config.component.ts new file mode 100644 index 0000000000..bc40727b55 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-security-config/modbus-security-config.component.ts @@ -0,0 +1,163 @@ +/// +/// 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 { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + forwardRef, + Input, + OnChanges, + OnDestroy +} from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormGroup, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { + ModbusSecurity, + noLeadTrailSpacesRegex, +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { SharedModule } from '@shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +@Component({ + selector: 'tb-modbus-security-config', + templateUrl: './modbus-security-config.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ModbusSecurityConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ModbusSecurityConfigComponent), + multi: true + } + ], + standalone: true, + imports: [ + CommonModule, + SharedModule, + ] +}) +export class ModbusSecurityConfigComponent implements ControlValueAccessor, Validator, OnChanges, OnDestroy { + + @coerceBoolean() + @Input() isMaster = false; + + securityConfigFormGroup: UntypedFormGroup; + + private disabled = false; + + private onChange: (value: ModbusSecurity) => void; + private onTouched: () => void; + + private destroy$ = new Subject(); + + constructor(private fb: FormBuilder, private cdr: ChangeDetectorRef) { + this.securityConfigFormGroup = this.fb.group({ + certfile: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], + keyfile: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], + password: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], + server_hostname: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], + reqclicert: [{value: false, disabled: true}], + }); + + this.observeValueChanges(); + } + + ngOnChanges(): void { + this.updateMasterEnabling(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + registerOnChange(fn: (value: ModbusSecurity) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.securityConfigFormGroup.disable({emitEvent: false}); + } else { + this.securityConfigFormGroup.enable({emitEvent: false}); + } + this.updateMasterEnabling(); + this.cdr.markForCheck(); + } + + validate(): ValidationErrors | null { + return this.securityConfigFormGroup.valid ? null : { + securityConfigFormGroup: { valid: false } + }; + } + + writeValue(securityConfig: ModbusSecurity): void { + const { certfile, password, keyfile, server_hostname } = securityConfig; + const securityState = { + certfile: certfile ?? '', + password: password ?? '', + keyfile: keyfile ?? '', + server_hostname: server_hostname ?? '', + reqclicert: !!securityConfig.reqclicert, + }; + + this.securityConfigFormGroup.reset(securityState, {emitEvent: false}); + } + + private updateMasterEnabling(): void { + if (this.isMaster) { + if (!this.disabled) { + this.securityConfigFormGroup.get('reqclicert').enable({emitEvent: false}); + } + this.securityConfigFormGroup.get('server_hostname').disable({emitEvent: false}); + } else { + if (!this.disabled) { + this.securityConfigFormGroup.get('server_hostname').enable({emitEvent: false}); + } + this.securityConfigFormGroup.get('reqclicert').disable({emitEvent: false}); + } + } + + private observeValueChanges(): void { + this.securityConfigFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value: ModbusSecurity) => { + this.onChange(value); + this.onTouched(); + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-config/modbus-slave-config.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-config/modbus-slave-config.component.html new file mode 100644 index 0000000000..9360ef499a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-config/modbus-slave-config.component.html @@ -0,0 +1,264 @@ + +
+
+
+
gateway.server-slave-config
+ + {{ ModbusProtocolLabelsMap.get(type) }} + +
+
+
+
gateway.host
+
+ + + + warning + + +
+
+
+
gateway.port
+
+ + + + warning + + +
+
+ +
+
gateway.port
+
+ + + + warning + + +
+
+
+
+
+ gateway.method +
+
+ + + {{ ModbusMethodLabelsMap.get(method) }} + + +
+
+
+
+
gateway.unit-id
+
+ + + + warning + + +
+
+
+
gateway.device-name
+
+ + + + warning + + +
+
+
+
gateway.device-profile
+
+ + + + warning + + +
+
+
+
+ + gateway.poll-period + +
+
+ + + +
+
+
+
gateway.baudrate
+
+ + + {{ rate }} + + +
+
+
+ + + {{ 'gateway.send-data-TB' | translate }} + + +
+
+ + + +
gateway.advanced-connection-settings
+
+
+
+
+
gateway.byte-order
+
+ + + {{ order }} + + +
+
+
+ + + + + + {{ 'gateway.tls-connection' | translate }} + + + + + + +
+ +
+
gateway.vendor-name
+
+ + + +
+
+
+
gateway.product-code
+
+ + + +
+
+
+
gateway.vendor-url
+
+ + + +
+
+
+
gateway.product-name
+
+ + + +
+
+
+
gateway.model-name
+
+ + + +
+
+
+
+
+
+
+
gateway.values
+ +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-config/modbus-slave-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-config/modbus-slave-config.component.ts new file mode 100644 index 0000000000..5f5b4d293c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-config/modbus-slave-config.component.ts @@ -0,0 +1,280 @@ +/// +/// 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 { ChangeDetectionStrategy, Component, forwardRef, OnDestroy } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormControl, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormGroup, + ValidationErrors, + Validator, + Validators, +} from '@angular/forms'; +import { + ModbusBaudrates, + ModbusMethodLabelsMap, + ModbusMethodType, + ModbusOrderType, + ModbusProtocolLabelsMap, + ModbusProtocolType, + ModbusRegisterValues, + ModbusSerialMethodType, + ModbusSlave, + noLeadTrailSpacesRegex, + PortLimits, + SlaveConfig, +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { SharedModule } from '@shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { GatewayPortTooltipPipe } from '@home/components/widget/lib/gateway/pipes/gateway-port-tooltip.pipe'; +import { ModbusSecurityConfigComponent } from '../modbus-security-config/modbus-security-config.component'; +import { ModbusValuesComponent, } from '../modbus-values/modbus-values.component'; +import { isEqual } from '@core/utils'; + +@Component({ + selector: 'tb-modbus-slave-config', + templateUrl: './modbus-slave-config.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ModbusSlaveConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ModbusSlaveConfigComponent), + multi: true + } + ], + standalone: true, + imports: [ + CommonModule, + SharedModule, + ModbusValuesComponent, + ModbusSecurityConfigComponent, + GatewayPortTooltipPipe, + ], +}) +export class ModbusSlaveConfigComponent implements ControlValueAccessor, Validator, OnDestroy { + + slaveConfigFormGroup: UntypedFormGroup; + showSecurityControl: FormControl; + ModbusProtocolLabelsMap = ModbusProtocolLabelsMap; + ModbusMethodLabelsMap = ModbusMethodLabelsMap; + portLimits = PortLimits; + + readonly modbusProtocolTypes = Object.values(ModbusProtocolType); + readonly modbusMethodTypes = Object.values(ModbusMethodType); + readonly modbusSerialMethodTypes = Object.values(ModbusSerialMethodType); + readonly modbusOrderType = Object.values(ModbusOrderType); + readonly ModbusProtocolType = ModbusProtocolType; + readonly modbusBaudrates = ModbusBaudrates; + + private isSlaveEnabled = false; + private readonly serialSpecificControlKeys = ['serialPort', 'baudrate']; + private readonly tcpUdpSpecificControlKeys = ['port', 'security', 'host']; + + private onChange: (value: SlaveConfig) => void; + private onTouched: () => void; + + private destroy$ = new Subject(); + + constructor(private fb: FormBuilder) { + this.showSecurityControl = this.fb.control(false); + this.slaveConfigFormGroup = this.fb.group({ + type: [ModbusProtocolType.TCP], + host: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + port: [null, [Validators.required, Validators.min(PortLimits.MIN), Validators.max(PortLimits.MAX)]], + serialPort: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + method: [ModbusMethodType.SOCKET], + unitId: [null, [Validators.required]], + baudrate: [this.modbusBaudrates[0]], + deviceName: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + deviceType: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + pollPeriod: [5000, [Validators.required]], + sendDataToThingsBoard: [false], + byteOrder:[ModbusOrderType.BIG], + security: [], + identity: this.fb.group({ + vendorName: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], + productCode: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], + vendorUrl: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], + productName: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], + modelName: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], + }), + values: [], + }); + + this.observeValueChanges(); + this.observeTypeChange(); + this.observeShowSecurity(); + } + + get protocolType(): ModbusProtocolType { + return this.slaveConfigFormGroup.get('type').value; + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + registerOnChange(fn: (value: SlaveConfig) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + validate(): ValidationErrors | null { + return this.slaveConfigFormGroup.valid ? null : { + slaveConfigFormGroup: { valid: false } + }; + } + + writeValue(slaveConfig: ModbusSlave): void { + this.showSecurityControl.patchValue(!!slaveConfig.security && !isEqual(slaveConfig.security, {})); + this.updateSlaveConfig(slaveConfig); + } + + setDisabledState(isDisabled: boolean): void { + this.isSlaveEnabled = !isDisabled; + this.updateFormEnableState(); + } + + private observeValueChanges(): void { + this.slaveConfigFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value: SlaveConfig) => { + if (value.type === ModbusProtocolType.Serial) { + value.port = value.serialPort; + delete value.serialPort; + } + this.onChange(value); + this.onTouched(); + }); + } + + private observeTypeChange(): void { + this.slaveConfigFormGroup.get('type').valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(type => { + this.updateFormEnableState(); + this.updateMethodType(type); + }); + } + + private updateMethodType(type: ModbusProtocolType): void { + if (this.slaveConfigFormGroup.get('method').value !== ModbusMethodType.RTU) { + this.slaveConfigFormGroup.get('method').patchValue( + type === ModbusProtocolType.Serial + ? ModbusSerialMethodType.ASCII + : ModbusMethodType.SOCKET, + {emitEvent: false} + ); + } + } + + private updateFormEnableState(): void { + if (this.isSlaveEnabled) { + this.slaveConfigFormGroup.enable({emitEvent: false}); + this.showSecurityControl.enable({emitEvent: false}); + } else { + this.slaveConfigFormGroup.disable({emitEvent: false}); + this.showSecurityControl.disable({emitEvent: false}); + } + this.updateEnablingByProtocol(); + this.updateSecurityEnable(this.showSecurityControl.value); + } + + private observeShowSecurity(): void { + this.showSecurityControl.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(value => this.updateSecurityEnable(value)); + } + + private updateSecurityEnable(securityEnabled: boolean): void { + if (securityEnabled && this.isSlaveEnabled && this.protocolType !== ModbusProtocolType.Serial) { + this.slaveConfigFormGroup.get('security').enable({emitEvent: false}); + } else { + this.slaveConfigFormGroup.get('security').disable({emitEvent: false}); + } + } + + private updateEnablingByProtocol(): void { + const isSerial = this.protocolType === ModbusProtocolType.Serial; + const enableKeys = isSerial ? this.serialSpecificControlKeys : this.tcpUdpSpecificControlKeys; + const disableKeys = isSerial ? this.tcpUdpSpecificControlKeys : this.serialSpecificControlKeys; + + if (this.isSlaveEnabled) { + enableKeys.forEach(key => this.slaveConfigFormGroup.get(key)?.enable({ emitEvent: false })); + } + + disableKeys.forEach(key => this.slaveConfigFormGroup.get(key)?.disable({ emitEvent: false })); + } + + private updateSlaveConfig(slaveConfig: ModbusSlave): void { + const { + type = ModbusProtocolType.TCP, + method = ModbusMethodType.RTU, + unitId = 0, + deviceName = '', + deviceType = '', + pollPeriod = 5000, + sendDataToThingsBoard = false, + byteOrder = ModbusOrderType.BIG, + security = {}, + identity = { + vendorName: '', + productCode: '', + vendorUrl: '', + productName: '', + modelName: '', + }, + values = {} as ModbusRegisterValues, + baudrate = this.modbusBaudrates[0], + host = '', + port = null, + } = slaveConfig; + + const slaveState: ModbusSlave = { + type, + method, + unitId, + deviceName, + deviceType, + pollPeriod, + sendDataToThingsBoard: !!sendDataToThingsBoard, + byteOrder, + security, + identity, + values, + baudrate, + host: type === ModbusProtocolType.Serial ? '' : host, + port: type === ModbusProtocolType.Serial ? null : port, + serialPort: (type === ModbusProtocolType.Serial ? port : '') as string, + }; + + this.slaveConfigFormGroup.setValue(slaveState, { emitEvent: false }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.html new file mode 100644 index 0000000000..1cf95ad06e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.html @@ -0,0 +1,366 @@ + +
+ +

{{ 'gateway.server-slave' | translate }}

+ +
+ +
+
+
+
gateway.name
+
+ + + + warning + + +
+
+
+
+
+
gateway.server-connection
+ + {{ ModbusProtocolLabelsMap.get(type) }} + +
+
+
+
gateway.host
+
+ + + + warning + + +
+
+
+
gateway.port
+
+ + + + warning + + +
+
+ +
+
gateway.port
+
+ + + + warning + + +
+
+
+
+
+ gateway.method +
+
+ + + {{ ModbusMethodLabelsMap.get(method) }} + + +
+
+
+ +
+
gateway.baudrate
+
+ + + {{ rate }} + + +
+
+
+
gateway.bytesize
+
+ + + {{ size }} + + +
+
+
+
gateway.stopbits
+
+ + + +
+
+
+
gateway.parity
+
+ + + {{ ModbusParityLabelsMap.get(parity) }} + + +
+
+
+ + + {{ 'gateway.strict' | translate }} + + +
+
+
+
gateway.unit-id
+
+ + + + warning + + +
+
+
+
gateway.device-name
+
+ + + + warning + + +
+
+
+
gateway.device-profile
+
+ + + + warning + + +
+
+
+ + + {{ 'gateway.send-data-on-change' | translate }} + + +
+
+ + + +
gateway.advanced-connection-settings
+
+
+
+
+
gateway.connection-timeout
+
+ + + +
+
+
+
gateway.byte-order
+
+ + + {{ order }} + + +
+
+
+
gateway.word-order
+
+ + + {{ order }} + + +
+
+
+ + + + + + {{ 'gateway.tls-connection' | translate }} + + + + + + +
+
+ + + {{ 'gateway.retries' | translate }} + + +
+
+ + + {{ 'gateway.retries-on-empty' | translate }} + + +
+
+ + + {{ 'gateway.retries-on-invalid' | translate }} + + +
+
+
+ + gateway.poll-period + +
+
+ + + +
+
+
+
gateway.connect-attempt-time
+
+ + + +
+
+
+
gateway.connect-attempt-count
+
+ + + +
+
+
+
gateway.wait-after-failed-attempts
+
+ + + +
+
+
+
+
+
+ +
+
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.scss new file mode 100644 index 0000000000..0c68b6af9e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.scss @@ -0,0 +1,36 @@ +/** + * 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 { + .slaves-config-container { + width: 80vw; + max-width: 900px; + } + + .slave-name-label { + margin-right: 16px; + color: rgba(0, 0, 0, 0.87); + } + + .fixed-title-width-260 { + min-width: 260px; + } + + ::ng-deep.security-config { + .fixed-title-width { + min-width: 230px; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.ts new file mode 100644 index 0000000000..5568a0e34c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.ts @@ -0,0 +1,244 @@ +/// +/// 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 { ChangeDetectionStrategy, Component, forwardRef, Inject, OnDestroy } from '@angular/core'; +import { + FormBuilder, + FormControl, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormGroup, + Validators, +} from '@angular/forms'; +import { + ModbusBaudrates, + ModbusByteSizes, + ModbusMethodLabelsMap, + ModbusMethodType, + ModbusOrderType, + ModbusParity, + ModbusParityLabelsMap, + ModbusProtocolLabelsMap, + ModbusProtocolType, + ModbusSerialMethodType, + ModbusSlaveInfo, + noLeadTrailSpacesRegex, + PortLimits, + SlaveConfig, +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { SharedModule } from '@shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { Subject } from 'rxjs'; +import { ModbusValuesComponent } from '../modbus-values/modbus-values.component'; +import { ModbusSecurityConfigComponent } from '../modbus-security-config/modbus-security-config.component'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { GatewayPortTooltipPipe } from '@home/components/widget/lib/gateway/pipes/gateway-port-tooltip.pipe'; +import { takeUntil } from 'rxjs/operators'; +import { isEqual } from '@core/utils'; +import { helpBaseUrl } from '@shared/models/constants'; + +@Component({ + selector: 'tb-modbus-slave-dialog', + templateUrl: './modbus-slave-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ModbusSlaveDialogComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ModbusSlaveDialogComponent), + multi: true + } + ], + standalone: true, + imports: [ + CommonModule, + SharedModule, + ModbusValuesComponent, + ModbusSecurityConfigComponent, + GatewayPortTooltipPipe, + ], + styleUrls: ['./modbus-slave-dialog.component.scss'], +}) +export class ModbusSlaveDialogComponent extends DialogComponent implements OnDestroy { + + slaveConfigFormGroup: UntypedFormGroup; + showSecurityControl: FormControl; + portLimits = PortLimits; + + readonly modbusProtocolTypes = Object.values(ModbusProtocolType); + readonly modbusMethodTypes = Object.values(ModbusMethodType); + readonly modbusSerialMethodTypes = Object.values(ModbusSerialMethodType); + readonly modbusParities = Object.values(ModbusParity); + readonly modbusByteSizes = ModbusByteSizes; + readonly modbusBaudrates = ModbusBaudrates; + readonly modbusOrderType = Object.values(ModbusOrderType); + readonly ModbusProtocolType = ModbusProtocolType; + readonly ModbusParityLabelsMap = ModbusParityLabelsMap; + readonly ModbusProtocolLabelsMap = ModbusProtocolLabelsMap; + readonly ModbusMethodLabelsMap = ModbusMethodLabelsMap; + readonly modbusHelpLink = + helpBaseUrl + '/docs/iot-gateway/config/modbus/#section-master-description-and-configuration-parameters'; + + private readonly serialSpecificControlKeys = ['serialPort', 'baudrate', 'stopbits', 'bytesize', 'parity', 'strict']; + private readonly tcpUdpSpecificControlKeys = ['port', 'security', 'host', 'wordOrder']; + + private destroy$ = new Subject(); + + constructor( + private fb: FormBuilder, + protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: ModbusSlaveInfo, + public dialogRef: MatDialogRef, + ) { + super(store, router, dialogRef); + + this.showSecurityControl = this.fb.control(false); + this.initializeSlaveFormGroup(); + this.updateSlaveFormGroup(); + this.updateControlsEnabling(this.data.value.type); + this.observeTypeChange(); + this.observeShowSecurity(); + this.showSecurityControl.patchValue(!!this.data.value.security && !isEqual(this.data.value.security, {})); + } + + get protocolType(): ModbusProtocolType { + return this.slaveConfigFormGroup.get('type').value; + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + cancel(): void { + this.dialogRef.close(null); + } + + add(): void { + if (!this.slaveConfigFormGroup.valid) { + return; + } + + const { values, type, serialPort, ...rest } = this.slaveConfigFormGroup.value; + const slaveResult = { ...rest, type, ...values }; + + if (type === ModbusProtocolType.Serial) { + slaveResult.port = serialPort; + } + + this.dialogRef.close(slaveResult); + } + + private initializeSlaveFormGroup(): void { + this.slaveConfigFormGroup = this.fb.group({ + name: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + type: [ModbusProtocolType.TCP], + host: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + port: [null, [Validators.required, Validators.min(PortLimits.MIN), Validators.max(PortLimits.MAX)]], + serialPort: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + method: [ModbusMethodType.SOCKET, [Validators.required]], + baudrate: [this.modbusBaudrates[0]], + stopbits: [1], + bytesize: [ModbusByteSizes[0]], + parity: [ModbusParity.None], + strict: [true], + unitId: [null, [Validators.required]], + deviceName: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + deviceType: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + sendDataOnlyOnChange: [false], + timeout: [35], + byteOrder: [ModbusOrderType.BIG], + wordOrder: [ModbusOrderType.BIG], + retries: [true], + retryOnEmpty: [true], + retryOnInvalid: [true], + pollPeriod: [5000, [Validators.required]], + connectAttemptTimeMs: [5000, [Validators.required]], + connectAttemptCount: [5, [Validators.required]], + waitAfterFailedAttemptsMs: [300000, [Validators.required]], + values: [{}], + security: [{}], + }); + } + + private updateSlaveFormGroup(): void { + this.slaveConfigFormGroup.patchValue({ + ...this.data.value, + port: this.data.value.type === ModbusProtocolType.Serial ? null : this.data.value.port, + serialPort: this.data.value.type === ModbusProtocolType.Serial ? this.data.value.port : '', + values: { + attributes: this.data.value.attributes ?? [], + timeseries: this.data.value.timeseries ?? [], + attributeUpdates: this.data.value.attributeUpdates ?? [], + rpc: this.data.value.rpc ?? [], + } + }); + } + + private observeTypeChange(): void { + this.slaveConfigFormGroup.get('type').valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(type => { + this.updateControlsEnabling(type); + this.updateMethodType(type); + }); + } + + private updateMethodType(type: ModbusProtocolType): void { + if (this.slaveConfigFormGroup.get('method').value !== ModbusMethodType.RTU) { + this.slaveConfigFormGroup.get('method').patchValue( + type === ModbusProtocolType.Serial + ? ModbusSerialMethodType.ASCII + : ModbusMethodType.SOCKET, + {emitEvent: false} + ); + } + } + + private updateControlsEnabling(type: ModbusProtocolType): void { + const [enableKeys, disableKeys] = type === ModbusProtocolType.Serial + ? [this.serialSpecificControlKeys, this.tcpUdpSpecificControlKeys] + : [this.tcpUdpSpecificControlKeys, this.serialSpecificControlKeys]; + + enableKeys.forEach(key => this.slaveConfigFormGroup.get(key)?.enable({ emitEvent: false })); + disableKeys.forEach(key => this.slaveConfigFormGroup.get(key)?.disable({ emitEvent: false })); + + this.updateSecurityEnabling(this.showSecurityControl.value); + } + + private observeShowSecurity(): void { + this.showSecurityControl.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(value => this.updateSecurityEnabling(value)); + } + + private updateSecurityEnabling(isEnabled: boolean): void { + if (isEnabled && this.protocolType !== ModbusProtocolType.Serial) { + this.slaveConfigFormGroup.get('security').enable({emitEvent: false}); + } else { + this.slaveConfigFormGroup.get('security').disable({emitEvent: false}); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-values/modbus-values.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-values/modbus-values.component.html new file mode 100644 index 0000000000..a6b4382638 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-values/modbus-values.component.html @@ -0,0 +1,121 @@ + + + +
+ +
+
+ + + + +
+ +
+
+
+
+ + +
+
gateway.attributes
+
+ + + {{ attribute.tag }} + + + + + + +
+
+
+
gateway.timeseries
+
+ + + {{ telemetry.tag }} + + + + + + +
+
+
+
gateway.attribute-updates
+
+ + + {{ attributeUpdate.tag }} + + + + + + +
+
+
+
gateway.rpc-requests
+
+ + + {{ rpcRequest.tag }} + + + + + + +
+
+
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-values/modbus-values.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-values/modbus-values.component.scss new file mode 100644 index 0000000000..e80998b88a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-values/modbus-values.component.scss @@ -0,0 +1,23 @@ +/** + * 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 ::ng-deep .mat-mdc-tab-body-wrapper { + min-height: 320px; +} + +::ng-deep .mdc-evolution-chip-set__chips { + align-items: center; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-values/modbus-values.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-values/modbus-values.component.ts new file mode 100644 index 0000000000..6bfc07c130 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-values/modbus-values.component.ts @@ -0,0 +1,236 @@ +/// +/// 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 { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + forwardRef, + Input, + OnDestroy, + OnInit, + Renderer2, + ViewContainerRef +} from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, +} from '@angular/forms'; +import { + ModbusKeysAddKeyTranslationsMap, + ModbusKeysDeleteKeyTranslationsMap, + ModbusKeysNoKeysTextTranslationsMap, + ModbusKeysPanelTitleTranslationsMap, + ModbusRegisterTranslationsMap, + ModbusRegisterType, + ModbusRegisterValues, + ModbusValue, + ModbusValueKey, + ModbusValues, + ModbusValuesState, +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { SharedModule } from '@shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { EllipsisChipListDirective } from '@shared/directives/ellipsis-chip-list.directive'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { ModbusDataKeysPanelComponent } from '../modbus-data-keys-panel/modbus-data-keys-panel.component'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +@Component({ + selector: 'tb-modbus-values', + templateUrl: './modbus-values.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ModbusValuesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ModbusValuesComponent), + multi: true + } + ], + standalone: true, + imports: [ + CommonModule, + SharedModule, + EllipsisChipListDirective, + ], + styleUrls: ['./modbus-values.component.scss'] +}) + +export class ModbusValuesComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy { + + @coerceBoolean() + @Input() singleMode = false; + + disabled = false; + modbusRegisterTypes: ModbusRegisterType[] = Object.values(ModbusRegisterType); + modbusValueKeys = Object.values(ModbusValueKey); + ModbusValuesTranslationsMap = ModbusRegisterTranslationsMap; + ModbusValueKey = ModbusValueKey; + valuesFormGroup: FormGroup; + + private onChange: (value: ModbusValuesState) => void; + private onTouched: () => void; + + private destroy$ = new Subject(); + + constructor(private fb: FormBuilder, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private cdr: ChangeDetectorRef, + ) {} + + ngOnInit() { + this.initializeValuesFormGroup(); + this.observeValuesChanges(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + registerOnChange(fn: (value: ModbusValuesState) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + writeValue(values: ModbusValuesState): void { + if (this.singleMode) { + this.valuesFormGroup.setValue(this.getSingleRegisterState(values as ModbusValues), { emitEvent: false }); + } else { + const { holding_registers, coils_initializer, input_registers, discrete_inputs } = values as ModbusRegisterValues; + this.valuesFormGroup.setValue({ + holding_registers: this.getSingleRegisterState(holding_registers), + coils_initializer: this.getSingleRegisterState(coils_initializer), + input_registers: this.getSingleRegisterState(input_registers), + discrete_inputs: this.getSingleRegisterState(discrete_inputs), + }, { emitEvent: false }); + } + this.cdr.markForCheck(); + } + + validate(): ValidationErrors | null { + return this.valuesFormGroup.valid ? null : { + valuesFormGroup: {valid: false} + }; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + this.cdr.markForCheck(); + } + + getValueGroup(valueKey: ModbusValueKey, register?: ModbusRegisterType): FormGroup { + return register + ? this.valuesFormGroup.get(register).get(valueKey) as FormGroup + : this.valuesFormGroup.get(valueKey) as FormGroup; + } + + manageKeys($event: Event, matButton: MatButton, keysType: ModbusValueKey, register?: ModbusRegisterType): void { + $event.stopPropagation(); + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + return; + } + + const keysControl = this.getValueGroup(keysType, register); + const ctx = { + values: keysControl.value, + isMaster: !this.singleMode, + keysType, + panelTitle: ModbusKeysPanelTitleTranslationsMap.get(keysType), + addKeyTitle: ModbusKeysAddKeyTranslationsMap.get(keysType), + deleteKeyTitle: ModbusKeysDeleteKeyTranslationsMap.get(keysType), + noKeysText: ModbusKeysNoKeysTextTranslationsMap.get(keysType) + }; + const dataKeysPanelPopover = this.popoverService.displayPopover( + trigger, + this.renderer, + this.viewContainerRef, + ModbusDataKeysPanelComponent, + 'leftBottom', + false, + null, + ctx, + {}, + {}, + {}, + true + ); + dataKeysPanelPopover.tbComponentRef.instance.popover = dataKeysPanelPopover; + dataKeysPanelPopover.tbComponentRef.instance.keysDataApplied.pipe(takeUntil(this.destroy$)).subscribe((keysData: ModbusValue[]) => { + dataKeysPanelPopover.hide(); + keysControl.patchValue(keysData); + keysControl.markAsDirty(); + this.cdr.markForCheck(); + }); + } + + private initializeValuesFormGroup(): void { + const getValuesFormGroup = () => this.fb.group(this.modbusValueKeys.reduce((acc, key) => { + acc[key] = this.fb.control([[], []]); + return acc; + }, {})); + + if (this.singleMode) { + this.valuesFormGroup = getValuesFormGroup(); + } else { + this.valuesFormGroup = this.fb.group( + this.modbusRegisterTypes.reduce((registersAcc, register) => { + registersAcc[register] = getValuesFormGroup(); + return registersAcc; + }, {}) + ); + } + } + + + private observeValuesChanges(): void { + this.valuesFormGroup.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(value => { + this.onChange(value); + this.onTouched(); + }); + } + + private getSingleRegisterState(values: ModbusValues): ModbusValues { + return { + attributes: values?.attributes ?? [], + timeseries: values?.timeseries ?? [], + attributeUpdates: values?.attributeUpdates ?? [], + rpc: values?.rpc ?? [], + }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.html new file mode 100644 index 0000000000..9849923218 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.html @@ -0,0 +1,41 @@ + + + + + + + + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.scss new file mode 100644 index 0000000000..fd799d8aa4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.scss @@ -0,0 +1,23 @@ +/** + * 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 { + height: 100%; +} +:host ::ng-deep { + .mat-mdc-tab-group, .mat-mdc-tab-body-wrapper { + height: 100%; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.ts new file mode 100644 index 0000000000..25f8671201 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.ts @@ -0,0 +1,191 @@ +/// +/// 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 { ChangeDetectionStrategy, Component, forwardRef, Input, OnDestroy, TemplateRef } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, +} from '@angular/forms'; +import { + MappingType, + MQTTBasicConfig, + RequestMappingData, + RequestType, +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { SharedModule } from '@shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { isObject } from 'lodash'; +import { + SecurityConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component'; +import { + WorkersConfigControlComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component'; +import { + BrokerConfigControlComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component'; +import { + MappingTableComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component'; +import { isDefinedAndNotNull } from '@core/utils'; + +@Component({ + selector: 'tb-mqtt-basic-config', + templateUrl: './mqtt-basic-config.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MqttBasicConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MqttBasicConfigComponent), + multi: true + } + ], + standalone: true, + imports: [ + CommonModule, + SharedModule, + SecurityConfigComponent, + WorkersConfigControlComponent, + BrokerConfigControlComponent, + MappingTableComponent, + ], + styleUrls: ['./mqtt-basic-config.component.scss'] +}) + +export class MqttBasicConfigComponent implements ControlValueAccessor, Validator, OnDestroy { + + @Input() + generalTabContent: TemplateRef; + + mappingTypes = MappingType; + basicFormGroup: FormGroup; + + private onChange: (value: MQTTBasicConfig) => void; + private onTouched: () => void; + + private destroy$ = new Subject(); + + constructor(private fb: FormBuilder) { + this.basicFormGroup = this.fb.group({ + dataMapping: [], + requestsMapping: [], + broker: [], + workers: [], + }); + + this.basicFormGroup.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(value => { + this.onChange(this.getMappedMQTTConfig(value)); + this.onTouched(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + registerOnChange(fn: (value: MQTTBasicConfig) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + writeValue(basicConfig: MQTTBasicConfig): void { + const { broker, dataMapping = [], requestsMapping } = basicConfig; + const editedBase = { + workers: broker && (broker.maxNumberOfWorkers || broker.maxMessageNumberPerWorker) ? { + maxNumberOfWorkers: broker.maxNumberOfWorkers, + maxMessageNumberPerWorker: broker.maxMessageNumberPerWorker, + } : {}, + dataMapping: dataMapping || [], + broker: broker || {}, + requestsMapping: Array.isArray(requestsMapping) + ? requestsMapping + : this.getRequestDataArray(requestsMapping), + }; + + this.basicFormGroup.setValue(editedBase, {emitEvent: false}); + } + + private getMappedMQTTConfig(basicConfig: MQTTBasicConfig): MQTTBasicConfig { + let { broker, workers, dataMapping, requestsMapping } = basicConfig || {}; + + if (isDefinedAndNotNull(workers.maxNumberOfWorkers) || isDefinedAndNotNull(workers.maxMessageNumberPerWorker)) { + broker = { + ...broker, + ...workers, + }; + } + + if ((requestsMapping as RequestMappingData[])?.length) { + requestsMapping = this.getRequestDataObject(requestsMapping as RequestMappingData[]); + } + + return { broker, workers, dataMapping, requestsMapping }; + } + + validate(): ValidationErrors | null { + return this.basicFormGroup.valid ? null : { + basicFormGroup: {valid: false} + }; + } + + private getRequestDataArray(value: Record): RequestMappingData[] { + const mappingConfigs = []; + + if (isObject(value)) { + Object.keys(value).forEach((configKey: string) => { + for (const mapping of value[configKey]) { + mappingConfigs.push({ + requestType: configKey, + requestValue: mapping + }); + } + }); + } + + return mappingConfigs; + } + + private getRequestDataObject(array: RequestMappingData[]): Record { + return array.reduce((result, { requestType, requestValue }) => { + result[requestType].push(requestValue); + return result; + }, { + connectRequests: [], + disconnectRequests: [], + attributeRequests: [], + attributeUpdates: [], + serverSideRpc: [], + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.html new file mode 100644 index 0000000000..584679fa2c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.html @@ -0,0 +1,146 @@ + +
+
+
gateway.server-url
+
+ + + + warning + + +
+
+
+
+
{{ 'gateway.timeout' | translate }}
+
+
+ + + + warning + + +
+
+
+
gateway.security
+
+ + + {{ version.name }} + + +
+
+
+
+
{{ 'gateway.scan-period' | translate }}
+
+
+ + + + warning + + +
+
+
+
+
{{ 'gateway.poll-period' | translate }}
+
+
+ + + + warning + + +
+
+
+
+
{{ 'gateway.sub-check-period' | translate }}
+
+
+ + + + warning + + +
+
+
+ + +
{{ 'gateway.enable-subscription' | translate }}
+
+
+
+
+ + + {{ 'gateway.show-map' | translate }} + + +
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.scss diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.ts new file mode 100644 index 0000000000..909898c954 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.ts @@ -0,0 +1,142 @@ +/// +/// 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 { ChangeDetectionStrategy, Component, forwardRef, OnDestroy } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormGroup, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { + noLeadTrailSpacesRegex, + SecurityPolicy, + SecurityPolicyTypes, + ServerConfig +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { SharedModule } from '@shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { + SecurityConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component'; +import { HOUR } from '@shared/models/time/time.models'; + +@Component({ + selector: 'tb-opc-server-config', + templateUrl: './opc-server-config.component.html', + styleUrls: ['./opc-server-config.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => OpcServerConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => OpcServerConfigComponent), + multi: true + } + ], + standalone: true, + imports: [ + CommonModule, + SharedModule, + SecurityConfigComponent, + ] +}) +export class OpcServerConfigComponent implements ControlValueAccessor, Validator, OnDestroy { + + securityPolicyTypes = SecurityPolicyTypes; + serverConfigFormGroup: UntypedFormGroup; + + onChange!: (value: string) => void; + onTouched!: () => void; + + private destroy$ = new Subject(); + + constructor(private fb: FormBuilder) { + this.serverConfigFormGroup = this.fb.group({ + name: ['', []], + url: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + timeoutInMillis: [1000, [Validators.required, Validators.min(1000)]], + scanPeriodInMillis: [HOUR, [Validators.required, Validators.min(1000)]], + pollPeriodInMillis: [5000, [Validators.required, Validators.min(50)]], + enableSubscriptions: [true, []], + subCheckPeriodInMillis: [100, [Validators.required, Validators.min(100)]], + showMap: [false, []], + security: [SecurityPolicy.BASIC128, []], + identity: [] + }); + + this.serverConfigFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + this.onChange(value); + this.onTouched(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + validate(): ValidationErrors | null { + return this.serverConfigFormGroup.valid ? null : { + serverConfigFormGroup: { valid: false } + }; + } + + writeValue(serverConfig: ServerConfig): void { + const { + timeoutInMillis = 1000, + scanPeriodInMillis = HOUR, + pollPeriodInMillis = 5000, + enableSubscriptions = true, + subCheckPeriodInMillis = 100, + showMap = false, + security = SecurityPolicy.BASIC128, + identity = {}, + } = serverConfig; + + this.serverConfigFormGroup.reset({ + ...serverConfig, + timeoutInMillis, + scanPeriodInMillis, + pollPeriodInMillis, + enableSubscriptions, + subCheckPeriodInMillis, + showMap, + security, + identity, + }, { emitEvent: false }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.html new file mode 100644 index 0000000000..5f8b25af9d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.html @@ -0,0 +1,30 @@ + + + + + + + + + +
+ +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.scss new file mode 100644 index 0000000000..fd799d8aa4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.scss @@ -0,0 +1,23 @@ +/** + * 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 { + height: 100%; +} +:host ::ng-deep { + .mat-mdc-tab-group, .mat-mdc-tab-body-wrapper { + height: 100%; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.ts new file mode 100644 index 0000000000..39198c9696 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.ts @@ -0,0 +1,134 @@ +/// +/// 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 { ChangeDetectionStrategy, Component, forwardRef, Input, OnDestroy, TemplateRef } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, +} from '@angular/forms'; +import { + ConnectorType, + MappingType, + OPCBasicConfig, +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { SharedModule } from '@shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { + SecurityConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component'; +import { + WorkersConfigControlComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component'; +import { + BrokerConfigControlComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component'; +import { + MappingTableComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component'; +import { + OpcServerConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component'; + +@Component({ + selector: 'tb-opc-ua-basic-config', + templateUrl: './opc-ua-basic-config.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => OpcUaBasicConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => OpcUaBasicConfigComponent), + multi: true + } + ], + standalone: true, + imports: [ + CommonModule, + SharedModule, + SecurityConfigComponent, + WorkersConfigControlComponent, + BrokerConfigControlComponent, + MappingTableComponent, + OpcServerConfigComponent, + ], + styleUrls: ['./opc-ua-basic-config.component.scss'] +}) + +export class OpcUaBasicConfigComponent implements ControlValueAccessor, Validator, OnDestroy { + @Input() generalTabContent: TemplateRef; + + mappingTypes = MappingType; + basicFormGroup: FormGroup; + + onChange!: (value: string) => void; + onTouched!: () => void; + + protected readonly connectorType = ConnectorType; + private destroy$ = new Subject(); + + constructor(private fb: FormBuilder) { + this.basicFormGroup = this.fb.group({ + mapping: [], + server: [], + }); + + this.basicFormGroup.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(value => { + this.onChange(value); + this.onTouched(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + writeValue(basicConfig: OPCBasicConfig): void { + const editedBase = { + server: basicConfig.server || {}, + mapping: basicConfig.mapping || [], + }; + + this.basicFormGroup.setValue(editedBase, {emitEvent: false}); + } + + validate(): ValidationErrors | null { + return this.basicFormGroup.valid ? null : { + basicFormGroup: {valid: false} + }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component.html similarity index 65% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component.html index 319b10f0b2..87976abf08 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component.html @@ -17,7 +17,7 @@ -->
-
gateway.security
+
{{ title | translate }}
{{ SecurityTypeTranslationsMap.get(type) | translate }} @@ -81,6 +81,48 @@
+ +
+
gateway.mode
+
+ + + + {{ type }} + + + +
+
+
+
gateway.username
+
+ + + + warning + + +
+
+
+
gateway.password
+
+ + +
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component.scss new file mode 100644 index 0000000000..416f368279 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component.scss @@ -0,0 +1,20 @@ +/** + * 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 { + width: 100%; + height: 100%; + display: block; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component.ts similarity index 51% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component.ts index 655545e5fb..18d1a96e38 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component.ts @@ -18,22 +18,12 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - ElementRef, forwardRef, - NgZone, + Input, OnDestroy, - ViewContainerRef + OnInit, } from '@angular/core'; -import { PageComponent } from '@shared/components/page.component'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { TranslateService } from '@ngx-translate/core'; -import { MatDialog } from '@angular/material/dialog'; -import { DialogService } from '@core/services/dialog.service'; import { Subject } from 'rxjs'; -import { Overlay } from '@angular/cdk/overlay'; -import { UtilsService } from '@core/services/utils.service'; -import { EntityService } from '@core/http/entity.service'; import { ControlValueAccessor, FormBuilder, @@ -41,124 +31,145 @@ import { NG_VALUE_ACCESSOR, UntypedFormGroup, ValidationErrors, - Validator, Validators } from '@angular/forms'; import { - BrokerSecurityType, - BrokerSecurityTypeTranslationsMap, - noLeadTrailSpacesRegex + SecurityType, + SecurityTypeTranslationsMap, + ModeType, + noLeadTrailSpacesRegex, + ConnectorSecurity } from '@home/components/widget/lib/gateway/gateway-widget.models'; import { takeUntil } from 'rxjs/operators'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { SharedModule } from '@shared/shared.module'; +import { CommonModule } from '@angular/common'; @Component({ - selector: 'tb-broker-security', - templateUrl: './broker-security.component.html', - styleUrls: ['./broker-security.component.scss'], + selector: 'tb-security-config', + templateUrl: './security-config.component.html', + styleUrls: ['./security-config.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, providers: [ { provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => BrokerSecurityComponent), + useExisting: forwardRef(() => SecurityConfigComponent), multi: true }, { provide: NG_VALIDATORS, - useExisting: forwardRef(() => BrokerSecurityComponent), + useExisting: forwardRef(() => SecurityConfigComponent), multi: true } + ], + standalone: true, + imports:[ + CommonModule, + SharedModule, ] }) -export class BrokerSecurityComponent extends PageComponent implements ControlValueAccessor, Validator, OnDestroy { +export class SecurityConfigComponent implements ControlValueAccessor, OnInit, OnDestroy { - BrokerSecurityType = BrokerSecurityType; + @Input() + title = 'gateway.security'; - securityTypes = Object.values(BrokerSecurityType); - - SecurityTypeTranslationsMap = BrokerSecurityTypeTranslationsMap; + @Input() + @coerceBoolean() + extendCertificatesModel = false; + BrokerSecurityType = SecurityType; + securityTypes = Object.values(SecurityType) as SecurityType[]; + modeTypes = Object.values(ModeType); + SecurityTypeTranslationsMap = SecurityTypeTranslationsMap; securityFormGroup: UntypedFormGroup; + private onChange: (value: string) => void; + private onTouched: () => void; + private destroy$ = new Subject(); - private propagateChange = (v: any) => {}; - - constructor(protected store: Store, - public translate: TranslateService, - public dialog: MatDialog, - private overlay: Overlay, - private viewContainerRef: ViewContainerRef, - private dialogService: DialogService, - private entityService: EntityService, - private utils: UtilsService, - private zone: NgZone, - private cd: ChangeDetectorRef, - private elementRef: ElementRef, - private fb: FormBuilder) { - super(store); + + constructor(private fb: FormBuilder, private cdr: ChangeDetectorRef) {} + + ngOnInit(): void { this.securityFormGroup = this.fb.group({ - type: [BrokerSecurityType.ANONYMOUS, []], + type: [SecurityType.ANONYMOUS, []], username: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], password: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], pathToCACert: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], pathToPrivateKey: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], pathToClientCert: ['', [Validators.pattern(noLeadTrailSpacesRegex)]] }); + if (this.extendCertificatesModel) { + this.securityFormGroup.addControl('mode', this.fb.control(ModeType.NONE, [])); + } this.securityFormGroup.valueChanges.pipe( takeUntil(this.destroy$) ).subscribe((value) => { - this.updateView(value); + this.onChange(value); + this.onTouched(); }); this.securityFormGroup.get('type').valueChanges.pipe( takeUntil(this.destroy$) - ).subscribe((type) => { - this.updateValidators(type); - }); + ).subscribe((type) => this.updateValidators(type)); } - ngOnDestroy() { + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); - super.ngOnDestroy(); } - registerOnChange(fn: any): void { - this.propagateChange = fn; - } - - registerOnTouched(fn: any): void {} - - writeValue(deviceInfo: any) { - if (!deviceInfo.type) { - deviceInfo.type = BrokerSecurityType.ANONYMOUS; + writeValue(securityInfo: ConnectorSecurity): void { + if (!securityInfo) { + const defaultSecurity = {type: SecurityType.ANONYMOUS}; + this.securityFormGroup.reset(defaultSecurity, {emitEvent: false}); + } else { + if (!securityInfo.type) { + securityInfo.type = SecurityType.ANONYMOUS; + } + this.securityFormGroup.reset(securityInfo, {emitEvent: false}); } - this.securityFormGroup.reset(deviceInfo); - this.updateView(deviceInfo); + this.cdr.markForCheck(); } validate(): ValidationErrors | null { - return this.securityFormGroup.valid ? null : { + return this.securityFormGroup.get('type').value !== SecurityType.BASIC || this.securityFormGroup.valid ? null : { securityForm: { valid: false } }; } - updateView(value: any) { - this.propagateChange(value); + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; } - private updateValidators(type) { + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + private updateValidators(type: SecurityType): void { if (type) { this.securityFormGroup.get('username').disable({emitEvent: false}); this.securityFormGroup.get('password').disable({emitEvent: false}); this.securityFormGroup.get('pathToCACert').disable({emitEvent: false}); this.securityFormGroup.get('pathToPrivateKey').disable({emitEvent: false}); this.securityFormGroup.get('pathToClientCert').disable({emitEvent: false}); - if (type === BrokerSecurityType.BASIC) { + this.securityFormGroup.get('mode')?.disable({emitEvent: false}); + if (type === SecurityType.BASIC) { this.securityFormGroup.get('username').enable({emitEvent: false}); this.securityFormGroup.get('password').enable({emitEvent: false}); - } else if (type === BrokerSecurityType.CERTIFICATES) { + } else if (type === SecurityType.CERTIFICATES) { this.securityFormGroup.get('pathToCACert').enable({emitEvent: false}); this.securityFormGroup.get('pathToPrivateKey').enable({emitEvent: false}); this.securityFormGroup.get('pathToClientCert').enable({emitEvent: false}); + if (this.extendCertificatesModel) { + const modeControl = this.securityFormGroup.get('mode'); + if (modeControl && !modeControl.value) { + modeControl.setValue(ModeType.NONE, {emitEvent: false}); + } + + modeControl?.enable({emitEvent: false}); + this.securityFormGroup.get('username').enable({emitEvent: false}); + this.securityFormGroup.get('password').enable({emitEvent: false}); + } } } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/type-value-panel/type-value-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/type-value-panel/type-value-panel.component.html new file mode 100644 index 0000000000..f72a2c18af --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/type-value-panel/type-value-panel.component.html @@ -0,0 +1,103 @@ + +
+
+
+
+ + + + +
{{ valueTitle(keyControl.get(keyControl.get('type').value).value) }}
+
+
+ +
+
gateway.type
+
+ + + +
+ + + + {{ valueTypes.get(keyControl.get('type').value)?.name | translate}} + +
+
+ + + + {{ valueTypes.get(valueType).name | translate }} + +
+
+
+
+
+
gateway.value
+ + + + + + + true + false + + + + warning + + +
+
+
+
+
+ +
+
+
+ +
+
+ +
+ {{ 'gateway.no-value' }} +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/type-value-panel/type-value-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/type-value-panel/type-value-panel.component.scss new file mode 100644 index 0000000000..770f17cac6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/type-value-panel/type-value-panel.component.scss @@ -0,0 +1,49 @@ +/** + * 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 { + + .title-container { + max-width: 11vw; + } + + .key-panel { + height: 250px; + overflow: auto; + } + + .tb-form-panel { + .mat-mdc-icon-button { + width: 56px; + height: 56px; + padding: 16px; + color: rgba(0, 0, 0, 0.54); + } + } + + .see-example { + width: 32px; + height: 32px; + margin: 4px; + } +} + +:host ::ng-deep { + .mat-mdc-form-field-icon-suffix { + display: flex; + } +} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/type-value-panel/type-value-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/type-value-panel/type-value-panel.component.ts new file mode 100644 index 0000000000..8e1c6fdb57 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/type-value-panel/type-value-panel.component.ts @@ -0,0 +1,160 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, OnDestroy, OnInit } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { isDefinedAndNotNull } from '@core/utils'; +import { + integerRegex, + MappingDataKey, + MappingValueType, + mappingValueTypesMap, + noLeadTrailSpacesRegex +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; + +@Component({ + selector: 'tb-type-value-panel', + templateUrl: './type-value-panel.component.html', + styleUrls: ['./type-value-panel.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TypeValuePanelComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TypeValuePanelComponent), + multi: true + } + ] +}) +export class TypeValuePanelComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy { + + valueTypeKeys: MappingValueType[] = Object.values(MappingValueType); + valueTypes = mappingValueTypesMap; + valueListFormArray: UntypedFormArray; + readonly MappingValueType = MappingValueType; + + private destroy$ = new Subject(); + private propagateChange = (v: any) => {}; + + constructor(private fb: UntypedFormBuilder) {} + + ngOnInit(): void { + this.valueListFormArray = this.fb.array([]); + this.valueListFormArray.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + this.updateView(value); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + trackByKey(_: number, keyControl: AbstractControl): any { + return keyControl; + } + + addKey(): void { + const dataKeyFormGroup = this.fb.group({ + type: [MappingValueType.STRING], + string: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + integer: [{value: 0, disabled: true}, [Validators.required, Validators.pattern(integerRegex)]], + double: [{value: 0, disabled: true}, [Validators.required]], + boolean: [{value: false, disabled: true}, [Validators.required]], + }); + this.observeTypeChange(dataKeyFormGroup); + this.valueListFormArray.push(dataKeyFormGroup); + } + + private observeTypeChange(dataKeyFormGroup: FormGroup): void { + dataKeyFormGroup.get('type').valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(type => { + dataKeyFormGroup.disable({emitEvent: false}); + dataKeyFormGroup.get('type').enable({emitEvent: false}); + dataKeyFormGroup.get(type).enable({emitEvent: false}); + }) + } + + deleteKey($event: Event, index: number): void { + if ($event) { + $event.stopPropagation(); + } + this.valueListFormArray.removeAt(index); + this.valueListFormArray.markAsDirty(); + } + + valueTitle(value: any): string { + if (isDefinedAndNotNull(value)) { + if (typeof value === 'object') { + return JSON.stringify(value); + } + return value; + } + return ''; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void {} + + writeValue(deviceInfoArray: Array): void { + for (const deviceInfo of deviceInfoArray) { + const config = { + type: [deviceInfo.type], + string: [{value: '', disabled: true}, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + integer: [{value: 0, disabled: true}, [Validators.required, Validators.pattern(integerRegex)]], + double: [{value: 0, disabled: true}, [Validators.required]], + boolean: [{value: false, disabled: true}, [Validators.required]], + }; + config[deviceInfo.type][0] = {value: deviceInfo.value, disabled: false}; + + const dataKeyFormGroup = this.fb.group(config); + this.observeTypeChange(dataKeyFormGroup); + this.valueListFormArray.push(dataKeyFormGroup); + } + } + + validate(): ValidationErrors | null { + return this.valueListFormArray.valid ? null : { + valueListForm: { valid: false } + }; + } + + private updateView(value: any): void { + this.propagateChange(value.map(({type, ...config}) => ({type, value: config[type]}))); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component.html new file mode 100644 index 0000000000..0a3bd4e6f3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component.html @@ -0,0 +1,63 @@ + +
+
+
+
{{ 'gateway.max-number-of-workers' | translate }}
+
+
+ + + + warning + + +
+
+
+
+
{{ 'gateway.max-messages-queue-for-worker' | translate }}
+
+
+ + + + warning + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component.ts new file mode 100644 index 0000000000..8abc3cb416 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component.ts @@ -0,0 +1,108 @@ +/// +/// 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 { + ChangeDetectionStrategy, + Component, + forwardRef, + OnDestroy, +} from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormGroup, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { SharedModule } from '@shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { WorkersConfig } from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +@Component({ + selector: 'tb-workers-config-control', + templateUrl: './workers-config-control.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + CommonModule, + SharedModule, + ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WorkersConfigControlComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => WorkersConfigControlComponent), + multi: true + } + ] +}) +export class WorkersConfigControlComponent implements OnDestroy, ControlValueAccessor, Validator { + + workersConfigFormGroup: UntypedFormGroup; + + private onChange: (value: string) => void; + private onTouched: () => void; + + private destroy$ = new Subject(); + + constructor(private fb: FormBuilder) { + this.workersConfigFormGroup = this.fb.group({ + maxNumberOfWorkers: [100, [Validators.required, Validators.min(1)]], + maxMessageNumberPerWorker: [10, [Validators.required, Validators.min(1)]], + }); + + this.workersConfigFormGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(value => { + this.onChange(value); + this.onTouched(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + writeValue(workersConfig: WorkersConfig): void { + const { maxNumberOfWorkers, maxMessageNumberPerWorker } = workersConfig; + this.workersConfigFormGroup.reset({ + maxNumberOfWorkers: maxNumberOfWorkers || 100, + maxMessageNumberPerWorker: maxMessageNumberPerWorker || 10, + }, {emitEvent: false}); + } + + validate(): ValidationErrors | null { + return this.workersConfigFormGroup.valid ? null : { + workersConfigFormGroup: {valid: false} + }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts index 7cdfbf5808..eca1497d22 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts @@ -33,7 +33,8 @@ import { } from '@home/components/widget/lib/gateway/gateway-widget.models'; import { Subject } from 'rxjs'; import { ResourcesService } from '@core/services/resources.service'; -import { takeUntil, tap } from "rxjs/operators"; +import { takeUntil, tap } from 'rxjs/operators'; +import { helpBaseUrl } from '@shared/models/constants'; @Component({ selector: 'tb-add-connector-dialog', @@ -83,7 +84,7 @@ export class AddConnectorDialogComponent extends DialogComponent { - const newName = c.value.trim().toLowerCase(); - const found = this.data.dataSourceData.find((connectorAttr) => { - const connectorData = connectorAttr.value; - return connectorData.name.toLowerCase() === newName; - }); - if (found) { - if (c.hasError('required')) { - return c.getError('required'); - } - return { - duplicateName: { - valid: false - } - }; - } - return null; + return (control: UntypedFormControl) => { + const newName = control.value.trim().toLowerCase(); + const isDuplicate = this.data.dataSourceData.some(({ value: { name } }) => + name.toLowerCase() === newName + ); + + return isDuplicate ? { duplicateName: { valid: false } } : null; }; } @@ -137,6 +128,6 @@ export class AddConnectorDialogComponent extends DialogComponent

{{ MappingTypeTranslationsMap.get(this.data?.mappingType) | translate}}

-
+
-
- gateway.mqtt-qos +
+ {{ 'gateway.mqtt-qos' | translate }}
@@ -140,8 +140,8 @@
- gateway.extension + tb-hint-tooltip-icon="{{ 'gateway.extension-hint' | translate }}"> + {{ 'gateway.extension' | translate }}
@@ -369,8 +369,8 @@
- gateway.device-name-filter + tb-hint-tooltip-icon="{{ 'gateway.device-name-filter-hint' | translate }}"> + {{ 'gateway.device-name-filter' | translate }}
@@ -388,8 +388,8 @@
-
- gateway.attribute-filter +
+ {{ 'gateway.attribute-filter' | translate }}
@@ -472,8 +472,8 @@
-
- gateway.device-name-filter +
+ {{ 'gateway.device-name-filter' | translate }}
@@ -491,8 +491,8 @@
-
- gateway.method-filter +
+ {{ 'gateway.method-filter' | translate }}
@@ -580,8 +580,8 @@
-
- gateway.response-topic-Qos +
+ {{ 'gateway.response-topic-Qos' | translate }}
@@ -615,6 +615,124 @@ + +
+
+
+ {{ 'gateway.device-node' | translate }} +
+
+
+ + + + {{ SourceTypeTranslationsMap.get(type) | translate }} + + + + + + + warning + +
+
+
+
+
+ + +
+
gateway.attributes
+
+ + + {{ attribute }} + + + + + + +
+
+
+
gateway.timeseries
+
+ + + {{ telemetry }} + + + + + + +
+
+
+
gateway.attribute-updates
+
+ + + {{ attribute }} + + + + + + +
+
+
+
gateway.rpc-methods
+
+ + + {{ attribute }} + + + + + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.scss index d002a0fc87..4212e8f834 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.scss @@ -47,6 +47,7 @@ .mdc-evolution-chip-set__chips { justify-content: flex-end; align-items: center; + flex-wrap: nowrap; } } } @@ -72,4 +73,14 @@ .mat-mdc-form-field-icon-suffix { display: flex; } + + .device-config { + gap: 12px; + padding-left: 10px; + padding-right: 10px; + } + + .device-node-pattern-field { + flex-basis: 3%; + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.ts index d01050b52e..9e5a43bc17 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.ts @@ -19,14 +19,20 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { FormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; -import { BaseData, HasId } from '@shared/models/base-data'; import { DialogComponent } from '@shared/components/dialog.component'; import { Router } from '@angular/router'; import { + Attribute, + AttributesUpdate, + ConnectorMapping, + ConnectorMappingFormValue, + ConverterMappingFormValue, ConvertorType, ConvertorTypeTranslationsMap, DataConversionTranslationsMap, + DeviceConnectorMapping, DeviceInfoType, + HelpLinkByMappingTypeMap, MappingHintTranslationsMap, MappingInfo, MappingKeysAddKeyTranslationsMap, @@ -37,28 +43,33 @@ import { MappingType, MappingTypeTranslationsMap, noLeadTrailSpacesRegex, + OPCUaSourceTypes, QualityTypes, QualityTypeTranslationsMap, + RequestMappingFormValue, RequestType, RequestTypesTranslationsMap, + RpcMethod, ServerSideRPCType, SourceTypes, - SourceTypeTranslationsMap + SourceTypeTranslationsMap, + Timeseries } from '@home/components/widget/lib/gateway/gateway-widget.models'; import { Subject } from 'rxjs'; import { startWith, takeUntil } from 'rxjs/operators'; import { MatButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; -import { MappingDataKeysPanelComponent } from '@home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component'; import { TranslateService } from '@ngx-translate/core'; +import { + MappingDataKeysPanelComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel/mapping-data-keys-panel.component'; @Component({ selector: 'tb-mapping-dialog', templateUrl: './mapping-dialog.component.html', - styleUrls: ['./mapping-dialog.component.scss'], - providers: [], + styleUrls: ['./mapping-dialog.component.scss'] }) -export class MappingDialogComponent extends DialogComponent> implements OnDestroy { +export class MappingDialogComponent extends DialogComponent implements OnDestroy { mappingForm: UntypedFormGroup; @@ -67,15 +78,17 @@ export class MappingDialogComponent extends DialogComponent; + OPCUaSourceTypesEnum = OPCUaSourceTypes; sourceTypesEnum = SourceTypes; SourceTypeTranslationsMap = SourceTypeTranslationsMap; - requestTypes = Object.values(RequestType); + requestTypes: RequestType[] = Object.values(RequestType); RequestTypeEnum = RequestType; RequestTypesTranslationsMap = RequestTypesTranslationsMap; @@ -91,18 +104,16 @@ export class MappingDialogComponent extends DialogComponent(); constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: MappingInfo, - public dialogRef: MatDialogRef, + public dialogRef: MatDialogRef, private fb: FormBuilder, private popoverService: TbPopoverService, private renderer: Renderer2, @@ -115,16 +126,32 @@ export class MappingDialogComponent extends DialogComponent { if (this.converterType) { - return this.mappingForm.get('converter').get(this.converterType).value.attributes.map(value => value.key); + return this.mappingForm.get('converter').get(this.converterType).value.attributes.map((value: Attribute) => value.key); } } get converterTelemetry(): Array { if (this.converterType) { - return this.mappingForm.get('converter').get(this.converterType).value.timeseries.map(value => value.key); + return this.mappingForm.get('converter').get(this.converterType).value.timeseries.map((value: Timeseries) => value.key); } } + get opcAttributes(): Array { + return this.mappingForm.get('attributes').value?.map((value: Attribute) => value.key) || []; + } + + get opcTelemetry(): Array { + return this.mappingForm.get('timeseries').value?.map((value: Timeseries) => value.key) || []; + } + + get opcRpcMethods(): Array { + return this.mappingForm.get('rpc_methods').value?.map((value: RpcMethod) => value.method) || []; + } + + get opcAttributesUpdates(): Array { + return this.mappingForm.get('attributes_updates')?.value?.map((value: AttributesUpdate) => value.key) || []; + } + get converterType(): ConvertorType { return this.mappingForm.get('converter').get('type').value; } @@ -155,116 +182,20 @@ export class MappingDialogComponent extends DialogComponent { - const converterGroup = this.mappingForm.get('converter'); - converterGroup.get('json').disable({emitEvent: false}); - converterGroup.get('bytes').disable({emitEvent: false}); - converterGroup.get('custom').disable({emitEvent: false}); - converterGroup.get(value).enable({emitEvent: false}); - }) - } - - if (this.data.mappingType === MappingType.REQUESTS) { - this.mappingForm.addControl('requestType', this.fb.control(RequestType.CONNECT_REQUEST, [])); - this.mappingForm.addControl('requestValue', this.fb.group({ - connectRequests: this.fb.group({ - topicFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - deviceInfo: [{}, []] - }), - disconnectRequests: this.fb.group({ - topicFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - deviceInfo: [{}, []] - }), - attributeRequests: this.fb.group({ - topicFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - deviceInfo: this.fb.group({ - deviceNameExpressionSource: [SourceTypes.MSG, []], - deviceNameExpression: ['', [Validators.required]], - }), - attributeNameExpressionSource: [SourceTypes.MSG, []], - attributeNameExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - topicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - valueExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - retain: [false, []] - }), - attributeUpdates: this.fb.group({ - deviceNameFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - attributeFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - topicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - valueExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - retain: [true, []] - }), - serverSideRpc: this.fb.group({ - type: [ServerSideRPCType.TWO_WAY, []], - deviceNameFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - methodFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - requestTopicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - responseTopicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - valueExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - responseTopicQoS: [0, []], - responseTimeout: [10000, [Validators.required, Validators.min(1)]], - }) - })); - this.mappingForm.get('requestType').valueChanges.pipe( - startWith(this.mappingForm.get('requestType').value), - takeUntil(this.destroy$) - ).subscribe((value) => { - const requestValueGroup = this.mappingForm.get('requestValue'); - requestValueGroup.get('connectRequests').disable({emitEvent: false}); - requestValueGroup.get('disconnectRequests').disable({emitEvent: false}); - requestValueGroup.get('attributeRequests').disable({emitEvent: false}); - requestValueGroup.get('attributeUpdates').disable({emitEvent: false}); - requestValueGroup.get('serverSideRpc').disable({emitEvent: false}); - requestValueGroup.get(value).enable(); - }); - this.mappingForm.get('requestValue.serverSideRpc.type').valueChanges.pipe( - takeUntil(this.destroy$) - ).subscribe((value) => { - const requestValueGroup = this.mappingForm.get('requestValue.serverSideRpc'); - if (value === ServerSideRPCType.ONE_WAY) { - requestValueGroup.get('responseTopicExpression').disable({emitEvent: false}); - requestValueGroup.get('responseTopicQoS').disable({emitEvent: false}); - requestValueGroup.get('responseTimeout').disable({emitEvent: false}); - } else { - requestValueGroup.get('responseTopicExpression').enable({emitEvent: false}); - requestValueGroup.get('responseTopicQoS').enable({emitEvent: false}); - requestValueGroup.get('responseTimeout').enable({emitEvent: false}); - } - }); - this.mappingForm.patchValue(this.prepareFormValueData()); + switch (this.data.mappingType) { + case MappingType.DATA: + this.mappingForm = this.fb.group({}); + this.createDataMappingForm(); + break; + case MappingType.REQUESTS: + this.mappingForm = this.fb.group({}); + this.createRequestMappingForm(); + break; + case MappingType.OPCUA: + this.createOPCUAMappingForm(); } } - helpLinkId(): string { - return 'https://thingsboard.io/docs/iot-gateway/config/mqtt/#section-mapping'; - } - cancel(): void { if (this.keysPopupClosed) { this.dialogRef.close(null); @@ -272,13 +203,12 @@ export class MappingDialogComponent extends DialogComponent { + dataKeysPanelPopover.tbComponentRef.instance.keysDataApplied.pipe(takeUntil(this.destroy$)).subscribe((keysData) => { dataKeysPanelPopover.hide(); keysControl.patchValue(keysData); keysControl.markAsDirty(); }); - dataKeysPanelPopover.tbHideStart.subscribe(() => { + dataKeysPanelPopover.tbHideStart.pipe(takeUntil(this.destroy$)).subscribe(() => { this.keysPopupClosed = true; }); } } - private prepareMappingData(): {[key: string]: any} { + private prepareMappingData(): ConnectorMapping { const formValue = this.mappingForm.value; - if (this.data.mappingType === MappingType.DATA) { - const { converter, topicFilter, subscriptionQos } = formValue; - return { - topicFilter, - subscriptionQos, - converter: { - type: converter.type, - ...converter[converter.type] - } - }; - } else { - return { - requestType: formValue.requestType, - requestValue: formValue.requestValue[formValue.requestType] - }; - } - } - - private prepareFormValueData(): {[key: string]: any} { - if (this.data.value && Object.keys(this.data.value).length) { - if (this.data.mappingType === MappingType.DATA) { - const { converter, topicFilter, subscriptionQos } = this.data.value; + switch (this.data.mappingType) { + case MappingType.DATA: + const {converter, topicFilter, subscriptionQos} = formValue; return { topicFilter, subscriptionQos, converter: { type: converter.type, - [converter.type]: { ...converter } + ...converter[converter.type] } }; - } else { + case MappingType.REQUESTS: return { - requestType: this.data.value.requestType, - requestValue: { - [this.data.value.requestType]: this.data.value.requestValue - } + requestType: formValue.requestType, + requestValue: formValue.requestValue[formValue.requestType] }; + default: + return formValue; + } + } + + private getFormValueData(): ConnectorMappingFormValue { + if (this.data.value && Object.keys(this.data.value).length) { + switch (this.data.mappingType) { + case MappingType.DATA: + const {converter, topicFilter, subscriptionQos} = this.data.value; + return { + topicFilter, + subscriptionQos, + converter: { + type: converter.type, + [converter.type]: {...converter} + } + } as ConverterMappingFormValue; + case MappingType.REQUESTS: + return { + requestType: this.data.value.requestType, + requestValue: { + [this.data.value.requestType]: this.data.value.requestValue + } + } as RequestMappingFormValue; + default: + return this.data.value as DeviceConnectorMapping; } } - return this.data.value; + } + + private createDataMappingForm(): void { + this.mappingForm.addControl('topicFilter', + this.fb.control('', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)])); + this.mappingForm.addControl('subscriptionQos', this.fb.control(0)); + this.mappingForm.addControl('converter', this.fb.group({ + type: [ConvertorType.JSON, []], + json: this.fb.group({ + deviceInfo: [{}, []], + attributes: [[], []], + timeseries: [[], []] + }), + bytes: this.fb.group({ + deviceInfo: [{}, []], + attributes: [[], []], + timeseries: [[], []] + }), + custom: this.fb.group({ + extension: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + extensionConfig: [{}, []] + }), + })); + this.mappingForm.patchValue(this.getFormValueData()); + this.mappingForm.get('converter.type').valueChanges.pipe( + startWith(this.mappingForm.get('converter.type').value), + takeUntil(this.destroy$) + ).subscribe((value) => { + const converterGroup = this.mappingForm.get('converter'); + converterGroup.get('json').disable({emitEvent: false}); + converterGroup.get('bytes').disable({emitEvent: false}); + converterGroup.get('custom').disable({emitEvent: false}); + converterGroup.get(value).enable({emitEvent: false}); + }); + } + + private createRequestMappingForm(): void { + this.mappingForm.addControl('requestType', this.fb.control(RequestType.CONNECT_REQUEST, [])); + this.mappingForm.addControl('requestValue', this.fb.group({ + connectRequests: this.fb.group({ + topicFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + deviceInfo: [{}, []] + }), + disconnectRequests: this.fb.group({ + topicFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + deviceInfo: [{}, []] + }), + attributeRequests: this.fb.group({ + topicFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + deviceInfo: this.fb.group({ + deviceNameExpressionSource: [SourceTypes.MSG, []], + deviceNameExpression: ['', [Validators.required]], + }), + attributeNameExpressionSource: [SourceTypes.MSG, []], + attributeNameExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + topicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + valueExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + retain: [false, []] + }), + attributeUpdates: this.fb.group({ + deviceNameFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + attributeFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + topicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + valueExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + retain: [true, []] + }), + serverSideRpc: this.fb.group({ + type: [ServerSideRPCType.TWO_WAY, []], + deviceNameFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + methodFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + requestTopicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + responseTopicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + valueExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + responseTopicQoS: [0, []], + responseTimeout: [10000, [Validators.required, Validators.min(1)]], + }) + })); + this.mappingForm.get('requestType').valueChanges.pipe( + startWith(this.mappingForm.get('requestType').value), + takeUntil(this.destroy$) + ).subscribe((value) => { + const requestValueGroup = this.mappingForm.get('requestValue'); + requestValueGroup.get('connectRequests').disable({emitEvent: false}); + requestValueGroup.get('disconnectRequests').disable({emitEvent: false}); + requestValueGroup.get('attributeRequests').disable({emitEvent: false}); + requestValueGroup.get('attributeUpdates').disable({emitEvent: false}); + requestValueGroup.get('serverSideRpc').disable({emitEvent: false}); + requestValueGroup.get(value).enable(); + }); + this.mappingForm.get('requestValue.serverSideRpc.type').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + const requestValueGroup = this.mappingForm.get('requestValue.serverSideRpc'); + if (value === ServerSideRPCType.ONE_WAY) { + requestValueGroup.get('responseTopicExpression').disable({emitEvent: false}); + requestValueGroup.get('responseTopicQoS').disable({emitEvent: false}); + requestValueGroup.get('responseTimeout').disable({emitEvent: false}); + } else { + requestValueGroup.get('responseTopicExpression').enable({emitEvent: false}); + requestValueGroup.get('responseTopicQoS').enable({emitEvent: false}); + requestValueGroup.get('responseTimeout').enable({emitEvent: false}); + } + }); + this.mappingForm.patchValue(this.getFormValueData()); + } + + private createOPCUAMappingForm(): void { + this.mappingForm = this.fb.group({ + deviceNodeSource: [OPCUaSourceTypes.PATH, []], + deviceNodePattern: ['', [Validators.required]], + deviceInfo: [{}, []], + attributes: [[], []], + timeseries: [[], []], + rpc_methods: [[], []], + attributes_updates: [[], []] + }); + this.mappingForm.patchValue(this.getFormValueData()); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html index 40e557bbbb..36b26cc7a8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html @@ -67,97 +67,97 @@
-
-
-
security.security
- - - {{ securityType.value | translate }} - - - security.access-token - - - {{ 'security.access-token-required' | translate }} - - - - info_outlined - - -
-
- - security.clientId - - - {{ 'security.clientId-required' | translate }} - - - - info_outlined - - - - security.username - - - {{ 'security.username-required' | translate }} - - - - info_outlined - - -
- - gateway.password - +
+
security.security
+ + + {{ securityType.value | translate }} + + + + security.access-token + + + {{ 'security.access-token-required' | translate }} + info_outlined + matTooltip="{{ 'gateway.hints.token' | translate }}">info_outlined -
- + + security.clientId + + + {{ 'security.clientId-required' | translate }} + + + + info_outlined + + + + security.username + + + {{ 'security.username-required' | translate }} + + + + info_outlined + + +
+ + gateway.password + + + + info_outlined + + + + {{ 'gateway.min-pack-send-delay-min' | translate }} + + {{ 'gateway.min-pack-send-delay-min-pattern' | translate }} + info_outlined @@ -747,22 +751,64 @@ - - gateway.statistics.check-connectors-configuration - - - {{ 'gateway.statistics.check-connectors-configuration-required' | translate }} - - - {{ 'gateway.statistics.check-connectors-configuration-min' | translate }} - - - {{ 'gateway.statistics.check-connectors-configuration-pattern' | translate }} - - +
+ + gateway.statistics.check-connectors-configuration + + + {{ 'gateway.statistics.check-connectors-configuration-required' | translate }} + + + {{ 'gateway.statistics.check-connectors-configuration-min' | translate }} + + + {{ 'gateway.statistics.check-connectors-configuration-pattern' | translate }} + + + + gateway.statistics.max-payload-size-bytes + + + {{ 'gateway.statistics.max-payload-size-bytes-required' | translate }} + + + {{ 'gateway.statistics.max-payload-size-bytes-min' | translate }} + + + {{ 'gateway.statistics.max-payload-size-bytes-pattern' | translate }} + + info_outlined + + +
+
+ + gateway.statistics.min-pack-size-to-send + + + {{ 'gateway.statistics.min-pack-size-to-send-required' | translate }} + + + {{ 'gateway.statistics.min-pack-size-to-send-min' | translate }} + + + {{ 'gateway.statistics.min-pack-size-to-send-pattern' | translate }} + + info_outlined + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.ts index 1c65920179..55121da8c5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.ts @@ -102,9 +102,9 @@ export class GatewayConfigurationComponent implements OnInit { statsSendPeriodInSeconds: [3600, [Validators.required, Validators.min(60), Validators.pattern(/^-?[0-9]+$/)]], commands: this.fb.array([], []) }), - maxPayloadSizeBytes: [1024, [Validators.required, Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]], - minPackSendDelayMS: [200, [Validators.required, Validators.min(0), Validators.pattern(/^-?[0-9]+$/)]], - minPackSizeToSend: [500, [Validators.required, Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]], + maxPayloadSizeBytes: [8196, [Validators.required, Validators.min(100), Validators.pattern(/^-?[0-9]+$/)]], + minPackSendDelayMS: [50, [Validators.required, Validators.min(10), Validators.pattern(/^-?[0-9]+$/)]], + minPackSizeToSend: [500, [Validators.required, Validators.min(100), Validators.pattern(/^-?[0-9]+$/)]], handleDeviceRenaming: [true, []], checkingDeviceActivity: this.fb.group({ checkDeviceInactivity: [false, []], @@ -619,6 +619,9 @@ export class GatewayConfigurationComponent implements OnInit { updateCredentials = true; } else { updateCredentials = this.initialCredentials.credentialsId !== securityConfig.accessToken; + if (updateCredentials) { + this.initialCredentials.credentialsId = securityConfig.accessToken; + } } if (updateCredentials) { newCredentials = { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html index eb7afe0cc5..e6391f1782 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html @@ -20,7 +20,8 @@

{{ 'gateway.connectors' | translate }}

- - -
-
- - -
- - - -
- -
-
- -
- -
-
- -
- -
-
-
- gateway.max-number-of-workers -
-
- - - - warning - - -
-
-
-
- gateway.max-messages-queue-for-worker -
-
- - - - warning - - -
-
-
-
-
-
+ + + + + + + + - + + + + + + - - + +
+ +
+
+
gateway.name
+
+ + + + warning + + +
+
+
+
gateway.connectors-table-class
+
+ + + +
+
+
+
gateway.connectors-table-key
+
+ + + +
+
+
+
gateway.logs-configuration
+
+ + + {{ 'gateway.enable-remote-logging' | translate }} + + +
+
+
gateway.remote-logging-level
+
+ + + {{ logLevel }} + + +
+
+
+
+ + + {{ 'gateway.send-change-data' | translate }} + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.ts index 66d33b9a06..e7e4d05e45 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.ts @@ -14,24 +14,24 @@ /// limitations under the License. /// -import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, Input, NgZone, ViewChild } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + Input, + NgZone, + OnDestroy, + ViewChild +} from '@angular/core'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { - FormBuilder, - FormControl, - FormGroup, - FormGroupDirective, - NgForm, - UntypedFormControl, - ValidatorFn, - Validators -} from '@angular/forms'; +import { FormBuilder, FormControl, FormGroup, UntypedFormControl, ValidatorFn, Validators } from '@angular/forms'; import { EntityId } from '@shared/models/id/entity-id'; import { AttributeService } from '@core/http/attribute.service'; import { TranslateService } from '@ngx-translate/core'; import { forkJoin, Observable, of, Subject, Subscription } from 'rxjs'; -import { AttributeData, AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; import { PageComponent } from '@shared/components/page.component'; import { PageLink } from '@shared/models/page/page-link'; import { AttributeDatasource } from '@home/models/datasource/attribute-datasource'; @@ -50,24 +50,24 @@ import { UtilsService } from '@core/services/utils.service'; import { EntityType } from '@shared/models/entity-type.models'; import { AddConnectorConfigData, + ConnectorBaseConfig, + ConnectorBaseInfo, ConnectorConfigurationModes, ConnectorType, + GatewayAttributeData, GatewayConnector, GatewayConnectorDefaultTypesTranslatesMap, GatewayLogLevel, - MappingType, - MqttVersions, noLeadTrailSpacesRegex, - PortLimits } from './gateway-widget.models'; import { MatDialog } from '@angular/material/dialog'; import { AddConnectorDialogComponent } from '@home/components/widget/lib/gateway/dialog/add-connector-dialog.component'; -import { debounceTime, take, takeUntil, tap } from 'rxjs/operators'; +import { debounceTime, filter, switchMap, take, takeUntil, tap } from 'rxjs/operators'; import { ErrorStateMatcher } from '@angular/material/core'; import { PageData } from '@shared/models/page/page-data'; export class ForceErrorStateMatcher implements ErrorStateMatcher { - isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + isErrorState(control: FormControl | null): boolean { return (control && control.invalid); } } @@ -78,77 +78,56 @@ export class ForceErrorStateMatcher implements ErrorStateMatcher { providers: [{ provide: ErrorStateMatcher, useClass: ForceErrorStateMatcher }], styleUrls: ['./gateway-connectors.component.scss'] }) -export class GatewayConnectorComponent extends PageComponent implements AfterViewInit { +export class GatewayConnectorComponent extends PageComponent implements AfterViewInit, OnDestroy { @Input() ctx: WidgetContext; - @Input() device: EntityId; @ViewChild('nameInput') nameInput: ElementRef; @ViewChild(MatSort, {static: false}) sort: MatSort; - pageLink: PageLink; - - connectorType = ConnectorType; - - dataSource: MatTableDataSource; - - displayedColumns = ['enabled', 'key', 'type', 'syncStatus', 'errors', 'actions']; - - mqttVersions = MqttVersions; - - gatewayConnectorDefaultTypes = GatewayConnectorDefaultTypesTranslatesMap; - - connectorConfigurationModes = ConnectorConfigurationModes; + readonly ConnectorType = ConnectorType; + readonly allowBasicConfig = new Set([ + ConnectorType.MQTT, + ConnectorType.OPCUA, + ConnectorType.MODBUS, + ]); + readonly gatewayLogLevel = Object.values(GatewayLogLevel); + readonly displayedColumns = ['enabled', 'key', 'type', 'syncStatus', 'errors', 'actions']; + readonly GatewayConnectorTypesTranslatesMap = GatewayConnectorDefaultTypesTranslatesMap; + readonly ConnectorConfigurationModes = ConnectorConfigurationModes; + pageLink: PageLink; + dataSource: MatTableDataSource; connectorForm: FormGroup; - - textSearchMode: boolean; - activeConnectors: Array; - - gatewayLogLevel = Object.values(GatewayLogLevel); - - mappingTypes = MappingType; - - portLimits = PortLimits; - - mode: ConnectorConfigurationModes = this.connectorConfigurationModes.BASIC; - + mode: ConnectorConfigurationModes = this.ConnectorConfigurationModes.BASIC; initialConnector: GatewayConnector; private inactiveConnectors: Array; - private attributeDataSource: AttributeDatasource; - private inactiveConnectorsDataSource: AttributeDatasource; - private serverDataSource: AttributeDatasource; - private activeData: Array = []; - private inactiveData: Array = []; - - private sharedAttributeData: Array = []; - + private sharedAttributeData: Array = []; private basicConfigSub: Subscription; - + private jsonConfigSub: Subscription; private subscriptionOptions: WidgetSubscriptionOptions = { callbacks: { onDataUpdated: () => this.ctx.ngZone.run(() => { this.onDataUpdated(); }), - onDataUpdateError: (subscription, e) => this.ctx.ngZone.run(() => { + onDataUpdateError: (_, e) => this.ctx.ngZone.run(() => { this.onDataUpdateError(e); }) } }; - private destroy$ = new Subject(); private subscription: IWidgetSubscription; - private attributeUpdateSubject = new Subject(); + private attributeUpdateSubject = new Subject(); constructor(protected store: Store, private fb: FormBuilder, @@ -161,217 +140,110 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie private utils: UtilsService, private cd: ChangeDetectorRef) { super(store); - const sortOrder: SortOrder = {property: 'key', direction: Direction.ASC}; - this.pageLink = new PageLink(1000, 0, null, sortOrder); - this.attributeDataSource = new AttributeDatasource(this.attributeService, this.telemetryWsService, this.zone, this.translate); - this.inactiveConnectorsDataSource = new AttributeDatasource(this.attributeService, this.telemetryWsService, this.zone, this.translate); - this.serverDataSource = new AttributeDatasource(this.attributeService, this.telemetryWsService, this.zone, this.translate); - this.dataSource = new MatTableDataSource([]); - this.connectorForm = this.fb.group({ - mode: [ConnectorConfigurationModes.BASIC, []], - name: ['', [Validators.required, this.uniqNameRequired(), Validators.pattern(noLeadTrailSpacesRegex)]], - type: ['', [Validators.required]], - enableRemoteLogging: [false, []], - logLevel: ['', [Validators.required]], - sendDataOnlyOnChange: [false, []], - key: ['auto'], - class: [''], - configuration: [''], - configurationJson: [{}, [Validators.required]], - basicConfig: this.fb.group({}) - }); - this.connectorForm.disable(); + this.initDataSources(); + this.initConnectorForm(); this.observeAttributeChange(); } - get portErrorTooltip(): string { - if (this.connectorForm.get('basicConfig.broker.port').hasError('required')) { - return this.translate.instant('gateway.port-required'); - } else if ( - this.connectorForm.get('basicConfig.broker.port').hasError('min') || - this.connectorForm.get('basicConfig.broker.port').hasError('max') - ) { - return this.translate.instant('gateway.port-limits-error', - {min: PortLimits.MIN, max: PortLimits.MAX}); - } - return ''; + ngAfterViewInit(): void { + this.observeName(); + + this.dataSource.sort = this.sort; + this.dataSource.sortingDataAccessor = this.getSortingDataAccessor(); + + this.loadConnectors(); + this.observeModeChange(); } - ngAfterViewInit() { - this.connectorForm.get('type').valueChanges.pipe( - takeUntil(this.destroy$) - ).subscribe(type => { - if (type && !this.initialConnector) { - this.attributeService.getEntityAttributes(this.device, AttributeScope.CLIENT_SCOPE, - [`${type.toUpperCase()}_DEFAULT_CONFIG`], {ignoreErrors: true}).subscribe(defaultConfig=>{ - if (defaultConfig && defaultConfig.length) { - this.connectorForm.get('configurationJson').setValue( - isString(defaultConfig[0].value) ? - JSON.parse(defaultConfig[0].value) : - defaultConfig[0].value); - this.cd.detectChanges(); - } - }) - } - }); + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + super.ngOnDestroy(); + } - this.connectorForm.get('name').valueChanges.pipe( - takeUntil(this.destroy$) - ).subscribe((name) => { - if (this.connectorForm.get('type').value === ConnectorType.MQTT) { - this.connectorForm.get('basicConfig').get('broker.name')?.setValue(name); - } - }); + saveConnector(): void { + const value = this.getConnectorData(); + const scope = (!this.initialConnector || this.activeConnectors.includes(this.initialConnector.name)) + ? AttributeScope.SHARED_SCOPE + : AttributeScope.SERVER_SCOPE; - this.connectorForm.get('configurationJson').valueChanges.pipe( - takeUntil(this.destroy$) - ).subscribe((config) => { - const basicConfig = this.connectorForm.get('basicConfig'); - const type = this.connectorForm.get('type').value; - const mode = this.connectorForm.get('mode').value; - if ( - !isEqual(config, basicConfig?.value) && - type === ConnectorType.MQTT && - mode === ConnectorConfigurationModes.ADVANCED - ) { - this.connectorForm.get('basicConfig').patchValue(config, {emitEvent: false}); - } + forkJoin(this.getEntityAttributeTasks(value, scope)).pipe(take(1)).subscribe(_ => { + this.showToast(!this.initialConnector + ? this.translate.instant('gateway.connector-created') + : this.translate.instant('gateway.connector-updated') + ); + this.initialConnector = value; + this.updateData(true); + this.connectorForm.markAsPristine(); }); + } - this.dataSource.sort = this.sort; - this.dataSource.sortingDataAccessor = (data: AttributeData, sortHeaderId: string) => { - switch (sortHeaderId) { - case 'syncStatus': - return this.isConnectorSynced(data) ? 1 : 0; - - case 'enabled': - return this.activeConnectors.includes(data.key) ? 1 : 0; + private getEntityAttributeTasks(value: GatewayConnector, scope: AttributeScope): Observable[] { + const tasks = []; + const attributesToSave = [{ key: value.name, value }]; + const attributesToDelete = []; + const shouldAddToConnectorsList = !this.activeConnectors.includes(value.name) && scope === AttributeScope.SHARED_SCOPE + || !this.inactiveConnectors.includes(value.name) && scope === AttributeScope.SERVER_SCOPE; + const isNewConnector = this.initialConnector && this.initialConnector.name !== value.name; - case 'errors': - const errors = this.getErrorsCount(data); - if (typeof errors === 'string') { - return this.sort.direction.toUpperCase() === Direction.DESC ? -1 : Infinity; - } - return errors; + if (isNewConnector) { + attributesToDelete.push({ key: this.initialConnector.name }); + this.removeConnectorFromList(this.initialConnector.name, true); + this.removeConnectorFromList(this.initialConnector.name, false); + } - default: - return data[sortHeaderId] || data.value[sortHeaderId]; + if (shouldAddToConnectorsList) { + if (scope === AttributeScope.SHARED_SCOPE) { + this.activeConnectors.push(value.name); + } else { + this.inactiveConnectors.push(value.name); } - }; + } - if (this.device) { - if (this.device.id === NULL_UUID) { - return; - } - forkJoin([ - this.attributeService.getEntityAttributes(this.device, AttributeScope.SHARED_SCOPE, ['active_connectors']), - this.attributeService.getEntityAttributes(this.device, AttributeScope.SERVER_SCOPE, ['inactive_connectors']) - ]).subscribe(attributes => { - if (attributes.length) { - this.activeConnectors = attributes[0].length ? attributes[0][0].value : []; - this.activeConnectors = isString(this.activeConnectors) ? JSON.parse(this.activeConnectors as any) : this.activeConnectors; - this.inactiveConnectors = attributes[1].length ? attributes[1][0].value : []; - this.inactiveConnectors = isString(this.inactiveConnectors) - ? JSON.parse(this.inactiveConnectors as any) - : this.inactiveConnectors; - this.updateData(true); - } else { - this.activeConnectors = []; - this.inactiveConnectors = []; - this.updateData(true); - } - }); + if (isNewConnector || shouldAddToConnectorsList) { + tasks.push(this.getSaveEntityAttributesTask(scope)); + } + + tasks.push(this.attributeService.saveEntityAttributes(this.device, scope, attributesToSave)); + + if (attributesToDelete.length) { + tasks.push(this.attributeService.deleteEntityAttributes(this.device, scope, attributesToDelete)); } + + return tasks; } - private uniqNameRequired(): ValidatorFn { - return (c: UntypedFormControl) => { - const newName = c.value.trim().toLowerCase(); - const found = this.dataSource.data.find((connectorAttr) => { - const connectorData = connectorAttr.value; - return connectorData.name.toLowerCase() === newName; - }); - if (found) { - if (this.initialConnector && this.initialConnector.name.toLowerCase() === newName) { - return null; - } - return { - duplicateName: { - valid: false - } - }; - } - return null; - }; + private getSaveEntityAttributesTask(scope: AttributeScope): Observable { + const key = scope === AttributeScope.SHARED_SCOPE ? 'active_connectors' : 'inactive_connectors'; + const value = scope === AttributeScope.SHARED_SCOPE ? this.activeConnectors : this.inactiveConnectors; + + return this.attributeService.saveEntityAttributes(this.device, scope, [{ key, value }]); } - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - super.ngOnDestroy(); + private removeConnectorFromList(connectorName: string, isActive: boolean): void { + const list = isActive? this.activeConnectors : this.inactiveConnectors; + const index = list.indexOf(connectorName); + if (index !== -1) { + list.splice(index, 1); + } } - saveConnector(): void { - const value = this.connectorForm.value; - value.configuration = camelCase(value.name) + '.json'; + private getConnectorData(): GatewayConnector { + const value = { ...this.connectorForm.value }; + value.configuration = `${camelCase(value.name)}.json`; delete value.basicConfig; + if (value.type !== ConnectorType.GRPC) { delete value.key; } if (value.type !== ConnectorType.CUSTOM) { delete value.class; } - value.ts = new Date().getTime(); - const attributesToSave = [{ - key: value.name, - value - }]; - const attributesToDelete = []; - const scope = (!this.initialConnector || this.activeConnectors.includes(this.initialConnector.name)) - ? AttributeScope.SHARED_SCOPE - : AttributeScope.SERVER_SCOPE; - let updateActiveConnectors = false; - if (this.initialConnector && this.initialConnector.name !== value.name) { - attributesToDelete.push({key: this.initialConnector.name}); - updateActiveConnectors = true; - const activeIndex = this.activeConnectors.indexOf(this.initialConnector.name); - const inactiveIndex = this.inactiveConnectors.indexOf(this.initialConnector.name); - if (activeIndex !== -1) { - this.activeConnectors.splice(activeIndex, 1); - } - if (inactiveIndex !== -1) { - this.inactiveConnectors.splice(inactiveIndex, 1); - } - } - if (!this.activeConnectors.includes(value.name) && scope === AttributeScope.SHARED_SCOPE) { - this.activeConnectors.push(value.name); - updateActiveConnectors = true; - } - if (!this.inactiveConnectors.includes(value.name) && scope === AttributeScope.SERVER_SCOPE) { - this.inactiveConnectors.push(value.name); - updateActiveConnectors = true; - } - const tasks = [this.attributeService.saveEntityAttributes(this.device, scope, attributesToSave)]; - if (updateActiveConnectors) { - tasks.push(this.attributeService.saveEntityAttributes(this.device, scope, [{ - key: scope === AttributeScope.SHARED_SCOPE ? 'active_connectors' : 'inactive_connectors', - value: scope === AttributeScope.SHARED_SCOPE ? this.activeConnectors : this.inactiveConnectors - }])); - } - if (attributesToDelete.length) { - tasks.push(this.attributeService.deleteEntityAttributes(this.device, scope, attributesToDelete)); - } - forkJoin(tasks).subscribe(_ => { - this.showToast(!this.initialConnector - ? this.translate.instant('gateway.connector-created') - : this.translate.instant('gateway.connector-updated') - ); - this.initialConnector = value; - this.updateData(true); - this.connectorForm.markAsPristine(); - }); + value.ts = Date.now(); + + return value; } private updateData(reload: boolean = false): void { @@ -393,13 +265,13 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie }); } - isConnectorSynced(attribute: AttributeData) { + isConnectorSynced(attribute: GatewayAttributeData) { const connectorData = attribute.value; - if (!connectorData.ts) { + if (!connectorData.ts || attribute.skipSync) { return false; } const clientIndex = this.activeData.findIndex(data => { - const sharedData = data.value; + const sharedData = typeof data.value === 'string' ? JSON.parse(data.value) : data.value; return sharedData.name === connectorData.name; }); if (clientIndex === -1) { @@ -407,23 +279,58 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie } const sharedIndex = this.sharedAttributeData.findIndex(data => { const sharedData = data.value; - return sharedData.name === connectorData.name && sharedData.ts && sharedData.ts <= connectorData.ts; + const hasSameName = sharedData.name === connectorData.name; + const hasEmptyConfig = isEqual(sharedData.configurationJson, {}) && hasSameName; + const hasSameConfig = this.hasSameConfig(sharedData.configurationJson, connectorData.configurationJson); + const isRecentlyCreated = sharedData.ts && sharedData.ts <= connectorData.ts; + return hasSameName && isRecentlyCreated && (hasSameConfig || hasEmptyConfig); }); return sharedIndex !== -1; } + private hasSameConfig(sharedDataConfigJson: ConnectorBaseInfo, connectorDataConfigJson: ConnectorBaseInfo): boolean { + const { name, id, enableRemoteLogging, logLevel, ...sharedDataConfig } = sharedDataConfigJson; + const { + name: connectorName, + id: connectorId, + enableRemoteLogging: connectorEnableRemoteLogging, + logLevel: connectorLogLevel, + ...connectorConfig + } = connectorDataConfigJson; + + return isEqual(sharedDataConfig, connectorConfig); + } + private combineData(): void { - this.dataSource.data = [...this.activeData, ...this.inactiveData, ...this.sharedAttributeData].filter((item, index, self) => - index === self.findIndex((t) => t.key === item.key) - ).map(attribute => { - attribute.value = typeof attribute.value === 'string' ? JSON.parse(attribute.value) : attribute.value; - return attribute; - }); + const combinedData = [ + ...this.activeData, + ...this.inactiveData, + ...this.sharedAttributeData + ]; + + const latestData = combinedData.reduce((acc, attribute) => { + const existingItemIndex = acc.findIndex(item => item.key === attribute.key); + + if (existingItemIndex === -1) { + acc.push(attribute); + } else if ( + attribute.lastUpdateTs > acc[existingItemIndex].lastUpdateTs && + !this.isConnectorSynced(acc[existingItemIndex]) + ) { + acc[existingItemIndex] = { ...attribute, skipSync: true }; + } + + return acc; + }, []); + + this.dataSource.data = latestData.map(attribute => ({ + ...attribute, + value: typeof attribute.value === 'string' ? JSON.parse(attribute.value) : attribute.value + })); } private clearOutConnectorForm(): void { this.initialConnector = null; - this.connectorForm.setControl('basicConfig', this.fb.group({}), {emitEvent: false}); this.connectorForm.setValue({ mode: ConnectorConfigurationModes.BASIC, name: '', @@ -440,7 +347,7 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie this.connectorForm.markAsPristine(); } - selectConnector($event: Event, attribute: AttributeData): void { + selectConnector($event: Event, attribute: GatewayAttributeData): void { if ($event) { $event.stopPropagation(); } @@ -454,7 +361,7 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie } } - isSameConnector(attribute: AttributeData): boolean { + isSameConnector(attribute: GatewayAttributeData): boolean { if (!this.initialConnector) { return false; } @@ -475,49 +382,45 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie })); } - returnType(attribute: AttributeData): string { + returnType(attribute: GatewayAttributeData): string { const value = attribute.value; - return this.gatewayConnectorDefaultTypes.get(value.type); + return this.GatewayConnectorTypesTranslatesMap.get(value.type); } - deleteConnector(attribute: AttributeData, $event: Event): void { - if ($event) { - $event.stopPropagation(); - } + deleteConnector(attribute: GatewayAttributeData, $event: Event): void { + $event?.stopPropagation(); + const title = `Delete connector \"${attribute.key}\"?`; const content = `All connector data will be deleted.`; - this.dialogService.confirm(title, content, 'Cancel', 'Delete').subscribe(result => { - if (result) { + + this.dialogService.confirm(title, content, 'Cancel', 'Delete').pipe( + take(1), + switchMap((result) => { + if (!result) { + return; + } const tasks: Array> = []; const scope = this.activeConnectors.includes(attribute.value?.name) ? - AttributeScope.SHARED_SCOPE : - AttributeScope.SERVER_SCOPE; + AttributeScope.SHARED_SCOPE : + AttributeScope.SERVER_SCOPE; tasks.push(this.attributeService.deleteEntityAttributes(this.device, scope, [attribute])); - const activeIndex = this.activeConnectors.indexOf(attribute.key); - const inactiveIndex = this.inactiveConnectors.indexOf(attribute.key); - if (activeIndex !== -1) { - this.activeConnectors.splice(activeIndex, 1); - } - if (inactiveIndex !== -1) { - this.inactiveConnectors.splice(inactiveIndex, 1); - } - tasks.push(this.attributeService.saveEntityAttributes(this.device, scope, [{ - key: scope === AttributeScope.SHARED_SCOPE ? 'active_connectors' : 'inactive_connectors', - value: scope === AttributeScope.SHARED_SCOPE ? this.activeConnectors : this.inactiveConnectors - }])); - forkJoin(tasks).subscribe(() => { - if (this.initialConnector ? this.initialConnector.name === attribute.key : true) { - this.clearOutConnectorForm(); - this.cd.detectChanges(); - this.connectorForm.disable(); - } - this.updateData(true); - }); + this.removeConnectorFromList(attribute.key, true); + this.removeConnectorFromList(attribute.key, false); + tasks.push(this.getSaveEntityAttributesTask(scope)); + + return forkJoin(tasks); + }) + ).subscribe(() => { + if (this.initialConnector ? this.initialConnector.name === attribute.key : true) { + this.clearOutConnectorForm(); + this.cd.detectChanges(); + this.connectorForm.disable(); } + this.updateData(true); }); } - connectorLogs(attribute: AttributeData, $event: Event): void { + connectorLogs(attribute: GatewayAttributeData, $event: Event): void { if ($event) { $event.stopPropagation(); } @@ -527,7 +430,7 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie this.ctx.stateController.openState('connector_logs', params); } - connectorRpc(attribute: AttributeData, $event: Event): void { + connectorRpc(attribute: GatewayAttributeData, $event: Event): void { if ($event) { $event.stopPropagation(); } @@ -538,7 +441,7 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie } - onEnableConnector(attribute: AttributeData): void { + onEnableConnector(attribute: GatewayAttributeData): void { attribute.value.ts = new Date().getTime(); this.updateActiveConnectorKeys(attribute.key); @@ -546,58 +449,152 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie this.attributeUpdateSubject.next(attribute); } - getErrorsCount(attribute: AttributeData): string { + getErrorsCount(attribute: GatewayAttributeData): string { const connectorName = attribute.key; const connector = this.subscription && this.subscription.data .find(data => data && data.dataKey.name === `${connectorName}_ERRORS_COUNT`); return (connector && this.activeConnectors.includes(connectorName)) ? (connector.data[0][1] || 0) : 'Inactive'; } - addConnector($event: Event) { - if ($event) { - $event.stopPropagation(); - } - this.confirmConnectorChange().subscribe((changeConfirmed) => { - if (changeConfirmed) { - return this.dialog.open(AddConnectorDialogComponent, { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], - data: { - dataSourceData: this.dataSource.data - } - }).afterClosed().subscribe((value) => { - if (value && changeConfirmed) { - this.initialConnector = null; - if (this.connectorForm.disabled) { - this.connectorForm.enable(); - } - if (!value.configurationJson) { - value.configurationJson = {}; - } - value.basicConfig = value.configurationJson; - if (value.type === ConnectorType.MQTT) { - this.addMQTTConfigControls(); - } else { - this.connectorForm.setControl('basicConfig', this.fb.group({}), {emitEvent: false}); - } - this.connectorForm.patchValue(value, {emitEvent: false}); - this.generate('basicConfig.broker.clientId'); - this.saveConnector(); - } - }); - } + addConnector(event?: Event): void { + event?.stopPropagation(); + + this.confirmConnectorChange() + .pipe( + take(1), + filter(Boolean), + switchMap(() => this.openAddConnectorDialog()), + filter(Boolean), + ) + .subscribe(value => { + this.initialConnector = null; + if (this.connectorForm.disabled) { + this.connectorForm.enable(); + } + if (!value.configurationJson) { + value.configurationJson = {} as ConnectorBaseConfig; + } + value.basicConfig = value.configurationJson; + this.updateConnector(value); + this.generate('basicConfig.broker.clientId'); + setTimeout(() => this.saveConnector()); }); } + private openAddConnectorDialog(): Observable { + return this.dialog.open(AddConnectorDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { dataSourceData: this.dataSource.data } + }).afterClosed(); + } + generate(formControlName: string): void { this.connectorForm.get(formControlName)?.patchValue('tb_gw_' + generateSecret(5)); } + uniqNameRequired(): ValidatorFn { + return (control: UntypedFormControl) => { + const newName = control.value?.trim().toLowerCase(); + const isDuplicate = this.dataSource.data.some(connectorAttr => connectorAttr.value.name.toLowerCase() === newName); + const isSameAsInitial = this.initialConnector?.name.toLowerCase() === newName; + + if (isDuplicate && !isSameAsInitial) { + return { duplicateName: { valid: false } }; + } + + return null; + }; + } + + private initDataSources(): void { + const sortOrder: SortOrder = {property: 'key', direction: Direction.ASC}; + this.pageLink = new PageLink(1000, 0, null, sortOrder); + this.attributeDataSource = new AttributeDatasource(this.attributeService, this.telemetryWsService, this.zone, this.translate); + this.inactiveConnectorsDataSource = new AttributeDatasource(this.attributeService, this.telemetryWsService, this.zone, this.translate); + this.serverDataSource = new AttributeDatasource(this.attributeService, this.telemetryWsService, this.zone, this.translate); + this.dataSource = new MatTableDataSource([]); + } + + private initConnectorForm(): void { + this.connectorForm = this.fb.group({ + mode: [ConnectorConfigurationModes.BASIC], + name: ['', [Validators.required, this.uniqNameRequired(), Validators.pattern(noLeadTrailSpacesRegex)]], + type: ['', [Validators.required]], + enableRemoteLogging: [false], + logLevel: ['', [Validators.required]], + sendDataOnlyOnChange: [false], + key: ['auto'], + class: [''], + configuration: [''], + configurationJson: [{}, [Validators.required]], + basicConfig: [{}] + }); + this.connectorForm.disable(); + } + + private observeName(): void { + this.connectorForm.get('name').valueChanges + .pipe( + filter(() => this.connectorForm.get('type').value === ConnectorType.MQTT), + takeUntil(this.destroy$) + ) + .subscribe(name => this.connectorForm.get('basicConfig').get('broker.name')?.setValue(name)); + } + + private getSortingDataAccessor(): (data: GatewayAttributeData, sortHeaderId: string) => string | number { + return (data: GatewayAttributeData, sortHeaderId: string) => { + switch (sortHeaderId) { + case 'syncStatus': + return this.isConnectorSynced(data) ? 1 : 0; + + case 'enabled': + return this.activeConnectors.includes(data.key) ? 1 : 0; + + case 'errors': + const errors = this.getErrorsCount(data); + if (typeof errors === 'string') { + return this.sort.direction.toUpperCase() === Direction.DESC ? -1 : Infinity; + } + return errors; + + default: + return data[sortHeaderId] || data.value[sortHeaderId]; + } + }; + } + + private loadConnectors(): void { + if (!this.device || this.device.id === NULL_UUID) { + return; + } + + forkJoin([ + this.attributeService.getEntityAttributes(this.device, AttributeScope.SHARED_SCOPE, ['active_connectors']), + this.attributeService.getEntityAttributes(this.device, AttributeScope.SERVER_SCOPE, ['inactive_connectors']) + ]).pipe(takeUntil(this.destroy$)).subscribe(attributes => { + this.activeConnectors = this.parseConnectors(attributes[0]); + this.inactiveConnectors = this.parseConnectors(attributes[1]); + + this.updateData(true); + }); + } + + private parseConnectors(attribute: GatewayAttributeData[]): string[] { + const connectors = attribute?.[0]?.value || []; + return isString(connectors) ? JSON.parse(connectors) : connectors; + } + + private observeModeChange(): void { + this.connectorForm.get('mode').valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.connectorForm.get('mode').markAsPristine()); + } + private observeAttributeChange(): void { this.attributeUpdateSubject.pipe( debounceTime(300), - tap((attribute: AttributeData) => this.executeAttributeUpdates(attribute)), + tap((attribute: GatewayAttributeData) => this.executeAttributeUpdates(attribute)), takeUntil(this.destroy$), ).subscribe(); } @@ -606,16 +603,20 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie const wasEnabled = this.activeConnectors.includes(key); if (wasEnabled) { const index = this.activeConnectors.indexOf(key); - if (index !== -1) this.activeConnectors.splice(index, 1); + if (index !== -1) { + this.activeConnectors.splice(index, 1); + } this.inactiveConnectors.push(key); } else { const index = this.inactiveConnectors.indexOf(key); - if (index !== -1) this.inactiveConnectors.splice(index, 1); + if (index !== -1) { + this.inactiveConnectors.splice(index, 1); + } this.activeConnectors.push(key); } } - private executeAttributeUpdates(attribute: AttributeData): void { + private executeAttributeUpdates(attribute: GatewayAttributeData): void { forkJoin(this.getAttributeExecutionTasks(attribute)) .pipe( take(1), @@ -625,7 +626,7 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie .subscribe(); } - private getAttributeExecutionTasks(attribute: AttributeData): Observable[] { + private getAttributeExecutionTasks(attribute: GatewayAttributeData): Observable[] { const isActive = this.activeConnectors.includes(attribute.key); const scopeOld = isActive ? AttributeScope.SERVER_SCOPE : AttributeScope.SHARED_SCOPE; const scopeNew = isActive ? AttributeScope.SHARED_SCOPE : AttributeScope.SERVER_SCOPE; @@ -640,7 +641,8 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie value: this.inactiveConnectors }]), this.attributeService.deleteEntityAttributes(this.device, scopeOld, [attribute]), - this.attributeService.saveEntityAttributes(this.device, scopeNew, [attribute])]; + this.attributeService.saveEntityAttributes(this.device, scopeNew, [attribute]) + ]; } private onDataUpdateError(e: any): void { @@ -678,50 +680,40 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie } } - private addMQTTConfigControls(): void { - const configControl = this.fb.group({}); - const brokerGroup = this.fb.group({ - name: ['', []], - host: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - port: [null, [Validators.required, Validators.min(PortLimits.MIN), Validators.max(PortLimits.MAX)]], - version: [5, []], - clientId: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], - maxNumberOfWorkers: [100, [Validators.required, Validators.min(1)]], - maxMessageNumberPerWorker: [10, [Validators.required, Validators.min(1)]], - security: [{}, [Validators.required]] - }); - configControl.addControl('broker', brokerGroup); - configControl.addControl('dataMapping', this.fb.control([], Validators.required)); - configControl.addControl('requestsMapping', this.fb.control({})); - if (this.connectorForm.get('basicConfig')) { - this.connectorForm.setControl('basicConfig', configControl, {emitEvent: false}); - } else { - this.connectorForm.addControl('basicConfig', configControl, {emitEvent: false}); - } - this.createBasicConfigWatcher(); - } - private createBasicConfigWatcher(): void { if (this.basicConfigSub) { this.basicConfigSub.unsubscribe(); } this.basicConfigSub = this.connectorForm.get('basicConfig').valueChanges.pipe( + filter(() => !!this.initialConnector), takeUntil(this.destroy$) ).subscribe((config) => { const configJson = this.connectorForm.get('configurationJson'); const type = this.connectorForm.get('type').value; const mode = this.connectorForm.get('mode').value; - if ( - !isEqual(config, configJson?.value) && - type === ConnectorType.MQTT && - mode === ConnectorConfigurationModes.BASIC - ) { - const newConfig = { ...configJson.value, ...config }; + if (!isEqual(config, configJson?.value) && this.allowBasicConfig.has(type) && mode === ConnectorConfigurationModes.BASIC) { + const newConfig = {...configJson.value, ...config}; this.connectorForm.get('configurationJson').patchValue(newConfig, {emitEvent: false}); } }); } + private createJsonConfigWatcher(): void { + if (this.jsonConfigSub) { + this.jsonConfigSub.unsubscribe(); + } + this.jsonConfigSub = this.connectorForm.get('configurationJson').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((config) => { + const basicConfig = this.connectorForm.get('basicConfig'); + const type = this.connectorForm.get('type').value; + const mode = this.connectorForm.get('mode').value; + if (!isEqual(config, basicConfig?.value) && this.allowBasicConfig.has(type) && mode === ConnectorConfigurationModes.ADVANCED) { + this.connectorForm.get('basicConfig').patchValue(config, {emitEvent: false}); + } + }); + } + private confirmConnectorChange(): Observable { if (this.initialConnector && this.connectorForm.dirty) { return this.dialogService.confirm( @@ -739,30 +731,39 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie if (this.connectorForm.disabled) { this.connectorForm.enable(); } - if (!connector.configuration) { - connector.configuration = ''; - } - if (!connector.key) { - connector.key = 'auto'; - } - if (!connector.configurationJson) { - connector.configurationJson = {}; - } - connector.basicConfig = connector.configurationJson; - this.initialConnector = connector; + const connectorState = { + configuration: '', + key: 'auto', + configurationJson: {} as ConnectorBaseConfig, + ...connector, + }; - if (connector.type === ConnectorType.MQTT) { - this.addMQTTConfigControls(); - } else { - this.connectorForm.setControl('basicConfig', this.fb.group({}), {emitEvent: false}); + connectorState.basicConfig = connectorState.configurationJson; + this.initialConnector = connectorState; + this.updateConnector(connectorState); + } + + private updateConnector(connector: GatewayConnector): void { + switch (connector.type) { + case ConnectorType.MQTT: + case ConnectorType.OPCUA: + case ConnectorType.MODBUS: + this.connectorForm.get('mode').setValue(connector.mode || ConnectorConfigurationModes.BASIC, {emitEvent: false}); + setTimeout(() => { + this.connectorForm.patchValue(connector, {emitEvent: false}); + this.connectorForm.markAsPristine(); + this.createBasicConfigWatcher(); + }); + break; + default: + this.connectorForm.patchValue({...connector, mode: null}); + this.connectorForm.markAsPristine(); } - - this.connectorForm.patchValue(connector, {emitEvent: false}); - this.connectorForm.markAsPristine(); + this.createJsonConfigWatcher(); } - private setClientData(data: PageData): void { + private setClientData(data: PageData): void { if (this.initialConnector) { const clientConnectorData = data.data.find(attr => attr.key === this.initialConnector.name); if (clientConnectorData) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-logs.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-logs.component.ts index d029279c2b..5fba27f86c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-logs.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-logs.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; import { MatDialogRef } from '@angular/material/dialog'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { PageLink } from '@shared/models/page/page-link'; @@ -30,7 +30,7 @@ import { GatewayLogData, GatewayStatus, LogLink } from './gateway-widget.models' templateUrl: './gateway-logs.component.html', styleUrls: ['./gateway-logs.component.scss'] }) -export class GatewayLogsComponent implements AfterViewInit { +export class GatewayLogsComponent implements OnInit, AfterViewInit { pageLink: PageLink; @@ -81,6 +81,10 @@ export class GatewayLogsComponent implements AfterViewInit { this.dataSource = new MatTableDataSource([]); } + ngOnInit(): void { + this.updateWidgetTitle(); + } + ngAfterViewInit() { this.dataSource.sort = this.sort; this.dataSource.paginator = this.paginator; @@ -107,6 +111,17 @@ export class GatewayLogsComponent implements AfterViewInit { this.changeSubscription(); } + private updateWidgetTitle(): void { + if (this.ctx.settings.isConnectorLog && this.ctx.settings.connectorLogState) { + const widgetTitle = this.ctx.widgetConfig.title; + const titlePlaceholder = '${connectorName}'; + if (widgetTitle.includes(titlePlaceholder)) { + const connector = this.ctx.stateController.getStateParams()[this.ctx.settings.connectorLogState]; + this.ctx.widgetTitle = widgetTitle.replace(titlePlaceholder, connector.key); + } + } + } + private updateData() { if (this.ctx.defaultSubscription.data.length && this.ctx.defaultSubscription.data[0]) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc-connector-templates.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc-connector-templates.component.html index 2fdfcfbf0a..ee644c54c7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc-connector-templates.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc-connector-templates.component.html @@ -32,7 +32,7 @@ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc-connector.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc-connector.component.html index 3b590e3872..3e94ae96d7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc-connector.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc-connector.component.html @@ -31,15 +31,15 @@ - + {{ 'gateway.rpc.withResponse' | translate }} - + {{ 'gateway.rpc.responseTopicExpression' | translate }} - + {{ 'gateway.rpc.responseTimeout' | translate }} @@ -50,46 +50,6 @@ placeholder="${params}"/> - - - {{ 'gateway.rpc.tag' | translate }} - - -
- - {{ 'gateway.rpc.type' | translate }} - - - {{ type }} - - - - - {{ 'gateway.rpc.functionCode' | translate }} - - - {{ modbusCodesTranslate.get(code) | translate}} - - - -
- - {{ 'gateway.rpc.value' | translate }} - - -
- - {{ 'gateway.rpc.address' | translate }} - - - - {{ 'gateway.rpc.objectsCount' | translate }} - - -
-
{{ 'gateway.rpc.methodRPC' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc-connector.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc-connector.component.ts index c515cbc201..989f9daeed 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc-connector.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc-connector.component.ts @@ -35,8 +35,6 @@ import { ConnectorType, GatewayConnectorDefaultTypesTranslatesMap, HTTPMethods, - ModbusCodesTranslate, - ModbusCommandTypes, noLeadTrailSpacesRegex, RPCCommand, RPCTemplateConfig, @@ -80,10 +78,7 @@ export class GatewayServiceRPCConnectorComponent implements OnInit, OnDestroy, C saveTemplate: EventEmitter = new EventEmitter(); commandForm: FormGroup; - isMQTTWithResponse: FormControl; - codesArray: Array = [1, 2, 3, 4, 5, 6, 15, 16]; ConnectorType = ConnectorType; - modbusCommandTypes = Object.values(ModbusCommandTypes) as ModbusCommandTypes[]; bACnetRequestTypes = Object.values(BACnetRequestTypes) as BACnetRequestTypes[]; bACnetObjectTypes = Object.values(BACnetObjectTypes) as BACnetObjectTypes[]; bLEMethods = Object.values(BLEMethods) as BLEMethods[]; @@ -99,7 +94,6 @@ export class GatewayServiceRPCConnectorComponent implements OnInit, OnDestroy, C SocketMethodProcessingsTranslates = SocketMethodProcessingsTranslates; SNMPMethodsTranslations = SNMPMethodsTranslations; gatewayConnectorDefaultTypesTranslates = GatewayConnectorDefaultTypesTranslatesMap; - modbusCodesTranslate = ModbusCodesTranslate; urlPattern = /^[-a-zA-Zd_$:{}?~+=\/.0-9-]*$/; numbersOnlyPattern = /^[0-9]*$/; @@ -135,7 +129,6 @@ export class GatewayServiceRPCConnectorComponent implements OnInit, OnDestroy, C this.propagateChange({...this.commandForm.value, ...value}); } }); - this.isMQTTWithResponse = this.fb.control(false); this.observeMQTTWithResponse(); } @@ -153,29 +146,10 @@ export class GatewayServiceRPCConnectorComponent implements OnInit, OnDestroy, C methodFilter: [null, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], requestTopicExpression: [null, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], responseTopicExpression: [{ value: null, disabled: true }, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - responseTimeout: [null, [Validators.min(10), Validators.pattern(this.numbersOnlyPattern)]], + responseTimeout: [{ value: null, disabled: true }, [Validators.min(10), Validators.pattern(this.numbersOnlyPattern)]], valueExpression: [null, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - }) - break; - case ConnectorType.MODBUS: - formGroup = this.fb.group({ - tag: [null, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], - type: [null, [Validators.required]], - functionCode: [null, [Validators.required]], - value: [null, []], - address: [null, [Validators.required, Validators.min(0), Validators.pattern(this.numbersOnlyPattern)]], - objectsCount: [null, [Validators.required, Validators.min(0), Validators.pattern(this.numbersOnlyPattern)]] - }) - const valueForm = formGroup.get('value'); - formGroup.get('functionCode').valueChanges.subscribe(value => { - if (value > 4) { - valueForm.addValidators([Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]); - } else { - valueForm.clearValidators(); - valueForm.setValue(null); - } - valueForm.updateValueAndValidity(); - }) + withResponse: [false, []], + }); break; case ConnectorType.BACNET: formGroup = this.fb.group({ @@ -407,12 +381,21 @@ export class GatewayServiceRPCConnectorComponent implements OnInit, OnDestroy, C } private observeMQTTWithResponse(): void { - this.isMQTTWithResponse.valueChanges.pipe( - tap((isActive: boolean) => { - const responseControl = this.commandForm.get('responseTopicExpression'); - isActive ? responseControl.enable() : responseControl.disable(); - }), - takeUntil(this.destroy$), - ).subscribe(); + if (this.connectorType === ConnectorType.MQTT) { + this.commandForm.get('withResponse').valueChanges.pipe( + tap((isActive: boolean) => { + const responseTopicControl = this.commandForm.get('responseTopicExpression'); + const responseTimeoutControl = this.commandForm.get('responseTimeout'); + if (isActive) { + responseTopicControl.enable(); + responseTimeoutControl.enable(); + } else { + responseTopicControl.disable(); + responseTimeoutControl.disable(); + } + }), + takeUntil(this.destroy$), + ).subscribe(); + } } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc.component.html index 8368698087..41dea0b656 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc.component.html @@ -41,8 +41,32 @@ - + + +
+
{{ 'gateway.rpc.title' | translate: {type: gatewayConnectorDefaultTypesTranslates.get(connectorType)} }}
+ +
+ + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc.component.scss index f74097bbba..b8424dc068 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc.component.scss @@ -40,6 +40,10 @@ } } + .rpc-parameters { + width: 100%; + } + .result-block { padding: 0 5px; display: flex; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc.component.ts index dadd1a38d2..d6b20d0137 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-service-rpc.component.ts @@ -22,6 +22,7 @@ import { ContentType } from '@shared/models/constants'; import { jsonRequired } from '@shared/components/json-object-edit.component'; import { ConnectorType, + GatewayConnectorDefaultTypesTranslatesMap, RPCCommand, RPCTemplate, RPCTemplateConfig, @@ -71,6 +72,9 @@ export class GatewayServiceRPCComponent implements OnInit { public connectorType: ConnectorType; public templates: Array = []; + readonly ConnectorType = ConnectorType; + readonly gatewayConnectorDefaultTypesTranslates = GatewayConnectorDefaultTypesTranslatesMap; + private subscription: IWidgetSubscription; private subscriptionOptions: WidgetSubscriptionOptions = { callbacks: { @@ -206,9 +210,6 @@ export class GatewayServiceRPCComponent implements OnInit { private updateTemplates() { this.templates = this.subscription.data[0].data[0][1].length ? JSON.parse(this.subscription.data[0].data[0][1]) : []; - if (this.templates.length && this.commandForm.get('params').value == "{}") { - this.commandForm.get('params').patchValue(this.templates[0].config); - } this.cd.detectChanges(); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.html index 4abf67a2d1..5dbc0aee00 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.html @@ -17,7 +17,7 @@ -->
- + {{ 'gateway.statistics.statistic' | translate }} @@ -30,8 +30,13 @@ - {{'gateway.statistics.statistic-commands-empty' | translate }} + {{ 'gateway.statistics.statistic-commands-empty' | translate }} +
+ +
{{ 'gateway.statistics.command' | translate }} @@ -43,13 +48,16 @@ matSort [matSortActive]="pageLink.sortOrder.property" [matSortDirection]="pageLink.sortDirection()" matSortDisableClear> - {{ 'widgets.gateway.created-time' | translate }} + {{ 'widgets.gateway.created-time' | translate }} + - {{row[0]| date:'yyyy-MM-dd HH:mm:ss' }} + {{ row[0]| date:'yyyy-MM-dd HH:mm:ss' }} - {{ 'widgets.gateway.message' | translate }} + {{ 'widgets.gateway.message' | translate }} + {{ row[1] }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.scss index cd7722d12d..9f719807ca 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.scss @@ -28,6 +28,7 @@ height: 100%; margin-right: 35px; padding: 15px; + gap: 22px; } @media only screen and (max-width: 750px) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.ts index a3cb79e125..b17b8ef28a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.ts @@ -32,6 +32,7 @@ import { Direction, SortOrder } from '@shared/models/page/sort-order'; import { MatTableDataSource } from '@angular/material/table'; import { MatSort } from '@angular/material/sort'; import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { deepClone } from '@core/utils'; @Component({ selector: 'tb-gateway-statistics', @@ -135,6 +136,11 @@ export class GatewayStatisticsComponent implements AfterViewInit { } } + public navigateToStatistics() { + const params = deepClone(this.ctx.stateController.getStateParams()); + this.ctx.stateController.openState('configuration', params); + } + public sortData() { this.dataSource.sortData(this.dataSource.data, this.sort); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-widget.models.ts index 095d14ae76..2dde0b8f54 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-widget.models.ts @@ -16,10 +16,11 @@ import { ResourcesService } from '@core/services/resources.service'; import { Observable } from 'rxjs'; -import { ValueTypeData } from '@shared/models/constants'; -import { Validators } from '@angular/forms'; +import { helpBaseUrl, ValueTypeData } from '@shared/models/constants'; +import { AttributeData } from '@shared/models/telemetry/telemetry.models'; -export const noLeadTrailSpacesRegex: RegExp = /^(?! )[\S\s]*(?( ] ); +export interface GatewayAttributeData extends AttributeData { + skipSync?: boolean; +} + export interface GatewayConnector { name: string; type: ConnectorType; configuration?: string; - configurationJson: string | {[key: string]: any}; - basicConfig?: string | {[key: string]: any}; + configurationJson: ConnectorBaseConfig; + basicConfig?: ConnectorBaseConfig; logLevel: string; key?: string; class?: string; + mode?: ConnectorConfigurationModes; +} + +export interface DataMapping { + topicFilter: string; + QoS: string; + converter: Converter; +} + +export interface RequestsMapping { + requestType: RequestType; + type: string; + details: string; +} + +export interface OpcUaMapping { + deviceNodePattern?: string; + deviceNamePattern?: string; + deviceProfileExpression?: string; +} + +export type MappingValue = DataMapping | RequestsMapping | OpcUaMapping; + +export interface ServerConfig { + name: string; + url: string; + timeoutInMillis: number; + scanPeriodInMillis: number; + pollPeriodInMillis: number; + enableSubscriptions: boolean; + subCheckPeriodInMillis: number; + showMap: boolean; + security: string; + identity: ConnectorSecurity; +} + +export interface BrokerConfig { + name: string; + host: string; + port: number; + version: number; + clientId: string; + maxNumberOfWorkers: number; + maxMessageNumberPerWorker: number; + security: ConnectorSecurity; +} + +export interface ConnectorSecurity { + type: SecurityType; + username?: string; + password?: string; + pathToCACert?: string; + pathToPrivateKey?: string; + pathToClientCert?: string; + mode?: ModeType; +} + +export type ConnectorMapping = DeviceConnectorMapping | RequestMappingData | ConverterConnectorMapping; + +export type ConnectorMappingFormValue = DeviceConnectorMapping | RequestMappingFormValue | ConverterMappingFormValue; + +export type ConnectorBaseConfig = ConnectorBaseInfo | MQTTBasicConfig | OPCBasicConfig | ModbusBasicConfig; + +export interface ConnectorBaseInfo { + name: string; + id: string; + enableRemoteLogging: boolean; + logLevel: GatewayLogLevel; +} + +export interface MQTTBasicConfig { + dataMapping: ConverterConnectorMapping[]; + requestsMapping: Record | RequestMappingData[]; + broker: BrokerConfig; + workers?: WorkersConfig; +} + +export interface OPCBasicConfig { + mapping: DeviceConnectorMapping[]; + server: ServerConfig; +} + +export interface ModbusBasicConfig { + master: ModbusMasterConfig; + slave: ModbusSlave; +} + +export interface WorkersConfig { + maxNumberOfWorkers: number; + maxMessageNumberPerWorker: number; +} + +interface DeviceInfo { + deviceNameExpression: string; + deviceNameExpressionSource: string; + deviceProfileExpression: string; + deviceProfileExpressionSource: string; +} + +export interface Attribute { + key: string; + type: string; + value: string; +} + +export interface Timeseries { + key: string; + type: string; + value: string; +} + +interface RpcArgument { + type: string; + value: number; +} + +export interface RpcMethod { + method: string; + arguments: RpcArgument[]; +} + +export interface AttributesUpdate { + key: string; + type: string; + value: string; +} + +export interface Converter { + type: ConvertorType; + deviceNameJsonExpression: string; + deviceTypeJsonExpression: string; + sendDataOnlyOnChange: boolean; + timeout: number; + attributes: Attribute[]; + timeseries: Timeseries[]; +} + +export interface ConverterConnectorMapping { + topicFilter: string; + subscriptionQos?: string; + converter: Converter; +} + +export type ConverterMappingFormValue = Omit & { + converter: { + type: ConvertorType; + } & Record; +}; + +export interface DeviceConnectorMapping { + deviceNodePattern: string; + deviceNodeSource: string; + deviceInfo: DeviceInfo; + attributes?: Attribute[]; + timeseries?: Timeseries[]; + rpc_methods?: RpcMethod[]; + attributes_updates?: AttributesUpdate[]; } export enum ConnectorType { @@ -159,40 +321,21 @@ export const GatewayConnectorDefaultTypesTranslatesMap = new Map([ - [1, 'gateway.rpc.read-coils'], - [2, 'gateway.rpc.read-discrete-inputs'], - [3, 'gateway.rpc.read-multiple-holding-registers'], - [4, 'gateway.rpc.read-input-registers'], - [5, 'gateway.rpc.write-single-coil'], - [6, 'gateway.rpc.write-single-holding-register'], - [15, 'gateway.rpc.write-multiple-coils'], - [16, 'gateway.rpc.write-multiple-holding-registers'] -]) + command: string; + params: any; + time: number; +} + +export const ModbusFunctionCodeTranslationsMap = new Map([ + [1, 'gateway.function-codes.read-coils'], + [2, 'gateway.function-codes.read-discrete-inputs'], + [3, 'gateway.function-codes.read-multiple-holding-registers'], + [4, 'gateway.function-codes.read-input-registers'], + [5, 'gateway.function-codes.write-single-coil'], + [6, 'gateway.function-codes.write-single-holding-register'], + [15, 'gateway.function-codes.write-multiple-coils'], + [16, 'gateway.function-codes.write-multiple-holding-registers'] +]); export enum BACnetRequestTypes { WriteProperty = 'writeProperty', @@ -201,8 +344,8 @@ export enum BACnetRequestTypes { export const BACnetRequestTypesTranslates = new Map([ [BACnetRequestTypes.WriteProperty, 'gateway.rpc.write-property'], - [BACnetRequestTypes.ReadProperty, "gateway.rpc.read-property"] -]) + [BACnetRequestTypes.ReadProperty, 'gateway.rpc.read-property'] +]); export enum BACnetObjectTypes { BinaryInput = 'binaryInput', @@ -220,7 +363,7 @@ export const BACnetObjectTypesTranslates = new Map([ [BACnetObjectTypes.BinaryInput, 'gateway.rpc.binary-input'], [BACnetObjectTypes.BinaryValue, 'gateway.rpc.binary-value'], [BACnetObjectTypes.AnalogValue, 'gateway.rpc.analog-value'] -]) +]); export enum BLEMethods { WRITE = 'write', @@ -232,7 +375,7 @@ export const BLEMethodsTranslates = new Map([ [BLEMethods.WRITE, 'gateway.rpc.write'], [BLEMethods.READ, 'gateway.rpc.read'], [BLEMethods.SCAN, 'gateway.rpc.scan'], -]) +]); export enum CANByteOrders { LITTLE = 'LITTLE', @@ -245,18 +388,18 @@ export enum SocketMethodProcessings { export const SocketMethodProcessingsTranslates = new Map([ [SocketMethodProcessings.WRITE, 'gateway.rpc.write'] -]) +]); export enum SNMPMethods { SET = 'set', - MULTISET = "multiset", - GET = "get", - BULKWALK = "bulkwalk", - TABLE = "table", - MULTIGET = "multiget", - GETNEXT = "getnext", - BULKGET = "bulkget", - WALKS = "walk" + MULTISET = 'multiset', + GET = 'get', + BULKWALK = 'bulkwalk', + TABLE = 'table', + MULTIGET = 'multiget', + GETNEXT = 'getnext', + BULKGET = 'bulkget', + WALKS = 'walk' } export const SNMPMethodsTranslations = new Map([ @@ -269,7 +412,7 @@ export const SNMPMethodsTranslations = new Map([ [SNMPMethods.GETNEXT, 'gateway.rpc.get-next'], [SNMPMethods.BULKGET, 'gateway.rpc.bulk-get'], [SNMPMethods.WALKS, 'gateway.rpc.walk'] -]) +]); export enum HTTPMethods { CONNECT = 'CONNECT', @@ -298,8 +441,8 @@ export interface RPCTemplateConfig { } export interface SaveRPCTemplateData { - config: RPCTemplateConfig, - templates: Array + config: RPCTemplateConfig; + templates: Array; } export interface LogLink { @@ -316,27 +459,38 @@ export interface GatewayLogData { } export interface AddConnectorConfigData { - dataSourceData: Array + dataSourceData: Array; } export interface CreatedConnectorConfigData { - type: ConnectorType, - name: string, - logLevel: GatewayLogLevel, - useDefaults: boolean, - sendDataOnlyOnChange: boolean, - configurationJson?: {[key: string]: any} + type: ConnectorType; + name: string; + logLevel: GatewayLogLevel; + useDefaults: boolean; + sendDataOnlyOnChange: boolean; + configurationJson?: {[key: string]: any}; } export interface MappingDataKey { - key: string, - value: any, - type: MappingValueType + key: string; + value: any; + type: MappingValueType; +} + +export interface RpcMethodsMapping { + method: string; + arguments: Array; } + export interface MappingInfo { - mappingType: MappingType, - value: {[key: string]: any}, - buttonTitle: string + mappingType: MappingType; + value: {[key: string]: any}; + buttonTitle: string; +} + +export interface ModbusSlaveInfo { + value: SlaveConfig; + buttonTitle: string; } export enum ConnectorConfigurationModes { @@ -344,17 +498,23 @@ export enum ConnectorConfigurationModes { ADVANCED = 'advanced' } -export enum BrokerSecurityType { +export enum SecurityType { ANONYMOUS = 'anonymous', BASIC = 'basic', CERTIFICATES = 'certificates' } -export const BrokerSecurityTypeTranslationsMap = new Map( +export enum ModeType { + NONE = 'None', + SIGN = 'Sign', + SIGNANDENCRYPT = 'SignAndEncrypt' +} + +export const SecurityTypeTranslationsMap = new Map( [ - [BrokerSecurityType.ANONYMOUS, 'gateway.broker.security-types.anonymous'], - [BrokerSecurityType.BASIC, 'gateway.broker.security-types.basic'], - [BrokerSecurityType.CERTIFICATES, 'gateway.broker.security-types.certificates'] + [SecurityType.ANONYMOUS, 'gateway.broker.security-types.anonymous'], + [SecurityType.BASIC, 'gateway.broker.security-types.basic'], + [SecurityType.CERTIFICATES, 'gateway.broker.security-types.certificates'] ] ); @@ -378,23 +538,34 @@ export const MqttVersions = [ export enum MappingType { DATA = 'data', - REQUESTS = 'requests' + REQUESTS = 'requests', + OPCUA = 'OPCua' } export const MappingTypeTranslationsMap = new Map( [ [MappingType.DATA, 'gateway.data-mapping'], - [MappingType.REQUESTS, 'gateway.requests-mapping'] + [MappingType.REQUESTS, 'gateway.requests-mapping'], + [MappingType.OPCUA, 'gateway.data-mapping'] ] ); export const MappingHintTranslationsMap = new Map( [ [MappingType.DATA, 'gateway.data-mapping-hint'], + [MappingType.OPCUA, 'gateway.opcua-data-mapping-hint'], [MappingType.REQUESTS, 'gateway.requests-mapping-hint'] ] ); +export const HelpLinkByMappingTypeMap = new Map( + [ + [MappingType.DATA, helpBaseUrl + '/docs/iot-gateway/config/mqtt/#section-mapping'], + [MappingType.OPCUA, helpBaseUrl + '/docs/iot-gateway/config/opc-ua/#section-mapping'], + [MappingType.REQUESTS, helpBaseUrl + '/docs/iot-gateway/config/mqtt/#section-mapping'] + ] +); + export const QualityTypes = [0, 1 ,2]; export const QualityTypeTranslationsMap = new Map( @@ -425,19 +596,46 @@ export enum SourceTypes { CONST = 'constant' } +export enum OPCUaSourceTypes { + PATH = 'path', + IDENTIFIER = 'identifier', + CONST = 'constant' +} + export enum DeviceInfoType { FULL = 'full', PARTIAL = 'partial' } -export const SourceTypeTranslationsMap = new Map( +export const SourceTypeTranslationsMap = new Map( [ [SourceTypes.MSG, 'gateway.source-type.msg'], [SourceTypes.TOPIC, 'gateway.source-type.topic'], [SourceTypes.CONST, 'gateway.source-type.const'], + [OPCUaSourceTypes.PATH, 'gateway.source-type.path'], + [OPCUaSourceTypes.IDENTIFIER, 'gateway.source-type.identifier'], + [OPCUaSourceTypes.CONST, 'gateway.source-type.const'] ] ); +export interface RequestMappingData { + requestType: RequestType; + requestValue: RequestDataItem; +} + +export type RequestMappingFormValue = Omit & { + requestValue: Record; +}; + +export interface RequestDataItem { + type: string; + details: string; + requestType: RequestType; + methodFilter?: string; + attributeFilter?: string; + topicFilter?: string; +} + export enum RequestType { CONNECT_REQUEST = 'connectRequests', DISCONNECT_REQUEST = 'disconnectRequests', @@ -459,14 +657,18 @@ export const RequestTypesTranslationsMap = new Map( export enum MappingKeysType { ATTRIBUTES = 'attributes', TIMESERIES = 'timeseries', - CUSTOM = 'extensionConfig' + CUSTOM = 'extensionConfig', + RPC_METHODS = 'rpc_methods', + ATTRIBUTES_UPDATES = 'attributes_updates' } export const MappingKeysPanelTitleTranslationsMap = new Map( [ [MappingKeysType.ATTRIBUTES, 'gateway.attributes'], [MappingKeysType.TIMESERIES, 'gateway.timeseries'], - [MappingKeysType.CUSTOM, 'gateway.keys'] + [MappingKeysType.CUSTOM, 'gateway.keys'], + [MappingKeysType.ATTRIBUTES_UPDATES, 'gateway.attribute-updates'], + [MappingKeysType.RPC_METHODS, 'gateway.rpc-methods'] ] ); @@ -474,7 +676,9 @@ export const MappingKeysAddKeyTranslationsMap = new Map [ [MappingKeysType.ATTRIBUTES, 'gateway.add-attribute'], [MappingKeysType.TIMESERIES, 'gateway.add-timeseries'], - [MappingKeysType.CUSTOM, 'gateway.add-key'] + [MappingKeysType.CUSTOM, 'gateway.add-key'], + [MappingKeysType.ATTRIBUTES_UPDATES, 'gateway.add-attribute-update'], + [MappingKeysType.RPC_METHODS, 'gateway.add-rpc-method'] ] ); @@ -482,7 +686,9 @@ export const MappingKeysDeleteKeyTranslationsMap = new Map => resourcesService.loadJsonResource(`/assets/metadata/connector-default-configs/${type}.json`); @@ -550,3 +757,260 @@ export const DataConversionTranslationsMap = new Map( [ConvertorType.CUSTOM, 'gateway.custom-hint'] ] ); + +export enum SecurityPolicy { + BASIC128 = 'Basic128Rsa15', + BASIC256 = 'Basic256', + BASIC256SHA = 'Basic256Sha256' +} + +export const SecurityPolicyTypes = [ + { value: SecurityPolicy.BASIC128, name: 'Basic128RSA15' }, + { value: SecurityPolicy.BASIC256, name: 'Basic256' }, + { value: SecurityPolicy.BASIC256SHA, name: 'Basic256SHA256' } +]; + +export enum ModbusProtocolType { + TCP = 'tcp', + UDP = 'udp', + Serial = 'serial', +} + +export const ModbusProtocolLabelsMap = new Map( + [ + [ModbusProtocolType.TCP, 'TCP'], + [ModbusProtocolType.UDP, 'UDP'], + [ModbusProtocolType.Serial, 'Serial'], + ] +); + +export enum ModbusMethodType { + SOCKET = 'socket', + RTU = 'rtu', +} + +export enum ModbusSerialMethodType { + RTU = 'rtu', + ASCII = 'ascii', +} + +export const ModbusMethodLabelsMap = new Map( + [ + [ModbusMethodType.SOCKET, 'Socket'], + [ModbusMethodType.RTU, 'RTU'], + [ModbusSerialMethodType.ASCII, 'ASCII'], + ] +); + +export const ModbusByteSizes = [5, 6, 7 ,8]; + +export enum ModbusParity { + Even = 'E', + Odd = 'O', + None = 'N' +} + +export const ModbusParityLabelsMap = new Map( + [ + [ModbusParity.Even, 'Even'], + [ModbusParity.Odd, 'Odd'], + [ModbusParity.None, 'None'], + ] +); + +export enum ModbusOrderType { + BIG = 'BIG', + LITTLE = 'LITTLE', +} + +export enum ModbusRegisterType { + HoldingRegisters = 'holding_registers', + CoilsInitializer = 'coils_initializer', + InputRegisters = 'input_registers', + DiscreteInputs = 'discrete_inputs' +} + +export const ModbusRegisterTranslationsMap = new Map( + [ + [ModbusRegisterType.HoldingRegisters, 'gateway.holding_registers'], + [ModbusRegisterType.CoilsInitializer, 'gateway.coils_initializer'], + [ModbusRegisterType.InputRegisters, 'gateway.input_registers'], + [ModbusRegisterType.DiscreteInputs, 'gateway.discrete_inputs'] + ] +); + +export enum ModbusDataType { + STRING = 'string', + BYTES = 'bytes', + BITS = 'bits', + INT8 = '8int', + UINT8 = '8uint', + FLOAT8 = '8float', + INT16 = '16int', + UINT16 = '16uint', + FLOAT16 = '16float', + INT32 = '32int', + UINT32 = '32uint', + FLOAT32 = '32float', + INT64 = '64int', + UINT64 = '64uint', + FLOAT64 = '64float' +} + +export const ModbusEditableDataTypes = [ModbusDataType.BYTES, ModbusDataType.BITS, ModbusDataType.STRING]; + +export enum ModbusObjectCountByDataType { + '8int' = 1, + '8uint' = 1, + '8float' = 1, + '16int' = 1, + '16uint' = 1, + '16float' = 1, + '32int' = 2, + '32uint' = 2, + '32float' = 2, + '64int' = 4, + '64uint' = 4, + '64float' = 4, +} + +export enum ModbusValueKey { + ATTRIBUTES = 'attributes', + TIMESERIES = 'timeseries', + ATTRIBUTES_UPDATES = 'attributeUpdates', + RPC_REQUESTS = 'rpc', +} + +export const ModbusKeysPanelTitleTranslationsMap = new Map( + [ + [ModbusValueKey.ATTRIBUTES, 'gateway.attributes'], + [ModbusValueKey.TIMESERIES, 'gateway.timeseries'], + [ModbusValueKey.ATTRIBUTES_UPDATES, 'gateway.attribute-updates'], + [ModbusValueKey.RPC_REQUESTS, 'gateway.rpc-requests'] + ] +); + +export const ModbusKeysAddKeyTranslationsMap = new Map( + [ + [ModbusValueKey.ATTRIBUTES, 'gateway.add-attribute'], + [ModbusValueKey.TIMESERIES, 'gateway.add-timeseries'], + [ModbusValueKey.ATTRIBUTES_UPDATES, 'gateway.add-attribute-update'], + [ModbusValueKey.RPC_REQUESTS, 'gateway.add-rpc-request'] + ] +); + +export const ModbusKeysDeleteKeyTranslationsMap = new Map( + [ + [ModbusValueKey.ATTRIBUTES, 'gateway.delete-attribute'], + [ModbusValueKey.TIMESERIES, 'gateway.delete-timeseries'], + [ModbusValueKey.ATTRIBUTES_UPDATES, 'gateway.delete-attribute-update'], + [ModbusValueKey.RPC_REQUESTS, 'gateway.delete-rpc-request'] + ] +); + +export const ModbusKeysNoKeysTextTranslationsMap = new Map( + [ + [ModbusValueKey.ATTRIBUTES, 'gateway.no-attributes'], + [ModbusValueKey.TIMESERIES, 'gateway.no-timeseries'], + [ModbusValueKey.ATTRIBUTES_UPDATES, 'gateway.no-attribute-updates'], + [ModbusValueKey.RPC_REQUESTS, 'gateway.no-rpc-requests'] + ] +); + +export interface ModbusMasterConfig { + slaves: SlaveConfig[]; +} + +export interface SlaveConfig { + name: string; + host?: string; + port: string | number; + serialPort?: string; + type: ModbusProtocolType; + method: ModbusMethodType; + timeout: number; + byteOrder: ModbusOrderType; + wordOrder: ModbusOrderType; + retries: boolean; + retryOnEmpty: boolean; + retryOnInvalid: boolean; + pollPeriod: number; + unitId: number; + deviceName: string; + deviceType: string; + sendDataOnlyOnChange: boolean; + connectAttemptTimeMs: number; + connectAttemptCount: number; + waitAfterFailedAttemptsMs: number; + attributes: ModbusValue[]; + timeseries: ModbusValue[]; + attributeUpdates: ModbusValue[]; + rpc: ModbusValue[]; + security?: ModbusSecurity; + baudrate?: number; + stopbits?: number; + bytesize?: number; + parity?: ModbusParity; + strict?: boolean; +} + +export interface ModbusValue { + tag: string; + type: ModbusDataType; + functionCode?: number; + objectsCount: number; + address: number; + value?: string; +} + +export interface ModbusSecurity { + certfile?: string; + keyfile?: string; + password?: string; + server_hostname?: string; + reqclicert?: boolean; +} + +export interface ModbusSlave { + host?: string; + type: ModbusProtocolType; + method: ModbusMethodType; + unitId: number; + serialPort?: string; + baudrate?: number; + deviceName: string; + deviceType: string; + pollPeriod: number; + sendDataToThingsBoard: boolean; + byteOrder: ModbusOrderType; + identity: ModbusIdentity; + values: ModbusValuesState; + port: string | number; + security: ModbusSecurity; +} + +export type ModbusValuesState = ModbusRegisterValues | ModbusValues; + +export interface ModbusRegisterValues { + holding_registers: ModbusValues; + coils_initializer: ModbusValues; + input_registers: ModbusValues; + discrete_inputs: ModbusValues; +} + +export interface ModbusValues { + attributes: ModbusValue[]; + timeseries: ModbusValue[]; + attributeUpdates: ModbusValue[]; + rpc: ModbusValue[]; +} + +export interface ModbusIdentity { + vendorName?: string; + productCode?: string; + vendorUrl?: string; + productName?: string; + modelName?: string; +} + +export const ModbusBaudrates = [4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600]; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/pipes/gateway-help-link.pipe.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/pipes/gateway-help-link.pipe.ts new file mode 100644 index 0000000000..0fec377860 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/pipes/gateway-help-link.pipe.ts @@ -0,0 +1,39 @@ +/// +/// 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 { Pipe, PipeTransform } from '@angular/core'; +import { + MappingValueType, + OPCUaSourceTypes, + SourceTypes +} from '@home/components/widget/lib/gateway/gateway-widget.models'; + +@Pipe({ + name: 'getGatewayHelpLink', + standalone: true, +}) +export class GatewayHelpLinkPipe implements PipeTransform { + transform(field: string, sourceType: SourceTypes | OPCUaSourceTypes, sourceTypes?: Array ): string { + if (!sourceTypes || sourceTypes?.includes(OPCUaSourceTypes.PATH)) { + if (sourceType !== OPCUaSourceTypes.CONST) { + return `widget/lib/gateway/${field}-${sourceType}_fn`; + } else { + return; + } + } + return 'widget/lib/gateway/expressions_fn'; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/pipes/gateway-port-tooltip.pipe.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/pipes/gateway-port-tooltip.pipe.ts new file mode 100644 index 0000000000..fcf5766c05 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/pipes/gateway-port-tooltip.pipe.ts @@ -0,0 +1,42 @@ +/// +/// 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 { Pipe, PipeTransform } from '@angular/core'; +import { PortLimits } from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { AbstractControl } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; + +@Pipe({ + name: 'getGatewayPortTooltip', + standalone: true, +}) +export class GatewayPortTooltipPipe implements PipeTransform { + + constructor(private translate: TranslateService) {} + + transform(portControl: AbstractControl): string { + if (portControl.hasError('required')) { + return this.translate.instant('gateway.port-required'); + } + if (portControl.hasError('min') || portControl.hasError('max')) { + return this.translate.instant('gateway.port-limits-error', { + min: PortLimits.MIN, + max: PortLimits.MAX, + }); + } + return ''; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/quick-link.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/quick-link.component.ts index 7a2e7704f2..2662605e2a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/quick-link.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/quick-link.component.ts @@ -49,6 +49,7 @@ import { PageLink } from '@shared/models/page/page-link'; import { Direction } from '@shared/models/page/sort-order'; import { emptyPageData, PageData } from '@shared/models/page/page-data'; import { deepClone } from '@core/utils'; +import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; @Component({ selector: 'tb-quick-link', @@ -114,6 +115,7 @@ export class QuickLinkComponent extends PageComponent implements OnInit, Control private fb: UntypedFormBuilder, private menuService: MenuService, public translate: TranslateService, + private customTranslate: CustomTranslatePipe, @SkipSelf() private errorStateMatcher: ErrorStateMatcher) { super(store); } @@ -135,7 +137,8 @@ export class QuickLinkComponent extends PageComponent implements OnInit, Control this.updateView(modelValue); }), map(value => value ? (typeof value === 'string' ? value : - ((value as any).translated ? value.name : this.translate.instant(value.name))) : ''), + ((value as any).translated ? value.name + : value.customTranslate ? this.customTranslate.transform(value.name) : this.translate.instant(value.name))) : ''), distinctUntilChanged(), switchMap(name => this.fetchLinks(name) ), share() @@ -202,7 +205,8 @@ export class QuickLinkComponent extends PageComponent implements OnInit, Control } displayLinkFn = (link?: MenuSection): string | undefined => - link ? ((link as any).translated ? link.name : this.translate.instant(link.fullName || link.name)) : undefined; + link ? ((link as any).translated ? link.name : link.customTranslate ? this.customTranslate.transform(link.fullName || link.name) + : this.translate.instant(link.fullName || link.name)) : undefined; fetchLinks(searchText?: string): Observable> { this.searchText = searchText; @@ -228,7 +232,8 @@ export class QuickLinkComponent extends PageComponent implements OnInit, Control map((links) => { const result = deepClone(links); for (const link of result) { - link.name = this.translate.instant(link.fullName || link.name); + link.name = link.customTranslate ? this.customTranslate.transform(link.fullName || link.name) + : this.translate.instant(link.fullName || link.name); (link as any).translated = true; } return result; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html index 7ec7562d31..72b5d8a7f9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html @@ -48,7 +48,7 @@ {{ 'widgets.recent-dashboards.name' | translate }} - {{ lastVisitedDashboard.title }} + {{ lastVisitedDashboard.title }} @@ -78,7 +78,7 @@ class="star" [ngClass]="{'starred': dashboard.starred}">{{ dashboard.starred ? 'star' : 'star_border' }}
@@ -87,6 +87,7 @@ subscriptSizing="dynamic" appearance="outline" [useIdValue]="false" + [customerId]="customerId" label="" placeholder="{{ 'dashboard.select-dashboard' | translate }}" [(ngModel)]="starredDashboardValue" (ngModelChange)="onStarDashboard($event)"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.ts index 69b2230e25..9ec876ffd6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.ts @@ -21,7 +21,8 @@ import { Input, OnDestroy, OnInit, - QueryList, ViewChild, + QueryList, + ViewChild, ViewChildren } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; @@ -29,11 +30,12 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { Authority } from '@shared/models/authority.enum'; import { BehaviorSubject, Observable, of } from 'rxjs'; -import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { getCurrentAuthState, getCurrentAuthUser } from '@core/auth/auth.selectors'; import { WidgetContext } from '@home/models/widget-component.models'; import { AbstractUserDashboardInfo, - LastVisitedDashboardInfo, StarredDashboardInfo, + LastVisitedDashboardInfo, + StarredDashboardInfo, UserDashboardAction, UserDashboardsInfo } from '@shared/models/user-settings.models'; @@ -77,6 +79,8 @@ export class RecentDashboardsWidgetComponent extends PageComponent implements On hasDashboardsAccess = true; dirty = false; + public customerId: string; + private isFullscreenMode = getCurrentAuthState(this.store).forceFullscreen; constructor(protected store: Store, private cd: ChangeDetectorRef, @@ -85,6 +89,9 @@ export class RecentDashboardsWidgetComponent extends PageComponent implements On } ngOnInit() { + if (this.authUser.authority === Authority.CUSTOMER_USER) { + this.customerId = this.authUser.customerId; + } this.hasDashboardsAccess = [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER].includes(this.authUser.authority); if (this.hasDashboardsAccess) { this.reload(); @@ -110,6 +117,11 @@ export class RecentDashboardsWidgetComponent extends PageComponent implements On ); } + public createDashboardUrl(id: string): string { + const baseUrl = this.isFullscreenMode ? '/dashboard/' : '/dashboards/'; + return baseUrl + id; + } + toggleValueChange(value: 'last' | 'starred') { this.toggleValue = value; if (this.dirty) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget2.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget2.ts index a46e6a8427..c9e4d872b6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget2.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget2.ts @@ -29,7 +29,7 @@ import { import { TranslateService } from '@ngx-translate/core'; import { UtilsService } from '@core/services/utils.service'; import { EntityDataPageLink } from '@shared/models/query/query.models'; -import { providerClass } from '@home/components/widget/lib/maps/providers'; +import { providerClass } from '@home/components/widget/lib/maps/providers/public-api'; import { isDefined, isDefinedAndNotNull, parseFunction } from '@core/utils'; import L from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/index.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/public-api.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/index.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/public-api.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.html index 72b154d89a..0d59ed8564 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.html @@ -26,7 +26,7 @@ {{place.icon}} - {{place.name}} + {{place.customTranslate ? (place.name | customTranslate) : (place.name | translate)}} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.ts index 8ff8fa74e9..539b77f34a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.ts @@ -20,7 +20,7 @@ import { WidgetContext } from '@home/models/widget-component.models'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { MenuService } from '@core/services/menu.service'; -import { HomeSection, HomeSectionPlace } from '@core/services/menu.models'; +import { HomeSection, MenuSection } from '@core/services/menu.models'; import { Router } from '@angular/router'; import { map } from 'rxjs/operators'; @@ -38,9 +38,7 @@ export class NavigationCardsWidgetComponent extends PageComponent implements OnI homeSections$ = this.menuService.homeSections(); showHomeSections$ = this.homeSections$.pipe( - map((sections) => { - return sections.filter((section) => this.sectionPlaces(section).length > 0); - }) + map((sections) => sections.filter((section) => this.sectionPlaces(section).length > 0)) ); cols = null; @@ -85,11 +83,11 @@ export class NavigationCardsWidgetComponent extends PageComponent implements OnI }); } - sectionPlaces(section: HomeSection): HomeSectionPlace[] { + sectionPlaces(section: HomeSection): MenuSection[] { return section && section.places ? section.places.filter((place) => this.filterPlace(place)) : []; } - private filterPlace(place: HomeSectionPlace): boolean { + private filterPlace(place: MenuSection): boolean { if (this.settings.filterType === 'include') { return this.settings.filter.includes(place.path); } else if (this.settings.filterType === 'exclude') { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts index 00cc3f24f9..12e2a1065b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts @@ -24,7 +24,7 @@ import { SetValueSettings, ValueToDataType } from '@shared/models/action-widget-settings.models'; -import { Circle, Effect, Element, G, Gradient, Runner, Svg, Text, Timeline } from '@svgdotjs/svg.js'; +import { Circle, Effect, Element, G, Gradient, Path, Runner, Svg, Text, Timeline } from '@svgdotjs/svg.js'; import '@svgdotjs/svg.filter.js'; import tinycolor from 'tinycolor2'; import { WidgetContext } from '@home/models/widget-component.models'; @@ -35,7 +35,10 @@ export enum PowerButtonLayout { outlined = 'outlined', default_volume = 'default_volume', simplified_volume = 'simplified_volume', - outlined_volume = 'outlined_volume' + outlined_volume = 'outlined_volume', + default_icon = 'default_icon', + simplified_icon = 'simplified_icon', + outlined_icon = 'outlined_icon' } export const powerButtonLayouts = Object.keys(PowerButtonLayout) as PowerButtonLayout[]; @@ -47,7 +50,10 @@ export const powerButtonLayoutTranslations = new Map( [PowerButtonLayout.outlined, 'widgets.power-button.layout-outlined'], [PowerButtonLayout.default_volume, 'widgets.power-button.layout-default-volume'], [PowerButtonLayout.simplified_volume, 'widgets.power-button.layout-simplified-volume'], - [PowerButtonLayout.outlined_volume, 'widgets.power-button.layout-outlined-volume'] + [PowerButtonLayout.outlined_volume, 'widgets.power-button.layout-outlined-volume'], + [PowerButtonLayout.default_icon, 'widgets.power-button.layout-default-icon'], + [PowerButtonLayout.simplified_icon, 'widgets.power-button.layout-simplified-icon'], + [PowerButtonLayout.outlined_icon, 'widgets.power-button.layout-outlined-icon'] ] ); @@ -58,7 +64,10 @@ export const powerButtonLayoutImages = new Map( [PowerButtonLayout.outlined, 'assets/widget/power-button/outlined-layout.svg'], [PowerButtonLayout.default_volume, 'assets/widget/power-button/default-volume-layout.svg'], [PowerButtonLayout.simplified_volume, 'assets/widget/power-button/simplified-volume-layout.svg'], - [PowerButtonLayout.outlined_volume, 'assets/widget/power-button/outlined-volume-layout.svg'] + [PowerButtonLayout.outlined_volume, 'assets/widget/power-button/outlined-volume-layout.svg'], + [PowerButtonLayout.default_icon, 'assets/widget/power-button/default-icon-layout.svg'], + [PowerButtonLayout.simplified_icon, 'assets/widget/power-button/simplified-icon-layout.svg'], + [PowerButtonLayout.outlined_icon, 'assets/widget/power-button/outlined-icon-layout.svg'] ] ); @@ -219,6 +228,21 @@ export const powerButtonShapeSize = 110; const cx = powerButtonShapeSize / 2; const cy = powerButtonShapeSize / 2; +const powerCircle = 'M13 4.67063C13 3.65748 12.0377 2.91866 11.0946 3.28889C4.59815 5.83928 0 12.1545 0 19.5412C0 29.1835 ' + + '7.83502 37.0001 17.5 37.0001C27.165 37.0001 35 29.1835 35 19.5412C35 12.1545 30.4019 5.83928 23.9054 3.28889C22.9623 2.91866 22 ' + + '3.65748 22 4.67063C22 5.33931 22.434 5.92434 23.0519 6.17991C28.3077 8.35375 32 13.5209 32 19.5412C32 27.52 25.5148 34.0001 17.5 ' + + '34.0001C9.48521 34.0001 3 27.52 3 19.5412C3 13.5209 6.69234 8.35374 11.9481 6.17991C12.566 5.92434 13 5.33931 13 4.67063Z'; +const powerLine = 'M16.5 1C16.5 0.447716 16.9477 0 17.5 0C18.0523 0 18.5 0.447715 18.5 1V17C18.5 ' + + '17.5523 18.0523 18 17.5 18C16.9477 18 16.5 17.5523 16.5 17V1Z'; + +const powerCircleStroke = 'M12 2.95698C12 1.45236 10.4775 0.438524 9.19424 1.22416C3.68417 4.59764 0 10.7283 0 17.7316C0 ' + + '28.3732 8.50659 37 19 37C29.4934 37 38 28.3732 38 17.7316C38 10.7283 34.3158 4.59764 28.8058 1.22416C27.5225 ' + + '0.438524 26 1.45236 26 2.95698C26 3.73878 26.4365 4.44718 27.0911 4.87461C31.2354 7.58066 34 12.3083 34 ' + + '17.7316C34 26.2172 27.2316 33 19 33C10.7684 33 4 26.2172 4 17.7316C4 12.3084 6.76462 7.58066 10.9089 ' + + '4.87461C11.5635 4.44718 12 3.73878 12 2.95698Z'; +const powerLineStroke = 'M0 2.5C0 1.11929 1.11929 0 2.5 0C3.88071 0 5 1.11929 5 2.5V15.5C5 16.8807 3.88071 18 ' + + '2.5 18C1.11929 18 0 16.8807 0 15.5V2.5Z'; + const powerButtonAnimation = (element: Element): Runner => element.animate(200, 0, 'now'); export abstract class PowerButtonShape { @@ -242,6 +266,12 @@ export abstract class PowerButtonShape { return new SimplifiedVolumePowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); case PowerButtonLayout.outlined_volume: return new OutlinedVolumePowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); + case PowerButtonLayout.default_icon: + return new DefaultIconPowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); + case PowerButtonLayout.simplified_icon: + return new SimplifiedIconPowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); + case PowerButtonLayout.outlined_icon: + return new OutlinedIconPowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); } } @@ -705,6 +735,31 @@ class DefaultVolumePowerButtonShape extends PowerButtonShape { private pressedTimeline: Timeline; private centerGroup: G; + + protected drawOffCenter(centerGroup: G) { + this.offLabelShape = this.createOffLabel('400').addTo(centerGroup); + } + + protected drawOnCenter() { + this.onLabelShape = this.createOnLabel('400'); + } + + protected addOnCenterToMask(onCircleShape: Circle) { + this.createMask(onCircleShape,[this.onLabelShape]); + } + + protected addOnCenterTimeLine(pressedTimeline: Timeline) { + this.onLabelShape.timeline(pressedTimeline); + } + + protected drawOffCenterColor(mainColor: PowerButtonColor) { + this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + } + + protected onCenterAnimation(scale: number) { + powerButtonAnimation(this.onLabelShape).transform({scale, origin: {x: cx, y: cy}}); + } + protected drawShape() { this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) .fill({opacity: 0}).stroke({width: 0}); @@ -723,15 +778,15 @@ class DefaultVolumePowerButtonShape extends PowerButtonShape { add.stop(1, '#FFFFFF', 1); }).from(0.832, 0.1188).to(0.268, 0.92); this.centerGroup = this.svgShape.group(); - this.offLabelShape = this.createOffLabel('400').addTo(this.centerGroup); + this.drawOffCenter(this.centerGroup); this.onCircleShape = this.svgShape.circle(powerButtonShapeSize - 24).center(cx, cy); - this.onLabelShape = this.createOnLabel('400'); - this.createMask(this.onCircleShape, [this.onLabelShape]); + this.drawOnCenter(); + this.addOnCenterToMask(this.onCircleShape); this.innerShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 24, cx, cy, 3, 0.3); this.pressedTimeline = new Timeline(); this.centerGroup.timeline(this.pressedTimeline); - this.onLabelShape.timeline(this.pressedTimeline); + this.addOnCenterTimeLine(this.pressedTimeline); this.innerShadow.timeline(this.pressedTimeline); } @@ -751,7 +806,7 @@ class DefaultVolumePowerButtonShape extends PowerButtonShape { this.innerBorder.fill(this.innerBorderGradient); this.innerBorder.attr({ 'fill-opacity': 1 }); } - this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.drawOffCenterColor(mainColor); this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); } @@ -776,14 +831,14 @@ class DefaultVolumePowerButtonShape extends PowerButtonShape { this.innerShadow.show(); const pressedScale = 0.75; powerButtonAnimation(this.centerGroup).transform({scale: pressedScale}); - powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale, origin: {x: cx, y: cy}}); + this.onCenterAnimation(pressedScale); this.innerShadow.animate(6, 0.6); } protected onPressEnd() { this.pressedTimeline.finish(); powerButtonAnimation(this.centerGroup).transform({scale: 1}); - powerButtonAnimation(this.onLabelShape).transform({scale: 1, origin: {x: cx, y: cy}}); + this.onCenterAnimation(1); this.innerShadow.animateRestore().after(() => { if (this.disabled) { this.innerShadow.hide(); @@ -793,6 +848,42 @@ class DefaultVolumePowerButtonShape extends PowerButtonShape { } +class DefaultIconPowerButtonShape extends DefaultVolumePowerButtonShape { + private offPowerSymbolCircle: Path; + private offPowerSymbolLine: Path; + private onPowerSymbolCircle: Path; + private onPowerSymbolLine: Path; + + protected drawOffCenter(centerGroup: G) { + this.offPowerSymbolCircle = this.svgShape.path(powerCircle).center(cx, cy).addTo(centerGroup); + this.offPowerSymbolLine = this.svgShape.path(powerLine).center(cx, cy-12).addTo(centerGroup); + } + + protected drawOnCenter() { + this.onPowerSymbolCircle = this.svgShape.path(powerCircle).center(cx, cy); + this.onPowerSymbolLine = this.svgShape.path(powerLine).center(cx, cy-12); + } + + protected addOnCenterToMask(onCircleShape: Circle) { + this.createMask(onCircleShape, [this.onPowerSymbolCircle, this.onPowerSymbolLine]); + } + + protected addOnCenterTimeLine(pressedTimeline: Timeline) { + this.onPowerSymbolCircle.timeline(pressedTimeline); + this.onPowerSymbolLine.timeline(pressedTimeline); + } + + protected drawOffCenterColor(mainColor: PowerButtonColor) { + this.offPowerSymbolCircle.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.offPowerSymbolLine.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + } + + protected onCenterAnimation(scale: number) { + powerButtonAnimation(this.onPowerSymbolCircle).transform({scale, origin: {x: cx, y: cy}}); + powerButtonAnimation(this.onPowerSymbolLine).transform({scale, origin: {x: cx, y: cy}}); + } +} + class SimplifiedVolumePowerButtonShape extends PowerButtonShape { private outerBorder: Circle; @@ -805,6 +896,10 @@ class SimplifiedVolumePowerButtonShape extends PowerButtonShape { private centerGroup: G; private onCenterGroup: G; + protected drawCenterGroup(centerGroup: G, onCenterGroup: G) { + this.offLabelShape = this.createOffLabel().addTo(centerGroup); + this.onLabelShape = this.createOnLabel().addTo(onCenterGroup); + } protected drawShape() { this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) @@ -812,9 +907,8 @@ class SimplifiedVolumePowerButtonShape extends PowerButtonShape { this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 4).center(cx, cy); this.createMask(this.outerBorder, [this.outerBorderMask]); this.centerGroup = this.svgShape.group(); - this.offLabelShape = this.createOffLabel().addTo(this.centerGroup); this.onCenterGroup = this.svgShape.group(); - this.onLabelShape = this.createOnLabel().addTo(this.onCenterGroup); + this.drawCenterGroup(this.centerGroup, this.onCenterGroup); this.innerShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 4, cx, cy, 3, 0.3); this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 4, cx, cy, 0, 0); this.pressedTimeline = new Timeline(); @@ -867,6 +961,27 @@ class SimplifiedVolumePowerButtonShape extends PowerButtonShape { } } +class SimplifiedIconPowerButtonShape extends SimplifiedVolumePowerButtonShape { + private offPowerSymbolCircle: Path; + private offPowerSymbolLine: Path; + private onPowerSymbolCircle: Path; + private onPowerSymbolLine: Path; + + protected drawCenterGroup(centerGroup: G, onCenterGroup: G) { + this.offPowerSymbolCircle = this.svgShape.path(powerCircle).center(cx, cy).addTo(centerGroup); + this.offPowerSymbolLine = this.svgShape.path(powerLine).center(cx, cy-12).addTo(centerGroup); + this.onPowerSymbolCircle = this.svgShape.path(powerCircle).center(cx, cy).addTo(onCenterGroup); + this.onPowerSymbolLine = this.svgShape.path(powerLine).center(cx, cy-12).addTo(onCenterGroup); + } + + protected drawColorState(mainColor: PowerButtonColor) { + this.offPowerSymbolCircle.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.offPowerSymbolLine.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.onPowerSymbolCircle.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.onPowerSymbolLine.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + } +} + class OutlinedVolumePowerButtonShape extends PowerButtonShape { private outerBorder: Circle; private outerBorderMask: Circle; @@ -881,6 +996,30 @@ class OutlinedVolumePowerButtonShape extends PowerButtonShape { private centerGroup: G; private onCenterGroup: G; + protected drawOffCenter(centerGroup: G) { + this.offLabelShape = this.createOffLabel('800').addTo(centerGroup); + } + + protected drawOnCenter() { + this.onLabelShape = this.createOnLabel('800'); + } + + protected addOnCenterToMask(onCircleShape: Circle) { + this.createMask(onCircleShape,[this.onLabelShape]); + } + + protected addOnCenterTimeLine(pressedTimeline: Timeline) { + this.onLabelShape.timeline(pressedTimeline); + } + + protected drawOffCenterColor(mainColor: PowerButtonColor) { + this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + } + + protected onCenterAnimation(scale: number) { + powerButtonAnimation(this.onLabelShape).transform({scale, origin: {x: cx, y: cy}}); + } + protected drawShape() { this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) .fill({opacity: 0}).stroke({width: 0}); @@ -895,19 +1034,19 @@ class OutlinedVolumePowerButtonShape extends PowerButtonShape { this.innerBorderMask = this.svgShape.circle(powerButtonShapeSize - 30).center(cx, cy); this.createMask(this.innerBorder, [this.innerBorderMask]); this.centerGroup = this.svgShape.group(); - this.offLabelShape = this.createOffLabel('800').addTo(this.centerGroup); + this.drawOffCenter(this.centerGroup); this.onCenterGroup = this.svgShape.group(); this.onCircleShape = this.svgShape.circle(powerButtonShapeSize - 30).center(cx, cy) .addTo(this.onCenterGroup); - this.onLabelShape = this.createOnLabel('800'); - this.createMask(this.onCircleShape, [this.onLabelShape]); + this.drawOnCenter(); + this.addOnCenterToMask(this.onCircleShape); this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 30, cx, cy, 0, 0); this.backgroundShape.addClass('tb-small-shadow'); this.pressedTimeline = new Timeline(); this.centerGroup.timeline(this.pressedTimeline); this.onCenterGroup.timeline(this.pressedTimeline); - this.onLabelShape.timeline(this.pressedTimeline); + this.addOnCenterTimeLine(this.pressedTimeline); this.pressedShadow.timeline(this.pressedTimeline); } @@ -919,7 +1058,7 @@ class OutlinedVolumePowerButtonShape extends PowerButtonShape { this.outerBorder.attr({ 'fill-opacity': 1 }); } this.innerBorder.attr({fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); - this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.drawOffCenterColor(mainColor); this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); } @@ -940,7 +1079,7 @@ class OutlinedVolumePowerButtonShape extends PowerButtonShape { const pressedScale = 0.75; powerButtonAnimation(this.centerGroup).transform({scale: pressedScale}); powerButtonAnimation(this.onCenterGroup).transform({scale: 0.98}); - powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale / 0.98, origin: {x: cx, y: cy}}); + this.onCenterAnimation(pressedScale / 0.98); this.pressedShadow.animate(6, 0.6); } @@ -948,8 +1087,44 @@ class OutlinedVolumePowerButtonShape extends PowerButtonShape { this.pressedTimeline.finish(); powerButtonAnimation(this.centerGroup).transform({scale: 1}); powerButtonAnimation(this.onCenterGroup).transform({scale: 1}); - powerButtonAnimation(this.onLabelShape).transform({scale: 1, origin: {x: cx, y: cy}}); + this.onCenterAnimation(1); this.pressedShadow.animateRestore(); } } + +class OutlinedIconPowerButtonShape extends OutlinedVolumePowerButtonShape { + private offPowerSymbolCircle: Path; + private offPowerSymbolLine: Path; + private onPowerSymbolCircle: Path; + private onPowerSymbolLine: Path; + + protected drawOffCenter(centerGroup: G) { + this.offPowerSymbolCircle = this.svgShape.path(powerCircleStroke).center(cx, cy).addTo(centerGroup); + this.offPowerSymbolLine = this.svgShape.path(powerLineStroke).center(cx, cy-12).addTo(centerGroup); + } + + protected drawOnCenter() { + this.onPowerSymbolCircle = this.svgShape.path(powerCircleStroke).center(cx, cy); + this.onPowerSymbolLine = this.svgShape.path(powerLineStroke).center(cx, cy-12); + } + + protected addOnCenterToMask(onCircleShape: Circle) { + this.createMask(onCircleShape, [this.onPowerSymbolCircle, this.onPowerSymbolLine]); + } + + protected addOnCenterTimeLine(pressedTimeline: Timeline) { + this.onPowerSymbolCircle.timeline(pressedTimeline); + this.onPowerSymbolLine.timeline(pressedTimeline); + } + + protected drawOffCenterColor(mainColor: PowerButtonColor) { + this.offPowerSymbolCircle.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.offPowerSymbolLine.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + } + + protected onCenterAnimation(scale: number) { + powerButtonAnimation(this.onPowerSymbolCircle).transform({scale, origin: {x: cx, y: cy}}); + powerButtonAnimation(this.onPowerSymbolLine).transform({scale, origin: {x: cx, y: cy}}); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.html new file mode 100644 index 0000000000..da2b80c7ce --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.html @@ -0,0 +1,29 @@ + +
+
+ + + +
+
+
+
scada.no-symbol-selected
+
+ +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.scss new file mode 100644 index 0000000000..c926e0a4b0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.scss @@ -0,0 +1,63 @@ +/** + * 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-scada-symbol-panel { + width: 100%; + height: 100%; + position: relative; + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px 24px 24px 24px; + > div:not(.tb-scada-symbol-overlay) { + z-index: 1; + } + .tb-scada-symbol-overlay { + position: absolute; + top: 12px; + left: 12px; + bottom: 12px; + right: 12px; + } + div.tb-widget-title { + padding: 0; + } + .tb-scada-symbol-content { + flex: 1; + min-width: 0; + min-height: 0; + .tb-scada-symbol-shape { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + .tb-no-scada-symbol { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + color: rgba(0, 0, 0, 0.87); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 16px; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.ts new file mode 100644 index 0000000000..6db0d202c7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.ts @@ -0,0 +1,146 @@ +/// +/// 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 { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + Input, + OnDestroy, + OnInit, + TemplateRef, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; +import { ScadaSymbolObject, ScadaSymbolObjectCallbacks } from '@home/components/widget/lib/scada/scada-symbol.models'; +import { + scadaSymbolWidgetDefaultSettings, + ScadaSymbolWidgetSettings +} from '@home/components/widget/lib/scada/scada-symbol-widget.models'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models'; +import { ImageService } from '@core/http/image.service'; +import { WidgetComponent } from '@home/components/widget/widget.component'; +import { isDefinedAndNotNull, mergeDeep } from '@core/utils'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { catchError, share } from 'rxjs/operators'; + +@Component({ + selector: 'tb-scada-symbol-widget', + templateUrl: './scada-symbol-widget.component.html', + styleUrls: ['../action/action-widget.scss', './scada-symbol-widget.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolWidgetComponent implements OnInit, AfterViewInit, OnDestroy, ScadaSymbolObjectCallbacks { + + @ViewChild('scadaSymbolShape', {static: false}) + scadaSymbolShape: ElementRef; + + @Input() + ctx: WidgetContext; + + @Input() + widgetTitlePanel: TemplateRef; + + private loadingSubject = new BehaviorSubject(false); + private settings: ScadaSymbolWidgetSettings; + private scadaSymbolContent$: Observable; + + backgroundStyle$: Observable; + overlayStyle: ComponentStyle = {}; + padding: string; + + loading$ = this.loadingSubject.asObservable().pipe(share()); + + scadaSymbolObject: ScadaSymbolObject; + noScadaSymbol = false; + + constructor(public widgetComponent: WidgetComponent, + protected imagePipe: ImagePipe, + protected sanitizer: DomSanitizer, + private imageService: ImageService, + protected cd: ChangeDetectorRef) { + } + + ngOnInit(): void { + this.ctx.$scope.actionWidget = this; + this.settings = mergeDeep({} as ScadaSymbolWidgetSettings, scadaSymbolWidgetDefaultSettings, this.ctx.settings || {}); + + this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); + this.overlayStyle = overlayStyle(this.settings.background.overlay); + this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; + + if (this.settings.scadaSymbolContent) { + this.scadaSymbolContent$ = of(this.settings.scadaSymbolContent); + } else if (this.settings.scadaSymbolUrl) { + this.scadaSymbolContent$ = this.imageService.getImageString(this.settings.scadaSymbolUrl) + .pipe(catchError(() => of('empty'))); + } else { + this.scadaSymbolContent$ = of('empty'); + } + } + + ngAfterViewInit(): void { + this.scadaSymbolContent$.subscribe((content) => { + this.initObject(this.scadaSymbolShape.nativeElement, content); + }); + } + + ngOnDestroy() { + if (this.scadaSymbolObject) { + this.scadaSymbolObject.destroy(); + } + this.loadingSubject.complete(); + this.loadingSubject.unsubscribe(); + } + + public onInit() { + const borderRadius = this.ctx.$widgetElement.css('borderRadius'); + this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; + this.cd.detectChanges(); + } + + onScadaSymbolObjectLoadingState(loading: boolean) { + this.loadingSubject.next(loading); + } + + onScadaSymbolObjectError(error: string) { + this.ctx.showErrorToast(error, 'bottom', 'center', this.ctx.toastTargetId, true); + } + + onScadaSymbolObjectMessage(message: string) { + this.ctx.showSuccessToast(message, 3000, 'bottom', 'center', this.ctx.toastTargetId, true); + } + + private initObject(rootElement: HTMLElement, + content: string) { + const simulated = this.ctx.utilsService.widgetEditMode || + this.ctx.isPreview || (isDefinedAndNotNull(this.settings.simulated) ? this.settings.simulated : false); + if (content.startsWith(' string; + formatValue: (value: any, dec?: number, units?: string, showZeroDecimals?: boolean) => string | undefined; + text: (element: Element | Element[], text: string) => void; + font: (element: Element | Element[], font: Font, color: string) => void; + animate: (element: Element, duration: number) => Runner; + resetAnimation: (element: Element) => void; + finishAnimation: (element: Element) => void; + disable: (element: Element | Element[]) => void; + enable: (element: Element | Element[]) => void; + callAction: (event: Event, behaviorId: string, value?: any, observer?: Partial>) => void; + setValue: (valueId: string, value: any) => void; +} + +export interface ScadaSymbolContext { + api: ScadaSymbolApi; + tags: {[id: string]: Element[]}; + values: {[id: string]: any}; + properties: {[id: string]: any}; + svg: Svg; +} + +export type ScadaSymbolStateRenderFunction = (ctx: ScadaSymbolContext, svg: Svg) => void; + +export type ScadaSymbolTagStateRenderFunction = (ctx: ScadaSymbolContext, element: Element) => void; + +export type ScadaSymbolActionTrigger = 'click'; + +export type ScadaSymbolActionFunction = (ctx: ScadaSymbolContext, element: Element, event: Event) => void; +export interface ScadaSymbolAction { + actionFunction?: string; + action?: ScadaSymbolActionFunction; +} + +export interface ScadaSymbolTag { + tag: string; + stateRenderFunction?: string; + stateRender?: ScadaSymbolTagStateRenderFunction; + actions?: {[trigger: string]: ScadaSymbolAction}; +} + +export enum ScadaSymbolBehaviorType { + value = 'value', + action = 'action', + widgetAction = 'widgetAction' +} + +export const scadaSymbolBehaviorTypes = Object.keys(ScadaSymbolBehaviorType) as ScadaSymbolBehaviorType[]; + +export const scadaSymbolBehaviorTypeTranslations = new Map( + [ + [ScadaSymbolBehaviorType.value, 'scada.behavior.type-value'], + [ScadaSymbolBehaviorType.action, 'scada.behavior.type-action'], + [ScadaSymbolBehaviorType.widgetAction, 'scada.behavior.type-widget-action'] + ] +); + + +export interface ScadaSymbolBehaviorBase { + id: string; + name: string; + hint?: string; + group?: string; + type: ScadaSymbolBehaviorType; +} + +export interface ScadaSymbolBehaviorValue extends ScadaSymbolBehaviorBase { + valueType: ValueType; + defaultGetValueSettings?: GetValueSettings; + trueLabel?: string; + falseLabel?: string; + stateLabel?: string; +} + +export interface ScadaSymbolBehaviorAction extends ScadaSymbolBehaviorBase { + valueType: ValueType; + defaultSetValueSettings?: SetValueSettings; + defaultWidgetActionSettings?: WidgetAction; +} + +export type ScadaSymbolBehavior = ScadaSymbolBehaviorValue & ScadaSymbolBehaviorAction; + +export enum ScadaSymbolPropertyType { + text = 'text', + number = 'number', + switch = 'switch', + color = 'color', + color_settings = 'color_settings', + font = 'font', + units = 'units' +} + +export const scadaSymbolPropertyTypes = Object.keys(ScadaSymbolPropertyType) as ScadaSymbolPropertyType[]; + +export const scadaSymbolPropertyTypeTranslations = new Map( + [ + [ScadaSymbolPropertyType.text, 'scada.property.type-text'], + [ScadaSymbolPropertyType.number, 'scada.property.type-number'], + [ScadaSymbolPropertyType.switch, 'scada.property.type-switch'], + [ScadaSymbolPropertyType.color, 'scada.property.type-color'], + [ScadaSymbolPropertyType.color_settings, 'scada.property.type-color-settings'], + [ScadaSymbolPropertyType.font, 'scada.property.type-font'], + [ScadaSymbolPropertyType.units, 'scada.property.type-units'] + ] +); + +export const scadaSymbolPropertyRowClasses = + ['column', 'column-xs', 'column-lt-md', 'align-start', 'no-border', 'no-gap', 'no-padding', 'same-padding']; + +export const scadaSymbolPropertyFieldClasses = + ['medium-width', 'flex', 'flex-xs', 'flex-lt-md']; + +export interface ScadaSymbolPropertyBase { + id: string; + name: string; + type: ScadaSymbolPropertyType; + default: any; + required?: boolean; + subLabel?: string; + divider?: boolean; + fieldSuffix?: string; + disableOnProperty?: string; + rowClass?: string; + fieldClass?: string; +} + +export interface ScadaSymbolNumberProperty extends ScadaSymbolPropertyBase { + min?: number; + max?: number; + step?: number; +} + +export type ScadaSymbolProperty = ScadaSymbolPropertyBase & ScadaSymbolNumberProperty; + +export interface ScadaSymbolMetadata { + title: string; + description?: string; + searchTags?: string[]; + widgetSizeX: number; + widgetSizeY: number; + stateRenderFunction?: string; + stateRender?: ScadaSymbolStateRenderFunction; + tags: ScadaSymbolTag[]; + behavior: ScadaSymbolBehavior[]; + properties: ScadaSymbolProperty[]; +} + +export const emptyMetadata = (): ScadaSymbolMetadata => ({ + title: '', + widgetSizeX: 3, + widgetSizeY: 3, + tags: [], + behavior: [], + properties: [] +}); + +const svgPartsRegex = /()(.*)<\/svg>/gms; + +const tbNamespaceRegex = //gms; + +export const applyTbNamespaceToSvgContent = (svgContent: string): string => { + svgPartsRegex.lastIndex = 0; + let svgRootNode: string; + let innerSvg = ''; + let match = svgPartsRegex.exec(svgContent); + if (match != null) { + if (match.length > 1) { + svgRootNode = match[1]; + } + if (match.length > 2) { + innerSvg = match[2]; + } + } + if (!svgRootNode) { + throw new Error('Invalid SVG document.'); + } + tbNamespaceRegex.lastIndex = 0; + match = tbNamespaceRegex.exec(svgRootNode); + if (match === null || !match.length) { + svgRootNode = svgRootNode.slice(0, -1) + ' xmlns:tb="https://thingsboard.io/svg">'; + return `${svgRootNode}\n${innerSvg}\n`; + } + return svgContent; +}; + +export const parseScadaSymbolMetadataFromContent = (svgContent: string): ScadaSymbolMetadata => { + try { + svgContent = applyTbNamespaceToSvgContent(svgContent); + const svgDoc = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); + return parseScadaSymbolMetadataFromDom(svgDoc); + } catch (_e) { + return emptyMetadata(); + } +}; + +const parseScadaSymbolMetadataFromDom = (svgDoc: Document): ScadaSymbolMetadata => { + try { + const elements = svgDoc.getElementsByTagName('tb:metadata'); + if (elements.length) { + return JSON.parse(elements[0].textContent); + } else { + return emptyMetadata(); + } + } catch (_e) { + console.error(_e); + return emptyMetadata(); + } +}; + +export const updateScadaSymbolMetadataInContent = (svgContent: string, metadata: ScadaSymbolMetadata): string => { + svgContent = applyTbNamespaceToSvgContent(svgContent); + const svgDoc = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); + const parsererror = svgDoc.getElementsByTagName('parsererror'); + if (parsererror?.length) { + return parsererror[0].outerHTML; + } + updateScadaSymbolMetadataInDom(svgDoc, metadata); + return svgDoc.documentElement.outerHTML; +}; + +const updateScadaSymbolMetadataInDom = (svgDoc: Document, metadata: ScadaSymbolMetadata) => { + let metadataElement: Node; + const elements = svgDoc.getElementsByTagName('tb:metadata'); + if (elements?.length) { + metadataElement = elements[0]; + metadataElement.textContent = ''; + } else { + metadataElement = svgDoc.createElement('tb:metadata'); + svgDoc.documentElement.insertBefore(metadataElement, svgDoc.documentElement.firstChild); + } + const content = JSON.stringify(metadata, null, 2); + const cdata = svgDoc.createCDATASection(content); + metadataElement.appendChild(cdata); +}; + +const tbMetadataRegex = /.*<\/tb:metadata>/gs; + +export interface ScadaSymbolContentData { + svgRootNode: string; + innerSvg: string; +} + +export const scadaSymbolContentData = (svgContent: string): ScadaSymbolContentData => { + const result: ScadaSymbolContentData = { + svgRootNode: '', + innerSvg: '' + }; + svgPartsRegex.lastIndex = 0; + const match = svgPartsRegex.exec(svgContent); + if (match != null) { + if (match.length > 1) { + result.svgRootNode = match[1]; + } + if (match.length > 2) { + let innerSvgContent = match[2]; + tbMetadataRegex.lastIndex = 0; + const metadataMatch = tbMetadataRegex.exec(svgContent); + if (metadataMatch !== null && metadataMatch.length) { + const metadata = metadataMatch[0]; + innerSvgContent = innerSvgContent.replace(metadata, ''); + } + result.innerSvg = innerSvgContent; + } + } + return result; +}; + +const defaultValueForValueType = (valueType: ValueType): any => { + if (!valueType) { + return null; + } + switch (valueType) { + case ValueType.STRING: + return ''; + case ValueType.INTEGER: + case ValueType.DOUBLE: + return 0; + case ValueType.BOOLEAN: + return false; + case ValueType.JSON: + return {}; + } +}; + +export const defaultGetValueSettings = (valueType: ValueType): GetValueSettings => ({ + action: GetValueAction.DO_NOTHING, + defaultValue: defaultValueForValueType(valueType), + executeRpc: { + method: 'getState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + getAttribute: { + key: 'state', + scope: null + }, + getTimeSeries: { + key: 'state' + }, + dataToValue: { + type: DataToValueType.NONE, + compareToValue: true, + dataToValueFunction: '/* Should return boolean value */\nreturn data;' + } +}); + +export const defaultSetValueSettings = (valueType: ValueType): SetValueSettings => ({ + action: SetValueAction.EXECUTE_RPC, + executeRpc: { + method: 'setState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + setAttribute: { + key: 'state', + scope: AttributeScope.SERVER_SCOPE + }, + putTimeSeries: { + key: 'state' + }, + valueToData: { + type: valueType !== ValueType.BOOLEAN ? ValueToDataType.VALUE : ValueToDataType.CONSTANT, + constantValue: false, + valueToDataFunction: + '/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;' + } +}); + +export const defaultWidgetActionSettings: WidgetAction = { + type: WidgetActionType.doNothing, + targetDashboardStateId: null, + openRightLayout: false, + setEntityId: false, + stateEntityParamName: null +}; + +export const updateBehaviorDefaultSettings = (behavior: ScadaSymbolBehavior): ScadaSymbolBehavior => { + if (behavior.type) { + switch (behavior.type) { + case ScadaSymbolBehaviorType.value: + delete behavior.defaultSetValueSettings; + delete behavior.defaultWidgetActionSettings; + if (!behavior.defaultGetValueSettings) { + behavior.defaultGetValueSettings = mergeDeep({} as GetValueSettings, defaultGetValueSettings(behavior.valueType)); + } + break; + case ScadaSymbolBehaviorType.action: + delete behavior.defaultGetValueSettings; + delete behavior.defaultWidgetActionSettings; + if (!behavior.defaultSetValueSettings) { + behavior.defaultSetValueSettings = mergeDeep({} as SetValueSettings, defaultSetValueSettings(behavior.valueType)); + } + break; + case ScadaSymbolBehaviorType.widgetAction: + delete behavior.defaultGetValueSettings; + delete behavior.defaultSetValueSettings; + if (!behavior.defaultWidgetActionSettings) { + behavior.defaultWidgetActionSettings = mergeDeep({} as WidgetAction, defaultWidgetActionSettings); + } + break; + } + } + return behavior; +}; + +export const defaultScadaSymbolObjectSettings = (metadata: ScadaSymbolMetadata): ScadaSymbolObjectSettings => { + const settings: ScadaSymbolObjectSettings = { + behavior: {}, + properties: {} + }; + for (const behavior of metadata.behavior) { + if (behavior.type === ScadaSymbolBehaviorType.value) { + settings.behavior[behavior.id] = + mergeDeep({} as GetValueSettings, + defaultGetValueSettings(behavior.valueType), (behavior as ScadaSymbolBehaviorValue).defaultGetValueSettings || {}); + } else if (behavior.type === ScadaSymbolBehaviorType.action) { + settings.behavior[behavior.id] = + mergeDeep({} as SetValueSettings, + defaultSetValueSettings(behavior.valueType), (behavior as ScadaSymbolBehaviorAction).defaultSetValueSettings || {}); + } else if (behavior.type === ScadaSymbolBehaviorType.widgetAction) { + settings.behavior[behavior.id] = + mergeDeep({} as WidgetAction, + defaultWidgetActionSettings, (behavior as ScadaSymbolBehaviorAction).defaultWidgetActionSettings || {}); + } + } + for (const property of metadata.properties) { + settings.properties[property.id] = property.default; + } + return settings; +}; + +export type ScadaSymbolObjectSettings = { + behavior: {[id: string]: any}; + properties: {[id: string]: any}; +}; + +const parseError = (ctx: WidgetContext, err: any): string => + ctx.$injector.get(UtilsService).parseException(err).message || 'Unknown Error'; + +export interface ScadaSymbolObjectCallbacks { + onScadaSymbolObjectLoadingState: (loading: boolean) => void; + onScadaSymbolObjectError: (error: string) => void; + onScadaSymbolObjectMessage: (message: string) => void; +} + +export class ScadaSymbolObject { + + private metadata: ScadaSymbolMetadata; + private settings: ScadaSymbolObjectSettings; + private context: ScadaSymbolContext; + + private svgShape: Svg; + private box: Box; + + private valueGetters: ValueGetter[] = []; + private valueActions: ValueAction[] = []; + private valueSetters: {[behaviorId: string]: ValueSetter} = {}; + + private stateValueSubjects: {[id: string]: BehaviorSubject} = {}; + + private readonly shapeResize$: ResizeObserver; + private readonly destroy$ = new Subject(); + + private scale = 1; + + private performInit = true; + + constructor(private rootElement: HTMLElement, + private ctx: WidgetContext, + private svgContent: string, + private inputSettings: ScadaSymbolObjectSettings, + private callbacks: ScadaSymbolObjectCallbacks, + private simulated: boolean) { + this.shapeResize$ = new ResizeObserver(() => { + this.resize(); + }); + this.svgContent = applyTbNamespaceToSvgContent(this.svgContent); + const doc: XMLDocument = new DOMParser().parseFromString(this.svgContent, 'image/svg+xml'); + this.metadata = parseScadaSymbolMetadataFromDom(doc); + const defaults = defaultScadaSymbolObjectSettings(this.metadata); + this.settings = mergeDeep({} as ScadaSymbolObjectSettings, + defaults, this.inputSettings || {} as ScadaSymbolObjectSettings); + this.prepareMetadata(); + this.prepareSvgShape(doc); + this.shapeResize$.observe(this.rootElement); + } + + public destroy() { + this.destroy$.next(); + this.destroy$.complete(); + if (this.shapeResize$) { + this.shapeResize$.disconnect(); + } + for (const stateValueId of Object.keys(this.stateValueSubjects)) { + this.stateValueSubjects[stateValueId].complete(); + this.stateValueSubjects[stateValueId].unsubscribe(); + } + this.valueActions.forEach(v => v.destroy()); + if (this.context) { + for (const tag of this.metadata.tags) { + const elements = this.context.tags[tag.tag]; + elements.forEach(element => { + element.timeline().stop(); + element.timeline(null); + }); + } + } + if (this.svgShape) { + this.svgShape.remove(); + } + } + + private prepareMetadata() { + this.metadata.stateRender = parseFunction(this.metadata.stateRenderFunction, ['ctx', 'svg']) || (() => {}); + for (const tag of this.metadata.tags) { + tag.stateRender = parseFunction(tag.stateRenderFunction, ['ctx', 'element']) || (() => {}); + if (tag.actions) { + for (const trigger of Object.keys(tag.actions)) { + const action = tag.actions[trigger]; + action.action = parseFunction(action.actionFunction, ['ctx', 'element', 'event']) || (() => {}); + } + } + } + } + + private prepareSvgShape(doc: XMLDocument) { + const elements = doc.getElementsByTagName('tb:metadata'); + for (let i=0;i { + const id = guid(); + const firstChar = id.charAt(0); + if (firstChar >= '0' && firstChar <= '9') { + return 'a' + id; + } else { + return id; + } + }, + formatValue, + text: this.setElementText.bind(this), + font: this.setElementFont.bind(this), + animate: this.animate.bind(this), + resetAnimation: this.resetAnimation.bind(this), + finishAnimation: this.finishAnimation.bind(this), + disable: this.disableElement.bind(this), + enable: this.enableElement.bind(this), + callAction: this.callAction.bind(this), + setValue: this.setValue.bind(this) + }, + tags: {}, + properties: {}, + values: {}, + svg: this.svgShape + }; + const taggedElements = this.svgShape.find(`[tb\\:tag]`); + for (const element of taggedElements) { + const tag: string = element.attr('tb:tag'); + let elements = this.context.tags[tag]; + if (!elements) { + elements = []; + this.context.tags[tag] = elements; + } + elements.push(element); + } + for (const property of this.metadata.properties) { + this.context.properties[property.id] = this.getPropertyValue(property.id); + } + for (const tag of this.metadata.tags) { + if (tag.actions) { + const elements = this.svgShape.find(`[tb\\:tag="${tag.tag}"]`); + for (const trigger of Object.keys(tag.actions)) { + const action = tag.actions[trigger]; + elements.forEach(element => { + element.attr('cursor', 'pointer'); + element.on(trigger, (event) => { + action.action(this.context, element, event); + }); + }); + } + } + } + for (const behavior of this.metadata.behavior) { + if (behavior.type === ScadaSymbolBehaviorType.value) { + const getBehavior = behavior as ScadaSymbolBehaviorValue; + let getValueSettings: GetValueSettings = this.settings.behavior[getBehavior.id]; + getValueSettings = {...getValueSettings, actionLabel: + this.ctx.utilsService.customTranslation(getBehavior.name, getBehavior.name)}; + const stateValueSubject = new BehaviorSubject(getValueSettings.defaultValue); + this.stateValueSubjects[getBehavior.id] = stateValueSubject; + this.context.values[getBehavior.id] = getValueSettings.defaultValue; + stateValueSubject.pipe(takeUntil(this.destroy$)).subscribe((value) => { + this.onStateValueChanged(getBehavior.id, value); + }); + const valueGetter = + ValueGetter.fromSettings(this.ctx, getValueSettings, getBehavior.valueType, { + next: (val) => {this.onValue(getBehavior.id, val);}, + error: (err) => { + const message = parseError(this.ctx, err); + this.onError(message); + } + }, this.simulated); + this.valueGetters.push(valueGetter); + this.valueActions.push(valueGetter); + } else if (behavior.type === ScadaSymbolBehaviorType.action) { + const setBehavior = behavior as ScadaSymbolBehaviorAction; + let setValueSettings: SetValueSettings = this.settings.behavior[setBehavior.id]; + setValueSettings = {...setValueSettings, actionLabel: + this.ctx.utilsService.customTranslation(setBehavior.name, setBehavior.name)}; + const valueSetter = ValueSetter.fromSettings(this.ctx, setValueSettings, this.simulated); + this.valueSetters[setBehavior.id] = valueSetter; + this.valueActions.push(valueSetter); + } else if (behavior.type === ScadaSymbolBehaviorType.widgetAction) { + // TODO: + } + } + this.renderState(); + if (this.valueGetters.length) { + const getValueObservables: Array> = []; + this.valueGetters.forEach(valueGetter => { + getValueObservables.push(valueGetter.getValue()); + }); + this.onLoadingState(true); + forkJoin(getValueObservables).pipe(takeUntil(this.destroy$)).subscribe( + { + next: () => { + this.onLoadingState(false); + }, + error: () => { + this.onLoadingState(false); + } + } + ); + } + } + + private onLoadingState(loading: boolean) { + this.callbacks.onScadaSymbolObjectLoadingState(loading); + } + + private onError(error: string) { + this.callbacks.onScadaSymbolObjectError(error); + } + + private onMessage(message: string) { + this.callbacks.onScadaSymbolObjectMessage(message); + } + + private callAction(event: Event, behaviorId: string, value?: any, observer?: Partial>) { + const behavior = this.metadata.behavior.find(b => b.id === behaviorId); + if (behavior) { + if (behavior.type === ScadaSymbolBehaviorType.action) { + const valueSetter = this.valueSetters[behaviorId]; + if (valueSetter) { + this.onLoadingState(true); + valueSetter.setValue(value).pipe(takeUntil(this.destroy$)).subscribe( + { + next: () => { + if (observer?.next) { + observer.next(); + } + this.onLoadingState(false); + }, + error: (err) => { + this.onLoadingState(false); + if (observer?.error) { + observer.error(err); + } + const message = parseError(this.ctx, err); + this.onError(message); + } + } + ); + } + } else if (behavior.type === ScadaSymbolBehaviorType.widgetAction) { + const widgetAction: WidgetAction = this.settings.behavior[behavior.id]; + if (this.simulated) { + const translatedType = this.ctx.translate.instant(widgetActionTypeTranslationMap.get(widgetAction.type)); + const message = this.ctx.translate.instant('scada.preview-widget-action-text', {type: translatedType}); + this.onMessage(message); + } else { + this.ctx.actionsApi.onWidgetAction(event, widgetAction); + } + if (observer?.next) { + observer.next(); + } + } + } + } + + private resize() { + if (this.svgShape) { + const targetWidth = this.rootElement.getBoundingClientRect().width; + const targetHeight = this.rootElement.getBoundingClientRect().height; + if (targetWidth && targetHeight) { + const svgAspect = this.box.width / this.box.height; + const shapeAspect = targetWidth / targetHeight; + let scale: number; + if (svgAspect > shapeAspect) { + scale = targetWidth / this.box.width; + } else { + scale = targetHeight / this.box.height; + } + if (this.scale !== scale) { + this.scale = scale; + this.svgShape.node.style.transform = `scale(${this.scale})`; + } + if (this.performInit) { + this.performInit = false; + this.init(); + } + } + } + } + + private onValue(id: string, value: any) { + const valueBehavior = this.metadata.behavior.find(b => b.id === id) as ScadaSymbolBehaviorValue; + value = this.normalizeValue(value, valueBehavior.valueType); + this.setValue(valueBehavior.id, value); + } + + private setValue(valueId: string, value: any) { + const stateValueSubject = this.stateValueSubjects[valueId]; + if (stateValueSubject && stateValueSubject.value !== value) { + stateValueSubject.next(value); + } + } + + private onStateValueChanged(id: string, value: any) { + if (this.context.values[id] !== value) { + this.context.values[id] = value; + this.renderState(); + } + } + + private renderState(): void { + try { + this.metadata.stateRender(this.context, this.svgShape); + } catch (e) { + console.error(e); + } + for (const tag of this.metadata.tags) { + const elements = this.context.tags[tag.tag]; + if (elements) { + elements.forEach(element => { + try { + tag.stateRender(this.context, element); + } catch (e) { + console.error(e); + } + }); + } + } + } + + private normalizeValue(value: any, type: ValueType): any { + if (isUndefinedOrNull(value)) { + return defaultValueForValueType(type); + } else { + return value; + } + } + + private setElementText(e: Element | Element[], text: string) { + this.elements(e).forEach(element => { + let textElement: Text; + if (element.type === 'text') { + const children = element.children(); + if (children.length && children[0].type === 'tspan') { + textElement = children[0] as Text; + } else { + textElement = element as Text; + } + } else if (element.type === 'tspan') { + textElement = element as Text; + } + if (textElement) { + textElement.text(text); + } + }); + } + + private setElementFont(e: Element | Element[], font: Font, color: string) { + this.elements(e).forEach(element => { + if (element.type === 'text') { + const textElement = element as Text; + if (font) { + textElement.font({ + family: font.family, + size: (isDefinedAndNotNull(font.size) && isDefinedAndNotNull(font.sizeUnit)) ? + font.size + font.sizeUnit : null, + weight: font.weight, + style: font.style + }); + } + if (color) { + textElement.fill(color); + } + } + }); + } + + private animate(element: Element, duration: number): Runner { + this.finishAnimation(element); + return element.animate(duration, 0, 'now'); + } + + private resetAnimation(element: Element) { + element.timeline().stop(); + element.timeline(new Timeline()); + } + + private finishAnimation(element: Element) { + element.timeline().finish(); + element.timeline(new Timeline()); + } + + private disableElement(e: Element | Element[]) { + this.elements(e).forEach(element => { + element.attr({'pointer-events': 'none'}); + }); + } + + private enableElement(e: Element | Element[]) { + this.elements(e).forEach(element => { + element.attr({'pointer-events': null}); + }); + } + + private elements(element: Element | Element[]): Element[] { + return Array.isArray(element) ? element : [element]; + } + + private getProperty(id: string): ScadaSymbolProperty { + return this.metadata.properties.find(p => p.id === id); + } + + private getPropertyValue(id: string): any { + const property = this.getProperty(id); + if (property) { + const value = this.settings.properties[id]; + if (isDefinedAndNotNull(value)) { + if (property.type === ScadaSymbolPropertyType.color_settings) { + return ColorProcessor.fromSettings(value); + } else if (property.type === ScadaSymbolPropertyType.text) { + const result = this.ctx.utilsService.customTranslation(value, value); + const entityInfo = this.ctx.defaultSubscription.getFirstEntityInfo(); + return createLabelFromSubscriptionEntityInfo(entityInfo, result); + } + return value; + } else { + switch (property.type) { + case ScadaSymbolPropertyType.text: + return ''; + case ScadaSymbolPropertyType.number: + return 0; + case ScadaSymbolPropertyType.color: + return '#000'; + case ScadaSymbolPropertyType.color_settings: + return ColorProcessor.fromSettings(constantColor('#000')); + } + } + } else { + return ''; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/action-button-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/action-button-widget-settings.component.html index 265a9c2501..b5c7c63650 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/action-button-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/action-button-widget-settings.component.html @@ -21,9 +21,9 @@
widgets.button-state.activated-state
widgets.button-state.disabled-state
widgets.command-button.on-click
widgets.button-state.disabled-state
widgets.rpc-state.initial-state
widgets.power-button.power-on
widgets.power-button.power-off
widgets.rpc-state.disabled-state
widgets.rpc-state.initial-state
widgets.toggle-button.check
widgets.toggle-button.uncheck
widgets.rpc-state.disabled-state
+ +
+
widget-config.appearance
+
+
{{ 'widgets.notification.max-notification-display' | translate }}
+ + + +
+
+
widget-config.appearance
+ + {{ 'widgets.notification.type-filter' | translate }} + + + {{ 'widgets.notification.button-mark-read' | translate }} + + + {{ 'widgets.notification.button-view-all' | translate }} + +
+
+ +
+
+ +
+ {{ 'widgets.notification.counter' | translate }} +
+
+
+
+
{{ 'widgets.notification.counter-value' | translate }}
+
+ + + + +
+
+
+
{{ 'widgets.notification.counter-color' | translate }}
+ + +
+
+ +
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/unread-notification-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/unread-notification-widget-settings.component.ts new file mode 100644 index 0000000000..e302679971 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/unread-notification-widget-settings.component.ts @@ -0,0 +1,87 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { unreadNotificationDefaultSettings } from '@home/components/widget/lib/cards/unread-notification-widget.models'; + +@Component({ + selector: 'tb-unread-notification-widget-settings', + templateUrl: './unread-notification-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'] +}) +export class UnreadNotificationWidgetSettingsComponent extends WidgetSettingsComponent { + + unreadNotificationWidgetSettingsForm: UntypedFormGroup; + + countPreviewFn = this._countPreviewFn.bind(this); + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.unreadNotificationWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return {...unreadNotificationDefaultSettings}; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.unreadNotificationWidgetSettingsForm = this.fb.group({ + maxNotificationDisplay: [settings?.maxNotificationDisplay, [Validators.required, Validators.min(1)]], + showCounter: [settings?.showCounter, []], + counterValueFont: [settings?.counterValueFont, []], + counterValueColor: [settings?.counterValueColor, []], + counterColor: [settings?.counterColor, []], + + enableViewAll: [settings?.enableViewAll, []], + enableFilter: [settings?.enableFilter, []], + enableMarkAsRead: [settings?.enableMarkAsRead, []], + + background: [settings?.background, []], + padding: [settings.padding, []] + }); + } + + protected validatorTriggers(): string[] { + return ['showCounter']; + } + + protected updateValidators(emitEvent: boolean) { + const showCounter: boolean = this.unreadNotificationWidgetSettingsForm.get('showCounter').value; + + if (showCounter) { + this.unreadNotificationWidgetSettingsForm.get('counterValueFont').enable({emitEvent}); + this.unreadNotificationWidgetSettingsForm.get('counterValueColor').enable({emitEvent}); + this.unreadNotificationWidgetSettingsForm.get('counterColor').enable({emitEvent}); + } else { + this.unreadNotificationWidgetSettingsForm.get('counterValueFont').disable({emitEvent}); + this.unreadNotificationWidgetSettingsForm.get('counterValueColor').disable({emitEvent}); + this.unreadNotificationWidgetSettingsForm.get('counterColor').disable({emitEvent}); + } + } + + private _countPreviewFn(): string { + return this.unreadNotificationWidgetSettingsForm.get('maxNotificationDisplay').value?.toString() || '6'; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.html index 8965b462b1..e7ca744685 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.html @@ -79,7 +79,7 @@
({} as RangeChartWidgetSettings, rangeChartDefaultSettings); + return mergeDeepIgnoreArray({} as RangeChartWidgetSettings, rangeChartDefaultSettings); } protected onSettingsSet(settings: WidgetSettings) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts index 32d3f5dd80..47cfc45a2f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts @@ -33,7 +33,6 @@ import { TimeSeriesChartWidgetSettings } from '@home/components/widget/lib/chart/time-series-chart-widget.models'; import { - TimeSeriesChartTooltipTrigger, TimeSeriesChartKeySettings, TimeSeriesChartType, TimeSeriesChartYAxes, @@ -41,6 +40,7 @@ import { } from '@home/components/widget/lib/chart/time-series-chart.models'; import { WidgetConfigComponentData } from '@home/models/widget-component.models'; import { WidgetService } from '@core/http/widget.service'; +import { TimeSeriesChartTooltipTrigger } from '@home/components/widget/lib/chart/time-series-chart-tooltip.models'; @Component({ selector: 'tb-time-series-chart-widget-settings', diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-button.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-button.scss index 683b2320f4..2aa2b28bfe 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-button.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-button.scss @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -.mdc-button.tb-action-settings-button { +.mdc-button.mat-mdc-outlined-button.mat-mdc-button-base.tb-action-settings-button { width: 100%; + height: 40px; .mdc-button__label { width: 100%; & > div { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/get-value-action-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/get-value-action-settings-panel.component.html index edb3651d38..53e6fb3e8a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/get-value-action-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/get-value-action-settings-panel.component.html @@ -16,7 +16,7 @@ -->
-
{{ panelTitle | translate }}
+
{{ panelTitle }}
{{ 'widgets.value-action.action' | translate }}
@@ -122,7 +122,7 @@ helpId="widget/lib/rpc/parse_value_fn">
-
{{ 'widgets.value-action.state-when-result-is' | translate:{state: (stateLabel | translate)} }}
+
{{ 'widgets.value-action.state-when-result-is' | translate:{state: stateLabel} }}
-
{{ panelTitle | translate }}
+
{{ panelTitle }}
{{ 'widgets.value-action.action' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html index c5b4b50272..3fffd3f424 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html @@ -16,7 +16,7 @@ -->
-
{{ panelTitle | translate }}
+
{{ panelTitle }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.html index d79e84d98e..e56fc1ec00 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.html @@ -18,7 +18,7 @@
-
{{ title | translate }}
+
{{ titleText | translate }}
{{ chartFillTypeTranslationMap.get(type) | translate }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.ts index 718c4cce02..bf02937221 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.ts @@ -55,7 +55,7 @@ export class ChartFillSettingsComponent implements OnInit, ControlValueAccessor disabled: boolean; @Input() - title = 'widgets.chart.fill'; + titleText = 'widgets.chart.fill'; @Input() fillNoneTitle = 'widgets.chart.fill-type-none'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.ts index fcfd5f47e5..aa69b05d75 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.ts @@ -139,7 +139,7 @@ export class ColorRangeListComponent implements OnInit, ControlValueAccessor, On } else { rangeList = deepClone(value); } - this.colorRangeListFormGroup.get('advancedMode').patchValue(rangeList.advancedMode, {emitEvent: false}); + this.colorRangeListFormGroup.get('advancedMode').patchValue(rangeList.advancedMode || false, {emitEvent: false}); if (isDefinedAndNotNull(rangeList?.range)) { rangeList.range.forEach((r) => this.rangeListFormArray.push(this.colorRangeControl(r), {emitEvent: false})); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-panel.component.ts index b4baf12dad..18ec18963f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-panel.component.ts @@ -16,7 +16,7 @@ import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; -import { ColorRange } from '@shared/models/widget-settings.models'; +import { ColorRange, ColorRangeSettings } from '@shared/models/widget-settings.models'; import { TbPopoverComponent } from '@shared/components/popover.component'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; @@ -71,8 +71,8 @@ export class ColorRangePanelComponent extends PageComponent implements OnInit { } applyColorRangeSettings() { - const colorRangeSettings = this.colorRangeFormGroup.get('rangeList').value; - this.colorRangeApplied.emit(colorRangeSettings); + const colorRangeSettings: ColorRangeSettings = this.colorRangeFormGroup.get('rangeList').value; + this.colorRangeApplied.emit(colorRangeSettings.range); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-settings.component.ts index 5770f45c99..54c8be65b8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-settings.component.ts @@ -25,7 +25,7 @@ import { ViewContainerRef } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { ColorRange, ComponentStyle } from '@shared/models/widget-settings.models'; +import { ColorRange, ColorRangeSettings, ComponentStyle } from '@shared/models/widget-settings.models'; import { MatButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { ColorRangePanelComponent } from '@home/components/widget/lib/settings/common/color-range-panel.component'; @@ -108,8 +108,8 @@ export class ColorRangeSettingsComponent implements OnInit, ControlValueAccessor this.updateColorStyle(); } - writeValue(value: Array): void { - this.modelValue = value; + writeValue(value: Array | ColorRangeSettings): void { + this.modelValue = Array.isArray(value) ? value : value.range; this.updateColorStyle(); } @@ -131,7 +131,7 @@ export class ColorRangeSettingsComponent implements OnInit, ControlValueAccessor {}, {}, {}, true); colorRangeSettingsPanelPopover.tbComponentRef.instance.popover = colorRangeSettingsPanelPopover; - colorRangeSettingsPanelPopover.tbComponentRef.instance.colorRangeApplied.subscribe((colorRangeSettings) => { + colorRangeSettingsPanelPopover.tbComponentRef.instance.colorRangeApplied.subscribe((colorRangeSettings: Array) => { colorRangeSettingsPanelPopover.hide(); this.modelValue = colorRangeSettings; this.updateColorStyle(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/gradient.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/gradient.component.ts index 0042f3be4b..c1c17cd688 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/gradient.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/gradient.component.ts @@ -141,8 +141,8 @@ export class GradientComponent implements OnInit, ControlValueAccessor, OnDestro writeValue(value: ColorGradientSettings): void { if (isDefinedAndNotNull(value)) { this.gradientFormGroup.get('advancedMode').patchValue(value.advancedMode, {emitEvent: false}); - this.gradientFormGroup.get('minValue').patchValue(isFinite(value.minValue) ? value.minValue : this.minValue, {emitEvent: false}); - this.gradientFormGroup.get('maxValue').patchValue(isFinite(value.maxValue) ? value.maxValue : this.maxValue, {emitEvent: false}); + this.gradientFormGroup.get('minValue').patchValue(isFinite(this.minValue) ? this.minValue : value.minValue, {emitEvent: false}); + this.gradientFormGroup.get('maxValue').patchValue(isFinite(this.maxValue) ? this.maxValue : value.maxValue, {emitEvent: false}); if (value?.gradient?.length) { this.gradientFormGroup.get('gradient').get('start').patchValue(value.gradient[0], {emitEvent: false}); this.gradientFormGroup.get('gradient').get('end').patchValue(value.gradient[value.gradient.length - 1], {emitEvent: false}); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.html new file mode 100644 index 0000000000..5b6d50bf1c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.html @@ -0,0 +1,129 @@ + + +
+
scada.behavior.behavior
+ +
+ + + +
{{ behaviorGroup.title | customTranslate }}
+
+
+ + + +
+
+ + + +
+
+
+
widget-config.appearance
+
+
+ + {{ propertyRow.label | customTranslate }} + +
{{ propertyRow.label | customTranslate }}
+
+ +
{{ property.subLabel | customTranslate }}
+ + +
{{ property.fieldSuffix | customTranslate }}
+
+ + + + + + +
{{ property.fieldSuffix | customTranslate }}
+
+ + + + +
+
+
+
+
+ + + + + + + + + +
+
{{ behavior.name | customTranslate }}
+ + + + + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.scss new file mode 100644 index 0000000000..7f67f6ca37 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.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 ::ng-deep { + .tb-scada-symbol-appearance-properties { + gap: 16px; + display: flex; + flex-direction: column; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.ts new file mode 100644 index 0000000000..e68788cc92 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.ts @@ -0,0 +1,282 @@ +/// +/// 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, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + ValidatorFn, + Validators +} from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + defaultScadaSymbolObjectSettings, + parseScadaSymbolMetadataFromContent, + ScadaSymbolBehaviorType, + ScadaSymbolMetadata, + ScadaSymbolObjectSettings, + ScadaSymbolPropertyType +} from '@home/components/widget/lib/scada/scada-symbol.models'; +import { IAliasController } from '@core/api/widget-api.models'; +import { TargetDevice, widgetType } from '@shared/models/widget.models'; +import { isDefinedAndNotNull, mergeDeep } from '@core/utils'; +import { + ScadaSymbolBehaviorGroup, + ScadaSymbolPropertyRow, + toBehaviorGroups, + toPropertyRows +} from '@home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.models'; +import { merge, Observable, of, Subscription } from 'rxjs'; +import { WidgetActionCallbacks } from '@home/components/widget/action/manage-widget-actions.component.models'; +import { ImageService } from '@core/http/image.service'; +import { map } from 'rxjs/operators'; + +@Component({ + selector: 'tb-scada-symbol-object-settings', + templateUrl: './scada-symbol-object-settings.component.html', + styleUrls: ['./scada-symbol-object-settings.component.scss', './../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolObjectSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolObjectSettingsComponent), + multi: true + } + ] +}) +export class ScadaSymbolObjectSettingsComponent implements OnInit, OnChanges, ControlValueAccessor, Validator { + + ScadaSymbolBehaviorType = ScadaSymbolBehaviorType; + + ScadaSymbolPropertyType = ScadaSymbolPropertyType; + + @Input() + disabled: boolean; + + @Input() + scadaSymbolUrl: string; + + @Input() + scadaSymbolContent: string; + + @Input() + scadaSymbolMetadata: ScadaSymbolMetadata; + + @Input() + aliasController: IAliasController; + + @Input() + targetDevice: TargetDevice; + + @Input() + callbacks: WidgetActionCallbacks; + + @Input() + widgetType: widgetType; + + private modelValue: ScadaSymbolObjectSettings; + + private propagateChange = null; + + private validatorTriggers: string[]; + private validatorSubscription: Subscription; + + public scadaSymbolObjectSettingsFormGroup: UntypedFormGroup; + + metadata: ScadaSymbolMetadata; + behaviorGroups: ScadaSymbolBehaviorGroup[]; + propertyRows: ScadaSymbolPropertyRow[]; + + constructor(protected store: Store, + private fb: UntypedFormBuilder, + private imageService: ImageService, + private cd: ChangeDetectorRef) { + } + + ngOnInit(): void { + this.scadaSymbolObjectSettingsFormGroup = this.fb.group({ + behavior: this.fb.group({}), + properties: this.fb.group({}) + }); + this.scadaSymbolObjectSettingsFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + this.loadMetadata(); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (['scadaSymbolUrl', 'scadaSymbolContent', 'scadaSymbolMetadata'].includes(propName)) { + this.loadMetadata(); + } + } + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.scadaSymbolObjectSettingsFormGroup.disable({emitEvent: false}); + } else { + this.scadaSymbolObjectSettingsFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: ScadaSymbolObjectSettings): void { + this.modelValue = value || { behavior: {}, properties: {} }; + this.setupValue(); + } + + validate(_c: UntypedFormControl) { + const valid = this.scadaSymbolObjectSettingsFormGroup.valid; + return valid ? null : { + scadaSymbolObjectSettings: { + valid: false, + }, + }; + } + + private loadMetadata() { + if (this.validatorSubscription) { + this.validatorSubscription.unsubscribe(); + this.validatorSubscription = null; + } + this.validatorTriggers = []; + + let metadata$: Observable; + if (this.scadaSymbolMetadata) { + metadata$ = of(this.scadaSymbolMetadata); + } else { + let content$: Observable; + if (this.scadaSymbolContent) { + content$ = of(this.scadaSymbolContent); + } else if (this.scadaSymbolUrl) { + content$ = this.imageService.getImageString(this.scadaSymbolUrl); + } else { + content$ = of(''); + } + metadata$ = content$.pipe( + map(content => parseScadaSymbolMetadataFromContent(content)) + ); + } + metadata$.subscribe( + (metadata) => { + this.metadata = metadata; + this.behaviorGroups = toBehaviorGroups(this.metadata.behavior); + this.propertyRows = toPropertyRows(this.metadata.properties); + const behaviorFormGroup = this.scadaSymbolObjectSettingsFormGroup.get('behavior') as UntypedFormGroup; + for (const control of Object.keys(behaviorFormGroup.controls)) { + behaviorFormGroup.removeControl(control, {emitEvent: false}); + } + const propertiesFormGroup = this.scadaSymbolObjectSettingsFormGroup.get('properties') as UntypedFormGroup; + for (const control of Object.keys(propertiesFormGroup.controls)) { + propertiesFormGroup.removeControl(control, {emitEvent: false}); + } + for (const behavior of this.metadata.behavior) { + behaviorFormGroup.addControl(behavior.id, this.fb.control(null, []), {emitEvent: false}); + } + for (const property of this.metadata.properties) { + if (property.disableOnProperty) { + if (!this.validatorTriggers.includes(property.disableOnProperty)) { + this.validatorTriggers.push(property.disableOnProperty); + } + } + const validators: ValidatorFn[] = []; + if (property.required) { + validators.push(Validators.required); + } + if (property.type === ScadaSymbolPropertyType.number) { + if (isDefinedAndNotNull(property.min)) { + validators.push(Validators.min(property.min)); + } + if (isDefinedAndNotNull(property.max)) { + validators.push(Validators.max(property.max)); + } + } + propertiesFormGroup.addControl(property.id, this.fb.control(null, validators), {emitEvent: false}); + } + if (this.validatorTriggers.length) { + const observables: Observable[] = []; + for (const trigger of this.validatorTriggers) { + if (propertiesFormGroup.get(trigger)) { + observables.push(propertiesFormGroup.get(trigger).valueChanges); + } + } + if (observables.length) { + this.validatorSubscription = merge(...observables).subscribe(() => { + this.updateValidators(); + }); + } + } + this.setupValue(); + this.cd.markForCheck(); + } + ); + } + + private updateValidators() { + const propertiesFormGroup = this.scadaSymbolObjectSettingsFormGroup.get('properties') as UntypedFormGroup; + for (const trigger of this.validatorTriggers) { + const value: boolean = propertiesFormGroup.get(trigger).value; + this.metadata.properties.filter(p => p.disableOnProperty === trigger).forEach( + (p) => { + const control = propertiesFormGroup.get(p.id); + if (value) { + control.enable({emitEvent: false}); + } else { + control.disable({emitEvent: false}); + } + } + ); + } + } + + private setupValue() { + if (this.metadata) { + const defaults = defaultScadaSymbolObjectSettings(this.metadata); + this.modelValue = mergeDeep(defaults, this.modelValue); + this.scadaSymbolObjectSettingsFormGroup.patchValue( + this.modelValue, {emitEvent: false} + ); + this.setDisabledState(this.disabled); + } + } + + private updateModel() { + this.modelValue = this.scadaSymbolObjectSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.models.ts new file mode 100644 index 0000000000..b884166cd2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.models.ts @@ -0,0 +1,77 @@ +/// +/// 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 { + ScadaSymbolBehavior, + ScadaSymbolProperty, + ScadaSymbolPropertyType +} from '@home/components/widget/lib/scada/scada-symbol.models'; + +export interface ScadaSymbolBehaviorGroup { + title?: string; + behaviors: ScadaSymbolBehavior[]; +} + +export interface ScadaSymbolPropertyRow { + label: string; + properties: ScadaSymbolProperty[]; + switch?: ScadaSymbolProperty; + rowClass?: string; +} + +export const toBehaviorGroups = (behaviors: ScadaSymbolBehavior[]): ScadaSymbolBehaviorGroup[] => { + const result: ScadaSymbolBehaviorGroup[] = []; + for (const behavior of behaviors) { + if (!behavior.group) { + result.push({ + title: null, + behaviors: [behavior] + }); + } else { + let behaviorGroup = result.find(g => g.title === behavior.group); + if (!behaviorGroup) { + behaviorGroup = { + title: behavior.group, + behaviors: [] + }; + result.push(behaviorGroup); + } + behaviorGroup.behaviors.push(behavior); + } + } + return result; +}; + +export const toPropertyRows = (properties: ScadaSymbolProperty[]): ScadaSymbolPropertyRow[] => { + const result: ScadaSymbolPropertyRow[] = []; + for (const property of properties) { + let propertyRow = result.find(r => r.label === property.name); + if (!propertyRow) { + propertyRow = { + label: property.name, + properties: [], + rowClass: property.rowClass + }; + result.push(propertyRow); + } + if (property.type === ScadaSymbolPropertyType.switch) { + propertyRow.switch = property; + } else { + propertyRow.properties.push(property); + } + } + return result; +}; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index 781042a4cf..fbfffc973f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -155,6 +155,9 @@ import { GradientComponent } from '@home/components/widget/lib/settings/common/g import { ValueSourceDataKeyComponent } from '@home/components/widget/lib/settings/common/value-source-data-key.component'; +import { + ScadaSymbolObjectSettingsComponent +} from '@home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component'; @NgModule({ declarations: [ @@ -211,6 +214,7 @@ import { TimeSeriesChartStateRowComponent, TimeSeriesChartGridSettingsComponent, StatusWidgetStateSettingsComponent, + ScadaSymbolObjectSettingsComponent, DataKeyInputComponent, EntityAliasInputComponent, AdvancedRangeComponent, @@ -275,6 +279,7 @@ import { TimeSeriesChartStateRowComponent, TimeSeriesChartGridSettingsComponent, StatusWidgetStateSettingsComponent, + ScadaSymbolObjectSettingsComponent, DataKeyInputComponent, EntityAliasInputComponent, AdvancedRangeComponent, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html index 9208efd68a..db6d0371f9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html @@ -21,11 +21,11 @@
widgets.rpc-state.initial-state
widgets.rpc-state.turn-on
widgets.rpc-state.turn-off
widgets.rpc-state.disabled-state
widgets.slider.initial-value
widgets.slider.on-value-change
widgets.rpc-state.disabled-state
widgets.gauge.max-value-short
+ + warning +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/digital-gauge-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/digital-gauge-widget-settings.component.ts index 5a4289a8d2..5de1b77b18 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/digital-gauge-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/digital-gauge-widget-settings.component.ts @@ -16,7 +16,14 @@ import { Datasource, WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; import { Component } from '@angular/core'; -import { AbstractControl, UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { + AbstractControl, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormGroup, ValidationErrors, + ValidatorFn, + Validators +} from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { GaugeType } from '@home/components/widget/lib/canvas-digital-gauge'; @@ -167,7 +174,7 @@ export class DigitalGaugeWidgetSettingsComponent extends WidgetSettingsComponent donutStartAngle: [settings.donutStartAngle, []], showMinMax: [settings.showMinMax, []], minValue: [settings.minValue, []], - maxValue: [settings.maxValue, []], + maxValue: [settings.maxValue, [this.maxValueValidation()]], minMaxFont: [settings.minMaxFont, []], minMaxColor: [settings.minMaxFont.color, []], @@ -206,6 +213,18 @@ export class DigitalGaugeWidgetSettingsComponent extends WidgetSettingsComponent }); } + private maxValueValidation(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value: string = control.value; + if (value) { + if (value < control.parent?.get('minValue').value) { + return {maxValue: true}; + } + } + return null; + }; + } + protected prepareOutputSettings(settings) { const barColor: ColorSettings = this.digitalGaugeWidgetSettingsForm.get('barColor').value; @@ -230,10 +249,15 @@ export class DigitalGaugeWidgetSettingsComponent extends WidgetSettingsComponent } protected validatorTriggers(): string[] { - return ['gaugeType', 'showTitle', 'showUnitTitle', 'showValue', 'showMinMax', 'showTimestamp', 'showTicks', 'animation']; + return ['gaugeType', 'showTitle', 'showUnitTitle', 'showValue', 'showMinMax', 'showTimestamp', 'showTicks', 'animation', 'minValue']; } - protected updateValidators(emitEvent: boolean) { + protected updateValidators(emitEvent: boolean, trigger: string) { + if (trigger === 'minValue') { + this.digitalGaugeWidgetSettingsForm.get('maxValue').updateValueAndValidity({emitEvent: true}); + this.digitalGaugeWidgetSettingsForm.get('maxValue').markAsTouched({onlySelf: true}); + return; + } const gaugeType: GaugeType = this.digitalGaugeWidgetSettingsForm.get('gaugeType').value; const showTitle: boolean = this.digitalGaugeWidgetSettingsForm.get('showTitle').value; const showUnitTitle: boolean = this.digitalGaugeWidgetSettingsForm.get('showUnitTitle').value; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.html index 180340881d..21b7bc5cf0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.html @@ -21,11 +21,11 @@
widgets.rpc-state.initial-state
widgets.rpc-state.disabled-state
+ +
+
scada.symbol.symbol
+ +
+ + +
+
widget-config.card-appearance
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/scada/scada-symbol-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/scada/scada-symbol-widget-settings.component.ts new file mode 100644 index 0000000000..609c88ee0f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/scada/scada-symbol-widget-settings.component.ts @@ -0,0 +1,62 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { TargetDevice, WidgetSettings, WidgetSettingsComponent, widgetType } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { scadaSymbolWidgetDefaultSettings } from '@home/components/widget/lib/scada/scada-symbol-widget.models'; + +@Component({ + selector: 'tb-scada-symbol-widget-settings', + templateUrl: './scada-symbol-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'] +}) +export class ScadaSymbolWidgetSettingsComponent extends WidgetSettingsComponent { + + get targetDevice(): TargetDevice { + return this.widgetConfig?.config?.targetDevice; + } + + get widgetType(): widgetType { + return this.widgetConfig?.widgetType; + } + + scadaSymbolWidgetSettingsForm: UntypedFormGroup; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.scadaSymbolWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return {...scadaSymbolWidgetDefaultSettings}; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.scadaSymbolWidgetSettingsForm = this.fb.group({ + scadaSymbolUrl: [settings.scadaSymbolUrl, []], + scadaSymbolObjectSettings: [settings.scadaSymbolObjectSettings, []], + background: [settings.background, []], + padding: [settings.padding, []] + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts index e4df67e2f8..37f61b11fb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts @@ -362,6 +362,12 @@ import { import { LabelValueCardWidgetSettingsComponent } from '@home/components/widget/lib/settings/cards/label-value-card-widget-settings.component'; +import { + UnreadNotificationWidgetSettingsComponent +} from '@home/components/widget/lib/settings/cards/unread-notification-widget-settings.component'; +import { +ScadaSymbolWidgetSettingsComponent +} from '@home/components/widget/lib/settings/scada/scada-symbol-widget-settings.component'; @NgModule({ declarations: [ @@ -490,7 +496,9 @@ import { PolarAreaChartWidgetSettingsComponent, RadarChartWidgetSettingsComponent, LabelCardWidgetSettingsComponent, - LabelValueCardWidgetSettingsComponent + LabelValueCardWidgetSettingsComponent, + UnreadNotificationWidgetSettingsComponent, + ScadaSymbolWidgetSettingsComponent ], imports: [ CommonModule, @@ -624,7 +632,9 @@ import { PolarAreaChartWidgetSettingsComponent, RadarChartWidgetSettingsComponent, LabelCardWidgetSettingsComponent, - LabelValueCardWidgetSettingsComponent + LabelValueCardWidgetSettingsComponent, + UnreadNotificationWidgetSettingsComponent, + ScadaSymbolWidgetSettingsComponent ] }) export class WidgetSettingsModule { @@ -725,5 +735,7 @@ export const widgetSettingsComponentsMap: {[key: string]: Type(); @@ -589,6 +590,9 @@ export class WidgetComponentService { if (isUndefined(result.typeParameters.displayRpcMessageToast)) { result.typeParameters.displayRpcMessageToast = true; } + if (isUndefined(result.typeParameters.targetDeviceOptional)) { + result.typeParameters.targetDeviceOptional = false; + } if (isFunction(widgetTypeInstance.actionSources)) { result.actionSources = widgetTypeInstance.actionSources(); } else { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index 04f3e5b996..955fd1ecf6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -22,7 +22,9 @@ import { DisplayColumnsPanelComponent } from '@home/components/widget/lib/displa import { AlarmsTableWidgetComponent } from '@home/components/widget/lib/alarm/alarms-table-widget.component'; import { SharedHomeComponentsModule } from '@home/components/shared-home-components.module'; import { TimeseriesTableWidgetComponent } from '@home/components/widget/lib/timeseries-table-widget.component'; -import { EntitiesHierarchyWidgetComponent } from '@home/components/widget/lib/entity/entities-hierarchy-widget.component'; +import { + EntitiesHierarchyWidgetComponent +} from '@home/components/widget/lib/entity/entities-hierarchy-widget.component'; import { RpcWidgetsModule } from '@home/components/widget/lib/rpc/rpc-widgets.module'; import { DateRangeNavigatorPanelComponent, @@ -87,12 +89,7 @@ import { SliderWidgetComponent } from '@home/components/widget/lib/rpc/slider-wi import { ToggleButtonWidgetComponent } from '@home/components/widget/lib/button/toggle-button-widget.component'; import { TimeSeriesChartWidgetComponent } from '@home/components/widget/lib/chart/time-series-chart-widget.component'; import { AddConnectorDialogComponent } from '@home/components/widget/lib/gateway/dialog/add-connector-dialog.component'; -import { MappingTableComponent } from '@home/components/widget/lib/gateway/connectors-configuration/mapping-table.component'; import { MappingDialogComponent } from '@home/components/widget/lib/gateway/dialog/mapping-dialog.component'; -import { DeviceInfoTableComponent } from '@home/components/widget/lib/gateway/connectors-configuration/device-info-table.component'; -import { MappingDataKeysPanelComponent } from '@home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component'; -import { BrokerSecurityComponent } from '@home/components/widget/lib/gateway/connectors-configuration/broker-security.component'; -import { EllipsisChipListDirective } from '@home/components/widget/lib/gateway/connectors-configuration/ellipsis-chip-list.directive'; import { StatusWidgetComponent } from '@home/components/widget/lib/indicator/status-widget.component'; import { LatestChartComponent } from '@home/components/widget/lib/chart/latest-chart.component'; import { PieChartWidgetComponent } from '@home/components/widget/lib/chart/pie-chart-widget.component'; @@ -100,156 +97,212 @@ import { BarChartWidgetComponent } from '@home/components/widget/lib/chart/bar-c import { PolarAreaWidgetComponent } from '@home/components/widget/lib/chart/polar-area-widget.component'; import { RadarChartWidgetComponent } from '@home/components/widget/lib/chart/radar-chart-widget.component'; import { MobileAppQrcodeWidgetComponent } from '@home/components/widget/lib/mobile-app-qrcode-widget.component'; +import { KeyValueIsNotEmptyPipe } from '@shared/pipe/key-value-not-empty.pipe'; import { LabelCardWidgetComponent } from '@home/components/widget/lib/cards/label-card-widget.component'; import { LabelValueCardWidgetComponent } from '@home/components/widget/lib/cards/label-value-card-widget.component'; import { RestConnectorSecurityComponent } from '@home/components/widget/lib/gateway/connectors-configuration/rest-connector-secuirity/rest-connector-security.component'; +import { + UnreadNotificationWidgetComponent +} from '@home/components/widget/lib/cards/unread-notification-widget.component'; +import { + NotificationTypeFilterPanelComponent +} from '@home/components/widget/lib/cards/notification-type-filter-panel.component'; +import { GatewayHelpLinkPipe } from '@home/components/widget/lib/gateway/pipes/gateway-help-link.pipe'; +import { EllipsisChipListDirective } from '@shared/directives/ellipsis-chip-list.directive'; +import { + BrokerConfigControlComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component'; +import { + WorkersConfigControlComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component'; +import { + OpcServerConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component'; +import { + MqttBasicConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component'; +import { + MappingTableComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component'; +import { + OpcUaBasicConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component'; +import { + ModbusBasicConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.component'; +import { + DeviceInfoTableComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/device-info-table/device-info-table.component'; +import { + MappingDataKeysPanelComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel/mapping-data-keys-panel.component'; +import { + TypeValuePanelComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/type-value-panel/type-value-panel.component'; +import { + ModbusRpcParametersComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-rpc-parameters/modbus-rpc-parameters.component'; +import { ScadaSymbolWidgetComponent } from '@home/components/widget/lib/scada/scada-symbol-widget.component'; @NgModule({ - declarations: - [ - DisplayColumnsPanelComponent, - EntitiesTableWidgetComponent, - AlarmsTableWidgetComponent, - TimeseriesTableWidgetComponent, - EntitiesHierarchyWidgetComponent, - EdgesOverviewWidgetComponent, - DateRangeNavigatorWidgetComponent, - DateRangeNavigatorPanelComponent, - JsonInputWidgetComponent, - MultipleInputWidgetComponent, - TripAnimationComponent, - PhotoCameraInputWidgetComponent, - GatewayFormComponent, - NavigationCardsWidgetComponent, - NavigationCardWidgetComponent, - QrCodeWidgetComponent, - MobileAppQrcodeWidgetComponent, - MarkdownWidgetComponent, - SelectEntityDialogComponent, - LegendComponent, - FlotWidgetComponent, - GatewayConnectorComponent, - AddConnectorDialogComponent, - MappingTableComponent, - MappingDialogComponent, - DeviceInfoTableComponent, - MappingDataKeysPanelComponent, - BrokerSecurityComponent, - GatewayLogsComponent, - GatewayStatisticsComponent, - GatewayServiceRPCComponent, - GatewayServiceRPCConnectorComponent, - GatewayServiceRPCConnectorTemplatesComponent, - DeviceGatewayCommandComponent, - GatewayConfigurationComponent, - GatewayRemoteConfigurationDialogComponent, - GatewayServiceRPCConnectorTemplateDialogComponent, - EllipsisChipListDirective, - ValueCardWidgetComponent, - AggregatedValueCardWidgetComponent, - CountWidgetComponent, - BatteryLevelWidgetComponent, - WindSpeedDirectionWidgetComponent, - SignalStrengthWidgetComponent, - ValueChartCardWidgetComponent, - ProgressBarWidgetComponent, - LiquidLevelWidgetComponent, - DoughnutWidgetComponent, - RangeChartWidgetComponent, - BarChartWithLabelsWidgetComponent, - SingleSwitchWidgetComponent, - ActionButtonWidgetComponent, - CommandButtonWidgetComponent, - PowerButtonWidgetComponent, - SliderWidgetComponent, - ToggleButtonWidgetComponent, - TimeSeriesChartWidgetComponent, - StatusWidgetComponent, - LatestChartComponent, - PieChartWidgetComponent, - BarChartWidgetComponent, - PolarAreaWidgetComponent, - RadarChartWidgetComponent, - LabelCardWidgetComponent, - LabelValueCardWidgetComponent - ], - imports: [ - CommonModule, - SharedModule, - RpcWidgetsModule, - HomePageWidgetsModule, - SharedHomeComponentsModule, - RestConnectorSecurityComponent - ], - exports: [ - EntitiesTableWidgetComponent, - AlarmsTableWidgetComponent, - TimeseriesTableWidgetComponent, - EntitiesHierarchyWidgetComponent, - EdgesOverviewWidgetComponent, - RpcWidgetsModule, - HomePageWidgetsModule, - DateRangeNavigatorWidgetComponent, - JsonInputWidgetComponent, - MultipleInputWidgetComponent, - TripAnimationComponent, - PhotoCameraInputWidgetComponent, - GatewayFormComponent, - NavigationCardsWidgetComponent, - NavigationCardWidgetComponent, - QrCodeWidgetComponent, - MobileAppQrcodeWidgetComponent, - MarkdownWidgetComponent, - LegendComponent, - FlotWidgetComponent, - GatewayConnectorComponent, - AddConnectorDialogComponent, - MappingTableComponent, - MappingDialogComponent, - DeviceInfoTableComponent, - MappingDataKeysPanelComponent, - BrokerSecurityComponent, - GatewayLogsComponent, - GatewayServiceRPCConnectorComponent, - GatewayServiceRPCConnectorTemplatesComponent, - EllipsisChipListDirective, - GatewayStatisticsComponent, - GatewayServiceRPCComponent, - DeviceGatewayCommandComponent, - GatewayConfigurationComponent, - GatewayRemoteConfigurationDialogComponent, - GatewayServiceRPCConnectorTemplateDialogComponent, - ValueCardWidgetComponent, - AggregatedValueCardWidgetComponent, - CountWidgetComponent, - BatteryLevelWidgetComponent, - WindSpeedDirectionWidgetComponent, - SignalStrengthWidgetComponent, - ValueChartCardWidgetComponent, - ProgressBarWidgetComponent, - LiquidLevelWidgetComponent, - DoughnutWidgetComponent, - RangeChartWidgetComponent, - BarChartWithLabelsWidgetComponent, - SingleSwitchWidgetComponent, - ActionButtonWidgetComponent, - CommandButtonWidgetComponent, - PowerButtonWidgetComponent, - SliderWidgetComponent, - ToggleButtonWidgetComponent, - TimeSeriesChartWidgetComponent, - StatusWidgetComponent, - PieChartWidgetComponent, - BarChartWidgetComponent, - PolarAreaWidgetComponent, - RadarChartWidgetComponent, - LabelCardWidgetComponent, - LabelValueCardWidgetComponent - ], + declarations: [ + DisplayColumnsPanelComponent, + EntitiesTableWidgetComponent, + AlarmsTableWidgetComponent, + TimeseriesTableWidgetComponent, + EntitiesHierarchyWidgetComponent, + EdgesOverviewWidgetComponent, + DateRangeNavigatorWidgetComponent, + DateRangeNavigatorPanelComponent, + JsonInputWidgetComponent, + MultipleInputWidgetComponent, + TripAnimationComponent, + PhotoCameraInputWidgetComponent, + GatewayFormComponent, + NavigationCardsWidgetComponent, + NavigationCardWidgetComponent, + QrCodeWidgetComponent, + MobileAppQrcodeWidgetComponent, + MarkdownWidgetComponent, + SelectEntityDialogComponent, + LegendComponent, + FlotWidgetComponent, + GatewayConnectorComponent, + AddConnectorDialogComponent, + MappingDialogComponent, + DeviceInfoTableComponent, + MappingDataKeysPanelComponent, + TypeValuePanelComponent, + GatewayLogsComponent, + GatewayStatisticsComponent, + GatewayServiceRPCComponent, + GatewayServiceRPCConnectorComponent, + GatewayServiceRPCConnectorTemplatesComponent, + DeviceGatewayCommandComponent, + GatewayConfigurationComponent, + GatewayRemoteConfigurationDialogComponent, + GatewayServiceRPCConnectorTemplateDialogComponent, + ValueCardWidgetComponent, + AggregatedValueCardWidgetComponent, + CountWidgetComponent, + BatteryLevelWidgetComponent, + WindSpeedDirectionWidgetComponent, + SignalStrengthWidgetComponent, + ValueChartCardWidgetComponent, + ProgressBarWidgetComponent, + LiquidLevelWidgetComponent, + DoughnutWidgetComponent, + RangeChartWidgetComponent, + BarChartWithLabelsWidgetComponent, + SingleSwitchWidgetComponent, + ActionButtonWidgetComponent, + CommandButtonWidgetComponent, + PowerButtonWidgetComponent, + SliderWidgetComponent, + ToggleButtonWidgetComponent, + TimeSeriesChartWidgetComponent, + StatusWidgetComponent, + LatestChartComponent, + PieChartWidgetComponent, + BarChartWidgetComponent, + PolarAreaWidgetComponent, + RadarChartWidgetComponent, + LabelCardWidgetComponent, + LabelValueCardWidgetComponent, + UnreadNotificationWidgetComponent, + NotificationTypeFilterPanelComponent, + ScadaSymbolWidgetComponent + ], + imports: [ + CommonModule, + SharedModule, + RpcWidgetsModule, + HomePageWidgetsModule, + SharedHomeComponentsModule, + RestConnectorSecurityComponent, + GatewayHelpLinkPipe, + BrokerConfigControlComponent, + WorkersConfigControlComponent, + OpcServerConfigComponent, + MqttBasicConfigComponent, + MappingTableComponent, + OpcUaBasicConfigComponent, + KeyValueIsNotEmptyPipe, + ModbusBasicConfigComponent, + EllipsisChipListDirective, + ModbusRpcParametersComponent, + ], + exports: [ + EntitiesTableWidgetComponent, + AlarmsTableWidgetComponent, + TimeseriesTableWidgetComponent, + EntitiesHierarchyWidgetComponent, + EdgesOverviewWidgetComponent, + RpcWidgetsModule, + HomePageWidgetsModule, + DateRangeNavigatorWidgetComponent, + JsonInputWidgetComponent, + MultipleInputWidgetComponent, + TripAnimationComponent, + PhotoCameraInputWidgetComponent, + GatewayFormComponent, + NavigationCardsWidgetComponent, + NavigationCardWidgetComponent, + QrCodeWidgetComponent, + MobileAppQrcodeWidgetComponent, + MarkdownWidgetComponent, + LegendComponent, + FlotWidgetComponent, + GatewayConnectorComponent, + AddConnectorDialogComponent, + MappingDialogComponent, + DeviceInfoTableComponent, + MappingDataKeysPanelComponent, + TypeValuePanelComponent, + GatewayLogsComponent, + GatewayServiceRPCConnectorComponent, + GatewayServiceRPCConnectorTemplatesComponent, + EllipsisChipListDirective, + GatewayStatisticsComponent, + GatewayServiceRPCComponent, + DeviceGatewayCommandComponent, + GatewayConfigurationComponent, + GatewayRemoteConfigurationDialogComponent, + GatewayServiceRPCConnectorTemplateDialogComponent, + ValueCardWidgetComponent, + AggregatedValueCardWidgetComponent, + CountWidgetComponent, + BatteryLevelWidgetComponent, + WindSpeedDirectionWidgetComponent, + SignalStrengthWidgetComponent, + ValueChartCardWidgetComponent, + ProgressBarWidgetComponent, + LiquidLevelWidgetComponent, + DoughnutWidgetComponent, + RangeChartWidgetComponent, + BarChartWithLabelsWidgetComponent, + SingleSwitchWidgetComponent, + ActionButtonWidgetComponent, + CommandButtonWidgetComponent, + PowerButtonWidgetComponent, + SliderWidgetComponent, + ToggleButtonWidgetComponent, + TimeSeriesChartWidgetComponent, + StatusWidgetComponent, + PieChartWidgetComponent, + BarChartWidgetComponent, + PolarAreaWidgetComponent, + RadarChartWidgetComponent, + LabelCardWidgetComponent, + LabelValueCardWidgetComponent, + UnreadNotificationWidgetComponent, + NotificationTypeFilterPanelComponent, + ScadaSymbolWidgetComponent + ], providers: [ - {provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule } + {provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule} ] }) export class WidgetComponentsModule { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index 300a7f54d1..b10af3e2f4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -169,12 +169,12 @@
-
+
{{ 'widget-config.mobile-hide' | translate }}
-
+
{{ 'widget-config.desktop-hide' | translate }} @@ -288,7 +288,7 @@
o.value === this.selectedOption)) { this.selectedOption = this.headerOptions[0].value; } @@ -962,7 +974,7 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe if (this.modelValue) { const config = this.modelValue.config; if (this.widgetType === widgetType.rpc && this.modelValue.isDataEnabled) { - if (!this.widgetEditMode && !targetDeviceValid(config.targetDevice)) { + if ((!this.widgetEditMode && !this.modelValue?.typeParameters.targetDeviceOptional) && !targetDeviceValid(config.targetDevice)) { return { targetDevice: { valid: false diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.html index 7235b300bc..43223de4b6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.html @@ -26,12 +26,10 @@ 'mat-elevation-z4': widget.dropShadow, 'tb-overflow-visible': widgetComponent.widgetContext?.overflowVisible, 'tb-has-timewindow': widget.hasTimewindow, - 'tb-edit': isEdit + 'tb-edit': isEdit || isEditingWidget, + 'tb-hover': hovered }" - [ngStyle]="widget.style" - (mousedown)="onMouseDown($event)" - (click)="onClicked($event)" - (contextmenu)="onContextMenu($event)"> + [ngStyle]="widget.style">
@@ -57,32 +55,11 @@ - - -
@@ -95,7 +72,7 @@
- +
{{widget.title$ | async}}
+
= new EventEmitter(); + hovered = false; + isReferenceWidget = false; + + get widgetEditActionsEnabled(): boolean { + return (this.isEditActionEnabled || this.isRemoveActionEnabled || this.isExportActionEnabled) && !this.widget?.isFullscreen; + } + private cssClass: string; + private editWidgetActionsTooltip: ITooltipsterInstance; + constructor(protected store: Store, private cd: ChangeDetectorRef, private renderer: Renderer2, + private container: ViewContainerRef, + private dashboardUtils: DashboardUtilsService, private utils: UtilsService) { super(store); } @@ -127,16 +145,37 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, A this.cssClass = this.utils.applyCssToElement(this.renderer, this.gridsterItem.el, 'tb-widget-css', cssString); } + $(this.gridsterItem.el).on('mousedown', (e) => this.onMouseDown(e.originalEvent)); + $(this.gridsterItem.el).on('click', (e) => this.onClicked(e.originalEvent)); + $(this.gridsterItem.el).on('contextmenu', (e) => this.onContextMenu(e.originalEvent)); + const dashboardElement = this.widget.widgetContext.dashboardPageElement; + if (dashboardElement) { + this.initEditWidgetActionTooltip(dashboardElement); + } } ngAfterViewInit(): void { this.widget.widgetContext.$widgetElement = $(this.tbWidgetElement.nativeElement); } + ngOnChanges(changes: SimpleChanges) { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (['isEditActionEnabled', 'isRemoveActionEnabled', 'isExportActionEnabled'].includes(propName)) { + this.updateEditWidgetActionsTooltipState(); + } + } + } + } + ngOnDestroy(): void { if (this.cssClass) { this.utils.clearCssElement(this.renderer, this.cssClass); } + if (this.editWidgetActionsTooltip) { + this.editWidgetActionsTooltip.destroy(); + } } isHighlighted(widget: DashboardWidget) { @@ -184,6 +223,13 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, A }); } + onReplaceReferenceWithWidgetCopy(event: MouseEvent) { + this.widgetComponentAction.emit({ + event, + actionType: WidgetComponentActionType.REPLACE_REFERENCE_WITH_WIDGET_COPY + }); + } + onExport(event: MouseEvent) { this.widgetComponentAction.emit({ event, @@ -198,4 +244,164 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, A }); } + updateEditWidgetActionsTooltipState() { + if (this.editWidgetActionsTooltip) { + if (this.widgetEditActionsEnabled) { + this.editWidgetActionsTooltip.enable(); + } else { + this.editWidgetActionsTooltip.disable(); + } + } + } + + private initEditWidgetActionTooltip(parent: HTMLElement) { + from(import('tooltipster')).subscribe(() => { + $(this.gridsterItem.el).tooltipster({ + parent: $(parent), + delay: this.widget.selected ? [0, 10000000] : [0, 100], + distance: 2, + zIndex: 151, + arrow: false, + theme: ['tb-widget-edit-actions-tooltip'], + interactive: true, + trigger: 'custom', + triggerOpen: { + mouseenter: true + }, + triggerClose: { + mouseleave: true + }, + side: ['top'], + trackOrigin: true, + trackerInterval: 25, + content: '', + functionPosition: (instance, helper, position) => { + const clientRect = helper.origin.getBoundingClientRect(); + const container = parent.getBoundingClientRect(); + position.coord.left = clientRect.right - position.size.width - container.left; + position.coord.top = position.coord.top - container.top; + position.target = clientRect.right; + return position; + }, + functionReady: (_instance, helper) => { + const tooltipEl = $(helper.tooltip); + tooltipEl.on('mouseenter', () => { + this.hovered = true; + this.cd.markForCheck(); + }); + tooltipEl.on('mouseleave', () => { + this.hovered = false; + this.cd.markForCheck(); + }); + }, + functionAfter: () => { + this.hovered = false; + this.cd.markForCheck(); + }, + functionBefore: () => { + this.widget.isReference = this.dashboardUtils.isReferenceWidget( + this.widget.widgetContext.dashboard.stateController.dashboardCtrl.dashboardCtx.getDashboard(), this.widget.widgetId); + componentRef.instance.cd.detectChanges(); + } + }); + this.editWidgetActionsTooltip = $(this.gridsterItem.el).tooltipster('instance'); + const componentRef = this.container.createComponent(EditWidgetActionsTooltipComponent); + componentRef.instance.container = this; + componentRef.instance.viewInited.subscribe(() => { + if (this.editWidgetActionsTooltip.status().open) { + this.editWidgetActionsTooltip.reposition(); + } + }); + this.editWidgetActionsTooltip.on('destroyed', () => { + componentRef.destroy(); + }); + const parentElement = componentRef.instance.element.nativeElement; + const content = parentElement.firstChild; + parentElement.removeChild(content); + parentElement.style.display = 'none'; + this.editWidgetActionsTooltip.content(content); + this.updateEditWidgetActionsTooltipState(); + this.widget.onSelected((selected) => + this.updateEditWidgetActionsTooltipSelectedState(selected)); + }); + } + + private updateEditWidgetActionsTooltipSelectedState(selected: boolean) { + if (this.editWidgetActionsTooltip) { + if (selected) { + this.editWidgetActionsTooltip.option('delay', [0, 10000000]); + this.editWidgetActionsTooltip.option('triggerClose', { + mouseleave: false + }); + if (this.widgetEditActionsEnabled) { + this.editWidgetActionsTooltip.open(); + } + } else { + this.editWidgetActionsTooltip.option('delay', [0, 100]); + this.editWidgetActionsTooltip.option('triggerClose', { + mouseleave: true + }); + this.editWidgetActionsTooltip.close(); + } + } + } + +} + +@Component({ + template: ` +
+
+ {{ 'widget.reference' | translate }} + +
+
+ + + +
+
`, + styles: [], + encapsulation: ViewEncapsulation.None +}) +export class EditWidgetActionsTooltipComponent implements AfterViewInit { + + @Input() + container: WidgetContainerComponent; + + @Output() + viewInited = new EventEmitter(); + + constructor(public element: ElementRef, + public cd: ChangeDetectorRef) { + } + + ngAfterViewInit() { + this.viewInited.emit(); + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index a79fa89242..1941c4edb3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -39,7 +39,7 @@ import { } from '@angular/core'; import { DashboardWidget } from '@home/models/dashboard-component.models'; import { - Widget, + Widget, WidgetAction, WidgetActionDescriptor, widgetActionSources, WidgetActionType, @@ -57,7 +57,7 @@ import { WidgetService } from '@core/http/widget.service'; import { UtilsService } from '@core/services/utils.service'; import { forkJoin, Observable, of, ReplaySubject, Subscription, throwError } from 'rxjs'; import { - deepClone, + deepClone, guid, insertVariable, isDefined, isNotEmptyStr, @@ -254,6 +254,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI actionDescriptorsBySourceId, getActionDescriptors: this.getActionDescriptors.bind(this), handleWidgetAction: this.handleWidgetAction.bind(this), + onWidgetAction: this.onWidgetAction.bind(this), elementClick: this.elementClick.bind(this), cardClick: this.cardClick.bind(this), click: this.click.bind(this), @@ -487,6 +488,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI } if (!this.widgetContext.inited && this.isReady()) { this.widgetContext.inited = true; + this.widgetContext.destroyed = false; this.dashboardWidget.updateWidgetParams(); this.widgetContext.detectContainerChanges(); if (this.cafs.init) { @@ -1039,7 +1041,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI return result; } - private handleWidgetAction($event: Event, descriptor: WidgetActionDescriptor, + private handleWidgetAction($event: Event, descriptor: WidgetAction, entityId?: EntityId, entityName?: string, additionalParams?: any, entityLabel?: string): void { const type = descriptor.type; const targetEntityParamName = descriptor.stateEntityParamName; @@ -1112,7 +1114,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI const customHtml = descriptor.customHtml; const customCss = descriptor.customCss; const customResources = descriptor.customResources; - const actionNamespace = `custom-action-pretty-${descriptor.name.toLowerCase()}`; + const actionNamespace = `custom-action-pretty-${guid()}`; let htmlTemplate = ''; if (isDefined(customHtml) && customHtml.length > 0) { htmlTemplate = customHtml; @@ -1420,13 +1422,8 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI const idsList = descriptors.map(descriptor => `#${descriptor.name}`).join(','); const targetElement = $(elementClicked).closest(idsList, this.widgetContext.$container[0]); if (targetElement.length && targetElement[0].id) { - $event.stopPropagation(); const descriptor = descriptors.find(descriptorInfo => descriptorInfo.name === targetElement[0].id); - const entityInfo = this.getActiveEntityInfo(); - const entityId = entityInfo ? entityInfo.entityId : null; - const entityName = entityInfo ? entityInfo.entityName : null; - const entityLabel = entityInfo && entityInfo.entityLabel ? entityInfo.entityLabel : null; - this.handleWidgetAction($event, descriptor, entityId, entityName, null, entityLabel); + this.onWidgetAction($event, descriptor); } } } @@ -1442,18 +1439,23 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI private onClick($event: Event, sourceId: string) { const descriptors = this.getActionDescriptors(sourceId); if (descriptors.length) { + this.onWidgetAction($event, descriptors[0]); + } + } + + private onWidgetAction($event: Event, action: WidgetAction) { + if ($event) { $event.stopPropagation(); - const descriptor = descriptors[0]; - const entityInfo = this.getActiveEntityInfo(); - const entityId = entityInfo ? entityInfo.entityId : null; - const entityName = entityInfo ? entityInfo.entityName : null; - const entityLabel = entityInfo && entityInfo.entityLabel ? entityInfo.entityLabel : null; - this.handleWidgetAction($event, descriptor, entityId, entityName, null, entityLabel); } + const entityInfo = this.getActiveEntityInfo(); + const entityId = entityInfo ? entityInfo.entityId : null; + const entityName = entityInfo ? entityInfo.entityName : null; + const entityLabel = entityInfo && entityInfo.entityLabel ? entityInfo.entityLabel : null; + this.handleWidgetAction($event, action, entityId, entityName, null, entityLabel); } private loadCustomActionResources(actionNamespace: string, customCss: string, customResources: Array, - actionDescriptor: WidgetActionDescriptor): Observable { + actionDescriptor: WidgetAction): Observable { const resourceTasks: Observable[] = []; const modulesTasks: Observable[] = []; diff --git a/ui-ngx/src/app/modules/home/menu/menu-link.component.html b/ui-ngx/src/app/modules/home/menu/menu-link.component.html index b1d7d00bcd..fea7165214 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-link.component.html +++ b/ui-ngx/src/app/modules/home/menu/menu-link.component.html @@ -17,5 +17,5 @@ --> {{section.icon}} - {{section.name | translate}} + {{section.customTranslate ? (section.name | customTranslate) : (section.name | translate)}} diff --git a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html index 9375b04500..3d488e9229 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html +++ b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html @@ -17,7 +17,7 @@ --> {{section.icon}} - {{section.name | translate}} + {{section.customTranslate ? (section.name | customTranslate) : (section.name | translate)}} diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts index d74373f734..e7fdfa78e3 100644 --- a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts @@ -65,12 +65,14 @@ export interface WidgetContextMenuItem extends ContextMenuItem { export interface DashboardCallbacks { onEditWidget?: ($event: Event, widget: Widget) => void; + replaceReferenceWithWidgetCopy?: ($event: Event, widget: Widget) => void; onExportWidget?: ($event: Event, widget: Widget, widgeTitle: string) => void; onRemoveWidget?: ($event: Event, widget: Widget) => void; onWidgetMouseDown?: ($event: Event, widget: Widget) => void; + onDashboardMouseDown?: ($event: Event) => void; onWidgetClicked?: ($event: Event, widget: Widget) => void; prepareDashboardContextMenu?: ($event: Event) => Array; - prepareWidgetContextMenu?: ($event: Event, widget: Widget) => Array; + prepareWidgetContextMenu?: ($event: Event, widget: Widget, isReference: boolean) => Array; } export interface IDashboardComponent { @@ -240,11 +242,20 @@ export class DashboardWidgets implements Iterable { highlightWidget(widgetId: string): DashboardWidget { const widget = this.findWidgetById(widgetId); if (widget && (!this.highlightedMode || !widget.highlighted || this.highlightedMode && widget.highlighted)) { - this.highlightedMode = true; + let detectChanges = false; + if (!this.highlightedMode) { + this.highlightedMode = true; + detectChanges = true; + } widget.highlighted = true; + widget.selected = false; this.dashboardWidgets.forEach((dashboardWidget) => { if (dashboardWidget !== widget) { dashboardWidget.highlighted = false; + dashboardWidget.selected = false; + if (detectChanges) { + dashboardWidget.widgetContext?.detectContainerChanges(); + } } }); return widget; @@ -330,8 +341,10 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { private highlightedValue = false; private selectedValue = false; + private selectedCallback: (selected: boolean) => void = () => {}; isFullscreen = false; + isReference = false; color: string; backgroundColor: string; @@ -399,6 +412,10 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { } } + onSelected(selectedCallback: (selected: boolean) => void) { + this.selectedCallback = selectedCallback; + } + get selected() { return this.selectedValue; } @@ -406,6 +423,7 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { set selected(selected: boolean) { if (this.selectedValue !== selected) { this.selectedValue = selected; + this.selectedCallback(selected); this.widgetContext.detectContainerChanges(); } } diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index 581fded240..fa4d367959 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -180,6 +180,10 @@ export class WidgetContext { } } + get dashboardPageElement(): HTMLElement { + return this.dashboard?.stateController?.dashboardCtrl?.elRef?.nativeElement; + } + authService: AuthService; deviceService: DeviceService; assetService: AssetService; @@ -449,6 +453,8 @@ export class WidgetContext { labelPattern.destroy(); } this.labelPatterns.clear(); + this.width = undefined; + this.height = undefined; this.destroyed = true; } @@ -531,6 +537,7 @@ export interface WidgetInfo extends WidgetTypeDescriptor, WidgetControllerDescri widgetName: string; fullFqn: string; deprecated: boolean; + scada: boolean; typeSettingsSchema?: string | any; typeDataKeySettingsSchema?: string | any; typeLatestDataKeySettingsSchema?: string | any; @@ -565,6 +572,7 @@ export const MissingWidgetType: WidgetInfo = { widgetName: 'Widget type not found', fullFqn: 'undefined', deprecated: false, + scada: false, sizeX: 8, sizeY: 6, resources: [], @@ -590,6 +598,7 @@ export const ErrorWidgetType: WidgetInfo = { widgetName: 'Error loading widget', fullFqn: 'error', deprecated: false, + scada: false, sizeX: 8, sizeY: 6, resources: [], @@ -632,6 +641,7 @@ export const toWidgetInfo = (widgetTypeEntity: WidgetType): WidgetInfo => ({ widgetName: widgetTypeEntity.name, fullFqn: fullWidgetTypeFqn(widgetTypeEntity), deprecated: widgetTypeEntity.deprecated, + scada: widgetTypeEntity.scada, type: widgetTypeEntity.descriptor.type, sizeX: widgetTypeEntity.descriptor.sizeX, sizeY: widgetTypeEntity.descriptor.sizeY, @@ -659,7 +669,7 @@ export const detailsToWidgetInfo = (widgetTypeDetailsEntity: WidgetTypeDetails): }; export const toWidgetType = (widgetInfo: WidgetInfo, id: WidgetTypeId, tenantId: TenantId, - createdTime: number): WidgetType => { + createdTime: number, version: number): WidgetType => { const descriptor: WidgetTypeDescriptor = { type: widgetInfo.type, sizeX: widgetInfo.sizeX, @@ -682,16 +692,18 @@ export const toWidgetType = (widgetInfo: WidgetInfo, id: WidgetTypeId, tenantId: id, tenantId, createdTime, + version, fqn: widgetTypeFqn(widgetInfo.fullFqn), name: widgetInfo.widgetName, deprecated: widgetInfo.deprecated, + scada: widgetInfo.scada, descriptor }; }; export const toWidgetTypeDetails = (widgetInfo: WidgetInfo, id: WidgetTypeId, tenantId: TenantId, - createdTime: number): WidgetTypeDetails => { - const widgetTypeEntity = toWidgetType(widgetInfo, id, tenantId, createdTime); + createdTime: number, version: number): WidgetTypeDetails => { + const widgetTypeEntity = toWidgetType(widgetInfo, id, tenantId, createdTime, version); return { ...widgetTypeEntity, description: widgetInfo.description, diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts index f05fc05a25..a01826bd08 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts @@ -14,8 +14,8 @@ /// limitations under the License. /// -import { Injectable, NgModule } from '@angular/core'; -import { Resolve, RouterModule, Routes } from '@angular/router'; +import { inject, Injectable, NgModule } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, ResolveFn, RouterModule, RouterStateSnapshot, Routes } from '@angular/router'; import { MailServerComponent } from '@modules/home/pages/admin/mail-server.component'; import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; @@ -23,7 +23,7 @@ import { Authority } from '@shared/models/authority.enum'; import { GeneralSettingsComponent } from '@modules/home/pages/admin/general-settings.component'; import { SecuritySettingsComponent } from '@modules/home/pages/admin/security-settings.component'; import { OAuth2SettingsComponent } from '@home/pages/admin/oauth2-settings.component'; -import { Observable } from 'rxjs'; +import { forkJoin, Observable } from 'rxjs'; import { OAuth2Service } from '@core/http/oauth2.service'; import { SmsProviderComponent } from '@home/pages/admin/sms-provider.component'; import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component'; @@ -31,7 +31,7 @@ import { EntitiesTableComponent } from '@home/components/entity/entities-table.c import { ResourcesLibraryTableConfigResolver } from '@home/pages/admin/resource/resources-library-table-config.resolve'; import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; -import { BreadCrumbConfig } from '@shared/components/breadcrumb'; +import { BreadCrumbConfig, BreadCrumbLabelFunction } from '@shared/components/breadcrumb'; import { QueuesTableConfigResolver } from '@home/pages/admin/queue/queues-table-config.resolver'; import { RepositoryAdminSettingsComponent } from '@home/pages/admin/repository-admin-settings.component'; import { AutoCommitAdminSettingsComponent } from '@home/pages/admin/auto-commit-admin-settings.component'; @@ -41,6 +41,11 @@ import { RouterTabsComponent } from '@home/components/router-tabs.component'; import { auditLogsRoutes } from '@home/pages/audit-log/audit-log-routing.module'; import { ImageGalleryComponent } from '@shared/components/image/image-gallery.component'; import { MobileAppSettingsComponent } from '@home/pages/admin/mobile-app-settings.component'; +import { ImageResourceType, IMAGES_URL_PREFIX, ResourceSubType } from '@shared/models/resource.models'; +import { ScadaSymbolComponent } from '@home/pages/scada-symbol/scada-symbol.component'; +import { ImageService } from '@core/http/image.service'; +import { ScadaSymbolData } from '@home/pages/scada-symbol/scada-symbol-editor.models'; +import { MenuId } from '@core/services/menu.models'; @Injectable() export class OAuth2LoginProcessingUrlResolver implements Resolve { @@ -53,14 +58,29 @@ export class OAuth2LoginProcessingUrlResolver implements Resolve { } } +export const scadaSymbolResolver: ResolveFn = + (route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + imageService = inject(ImageService)) => { + const type: ImageResourceType = route.params.type; + const key = decodeURIComponent(route.params.key); + return forkJoin({ + imageResource: imageService.getImageInfo(type, key), + scadaSymbolContent: imageService.getImageString(`${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(key)}`) + }); +}; + +export const scadaSymbolBreadcumbLabelFunction: BreadCrumbLabelFunction + = ((route, translate, component) => + component.symbolData?.imageResource?.title); + const routes: Routes = [ { path: 'resources', data: { auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], breadcrumb: { - label: 'admin.resources', - icon: 'folder' + menuId: MenuId.resources } }, children: [ @@ -77,8 +97,7 @@ const routes: Routes = [ path: 'images', data: { breadcrumb: { - label: 'image.gallery', - icon: 'filter' + menuId: MenuId.images } }, children: [ @@ -88,16 +107,51 @@ const routes: Routes = [ data: { auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], title: 'image.gallery', - }, + imageSubType: ResourceSubType.IMAGE + } + } + ] + }, + { + path: 'scada-symbols', + data: { + breadcrumb: { + menuId: MenuId.scada_symbols } + }, + children: [ + { + path: '', + component: ImageGalleryComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], + title: 'scada.symbols', + imageSubType: ResourceSubType.SCADA_SYMBOL + } + }, + { + path: ':type/:key', + component: ScadaSymbolComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: scadaSymbolBreadcumbLabelFunction, + icon: 'view_in_ar' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], + title: 'scada.symbol.symbol' + }, + resolve: { + symbolData: scadaSymbolResolver + } + }, ] }, { path: 'resources-library', data: { breadcrumb: { - label: 'resource.resources-library', - icon: 'mdi:rhombus-split' + menuId: MenuId.resources_library } }, children: [ @@ -139,8 +193,7 @@ const routes: Routes = [ auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], showMainLoadingBar: false, breadcrumb: { - label: 'admin.settings', - icon: 'settings' + menuId: MenuId.settings } }, children: [ @@ -163,8 +216,7 @@ const routes: Routes = [ auth: [Authority.SYS_ADMIN], title: 'admin.general-settings', breadcrumb: { - label: 'admin.general', - icon: 'settings_applications' + menuId: MenuId.general } } }, @@ -176,8 +228,7 @@ const routes: Routes = [ auth: [Authority.SYS_ADMIN], title: 'admin.outgoing-mail-settings', breadcrumb: { - label: 'admin.outgoing-mail', - icon: 'mail' + menuId: MenuId.mail_server } } }, @@ -189,8 +240,7 @@ const routes: Routes = [ auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], title: 'admin.notifications-settings', breadcrumb: { - label: 'admin.notifications', - icon: 'mdi:message-badge' + menuId: MenuId.notification_settings } } }, @@ -198,8 +248,7 @@ const routes: Routes = [ path: 'queues', data: { breadcrumb: { - label: 'admin.queues', - icon: 'swap_calls' + menuId: MenuId.queues } }, children: [ @@ -240,8 +289,7 @@ const routes: Routes = [ auth: [Authority.TENANT_ADMIN], title: 'admin.home-settings', breadcrumb: { - label: 'admin.home', - icon: 'settings_applications' + menuId: MenuId.home_settings } } }, @@ -253,8 +301,7 @@ const routes: Routes = [ auth: [Authority.TENANT_ADMIN], title: 'admin.repository-settings', breadcrumb: { - label: 'admin.repository', - icon: 'manage_history' + menuId: MenuId.repository_settings } } }, @@ -266,8 +313,7 @@ const routes: Routes = [ auth: [Authority.TENANT_ADMIN], title: 'admin.auto-commit-settings', breadcrumb: { - label: 'admin.auto-commit', - icon: 'settings_backup_restore' + menuId: MenuId.auto_commit_settings } } }, @@ -279,8 +325,7 @@ const routes: Routes = [ auth: [Authority.SYS_ADMIN], title: 'admin.mobile-app.mobile-app', breadcrumb: { - label: 'admin.mobile-app.mobile-app', - icon: 'smartphone' + menuId: MenuId.mobile_app_settings } } }, @@ -316,8 +361,7 @@ const routes: Routes = [ data: { auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], breadcrumb: { - label: 'security.security', - icon: 'security' + menuId: MenuId.security_settings } }, children: [ @@ -340,8 +384,7 @@ const routes: Routes = [ auth: [Authority.SYS_ADMIN], title: 'admin.general', breadcrumb: { - label: 'admin.general', - icon: 'settings_applications' + menuId: MenuId.security_settings_general } } }, @@ -353,8 +396,7 @@ const routes: Routes = [ auth: [Authority.SYS_ADMIN], title: 'admin.2fa.2fa', breadcrumb: { - label: 'admin.2fa.2fa', - icon: 'mdi:two-factor-authentication' + menuId: MenuId.two_fa } } }, @@ -366,8 +408,7 @@ const routes: Routes = [ auth: [Authority.SYS_ADMIN], title: 'admin.oauth2.oauth2', breadcrumb: { - label: 'admin.oauth2.oauth2', - icon: 'mdi:shield-account' + menuId: MenuId.oauth2 } }, resolve: { diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html index 76625326b4..5c3d9da081 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html @@ -58,7 +58,7 @@ resource.title - + {{ 'resource.title-required' | translate }} @@ -68,7 +68,7 @@ impleme ngOnInit() { super.ngOnInit(); - this.entityForm.get('resourceType').valueChanges.pipe( - startWith(ResourceType.JS_MODULE), - filter(() => this.isAdd), - takeUntil(this.destroy$) - ).subscribe((type) => { - if (type === this.resourceType.LWM2M_MODEL) { - this.entityForm.get('title').disable({emitEvent: false}); - this.entityForm.patchValue({title: ''}, {emitEvent: false}); - } else { - this.entityForm.get('title').enable({emitEvent: false}); - } - this.entityForm.patchValue({ - data: null, - fileName: null - }, {emitEvent: false}); - }); + if (this.isAdd) { + this.observeResourceTypeChange(); + } } ngOnDestroy() { @@ -96,7 +83,7 @@ export class ResourcesLibraryComponent extends EntityComponent impleme title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], resourceType: [entity?.resourceType ? entity.resourceType : ResourceType.JS_MODULE, Validators.required], fileName: [entity ? entity.fileName : null, Validators.required], - data: [entity ? entity.data : null, Validators.required] + data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []] }); } @@ -105,7 +92,6 @@ export class ResourcesLibraryComponent extends EntityComponent impleme this.entityForm.get('resourceType').disable({emitEvent: false}); if (entity.resourceType !== ResourceType.JS_MODULE) { this.entityForm.get('fileName').disable({emitEvent: false}); - this.entityForm.get('data').disable({emitEvent: false}); } } this.entityForm.patchValue({ @@ -153,4 +139,24 @@ export class ResourcesLibraryComponent extends EntityComponent impleme horizontalPosition: 'right' })); } + + private observeResourceTypeChange(): void { + this.entityForm.get('resourceType').valueChanges.pipe( + startWith(ResourceType.JS_MODULE), + takeUntil(this.destroy$) + ).subscribe((type: ResourceType) => this.onResourceTypeChange(type)); + } + + private onResourceTypeChange(type: ResourceType): void { + if (type === this.resourceType.LWM2M_MODEL) { + this.entityForm.get('title').disable({emitEvent: false}); + this.entityForm.patchValue({title: ''}, {emitEvent: false}); + } else { + this.entityForm.get('title').enable({emitEvent: false}); + } + this.entityForm.patchValue({ + data: null, + fileName: null + }, {emitEvent: false}); + } } diff --git a/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts b/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts index 0f971a879d..3ada68643f 100644 --- a/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts @@ -21,6 +21,7 @@ import { Observable } from 'rxjs'; import { OAuth2Service } from '@core/http/oauth2.service'; import { AlarmTableComponent } from '@home/components/alarm/alarm-table.component'; import { AlarmsMode } from '@shared/models/alarm.models'; +import { MenuId } from '@core/services/menu.models'; @Injectable() export class OAuth2LoginProcessingUrlResolver implements Resolve { @@ -41,8 +42,7 @@ const routes: Routes = [ auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], title: 'alarm.alarms', breadcrumb: { - label: 'alarm.alarms', - icon: 'mdi:alert-outline' + menuId: MenuId.alarms }, isPage: true, alarmsMode: AlarmsMode.ALL diff --git a/ui-ngx/src/app/modules/home/pages/api-usage/api-usage-routing.module.ts b/ui-ngx/src/app/modules/home/pages/api-usage/api-usage-routing.module.ts index bc582da666..f76ceb86bc 100644 --- a/ui-ngx/src/app/modules/home/pages/api-usage/api-usage-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/api-usage/api-usage-routing.module.ts @@ -21,6 +21,7 @@ import { ApiUsageComponent } from '@home/pages/api-usage/api-usage.component'; import { Dashboard } from '@shared/models/dashboard.models'; import { ResourcesService } from '@core/services/resources.service'; import { Observable } from 'rxjs'; +import { MenuId } from '@core/services/menu.models'; const apiUsageDashboardJson = '/assets/dashboard/api_usage.json'; @@ -38,8 +39,7 @@ const routes: Routes = [ auth: [Authority.TENANT_ADMIN], title: 'api-usage.api-usage', breadcrumb: { - label: 'api-usage.api-usage', - icon: 'insert_chart' + menuId: MenuId.api_usage } }, resolve: { diff --git a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-routing.module.ts b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-routing.module.ts index 6f7316b1ae..67229feace 100644 --- a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-routing.module.ts @@ -24,14 +24,14 @@ import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; import { BreadCrumbConfig } from '@shared/components/breadcrumb'; import { AssetProfilesTableConfigResolver } from './asset-profiles-table-config.resolver'; +import { MenuId } from '@core/services/menu.models'; export const assetProfilesRoutes: Routes = [ { path: 'assetProfiles', data: { breadcrumb: { - label: 'asset-profile.asset-profiles', - icon: 'mdi:alpha-a-box' + menuId: MenuId.asset_profiles } }, children: [ diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-routing.module.ts b/ui-ngx/src/app/modules/home/pages/asset/asset-routing.module.ts index 6ddc5735ab..05dec3b260 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-routing.module.ts @@ -24,14 +24,14 @@ import { EntityDetailsPageComponent } from '@home/components/entity/entity-detai import { BreadCrumbConfig } from '@shared/components/breadcrumb'; import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; +import { MenuId } from '@core/services/menu.models'; export const assetRoutes: Routes = [ { path: 'assets', data: { breadcrumb: { - label: 'asset.assets', - icon: 'domain' + menuId: MenuId.assets } }, children: [ diff --git a/ui-ngx/src/app/modules/home/pages/audit-log/audit-log-routing.module.ts b/ui-ngx/src/app/modules/home/pages/audit-log/audit-log-routing.module.ts index 768400a21e..5afeb2c694 100644 --- a/ui-ngx/src/app/modules/home/pages/audit-log/audit-log-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/audit-log/audit-log-routing.module.ts @@ -18,6 +18,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { Authority } from '@shared/models/authority.enum'; import { AuditLogTableComponent } from '@home/components/audit-log/audit-log-table.component'; +import { MenuId } from '@core/services/menu.models'; export const auditLogsRoutes: Routes = [ { @@ -27,8 +28,7 @@ export const auditLogsRoutes: Routes = [ auth: [Authority.TENANT_ADMIN], title: 'audit-log.audit-logs', breadcrumb: { - label: 'audit-log.audit-logs', - icon: 'track_changes' + menuId: MenuId.audit_log }, isPage: true } diff --git a/ui-ngx/src/app/modules/home/pages/customer/customer-routing.module.ts b/ui-ngx/src/app/modules/home/pages/customer/customer-routing.module.ts index 7090ce7fc8..1339d55b40 100644 --- a/ui-ngx/src/app/modules/home/pages/customer/customer-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/customer/customer-routing.module.ts @@ -31,14 +31,14 @@ import { EdgesTableConfigResolver } from '@home/pages/edge/edges-table-config.re import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; +import { MenuId } from '@core/services/menu.models'; const routes: Routes = [ { path: 'customers', data: { breadcrumb: { - label: 'customer.customers', - icon: 'supervisor_account' + menuId: MenuId.customers } }, children: [ diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-routing.module.ts b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-routing.module.ts index 223ce60825..28fc9d4e12 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-routing.module.ts @@ -33,6 +33,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { MenuId } from '@core/services/menu.models'; @Injectable() export class DashboardResolver implements Resolve { @@ -66,8 +67,7 @@ const routes: Routes = [ path: 'dashboards', data: { breadcrumb: { - label: 'dashboard.dashboards', - icon: 'dashboard' + menuId: MenuId.dashboards } }, children: [ diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-routing.module.ts b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-routing.module.ts index 9626d8a616..66b7961914 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-routing.module.ts @@ -24,14 +24,14 @@ import { EntityDetailsPageComponent } from '@home/components/entity/entity-detai import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; import { BreadCrumbConfig } from '@shared/components/breadcrumb'; +import { MenuId } from '@core/services/menu.models'; export const deviceProfilesRoutes: Routes = [ { path: 'deviceProfiles', data: { breadcrumb: { - label: 'device-profile.device-profiles', - icon: 'mdi:alpha-d-box' + menuId: MenuId.device_profiles } }, children: [ diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html index fc71dbedcb..aa928687ad 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html @@ -35,8 +35,7 @@
+ [isAdd]="isTransportTypeChanged">
@@ -65,8 +64,7 @@
+ formControlName="configuration">
diff --git a/ui-ngx/src/app/modules/home/pages/device/device-routing.module.ts b/ui-ngx/src/app/modules/home/pages/device/device-routing.module.ts index 5fa15dd375..01b4370e55 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/device/device-routing.module.ts @@ -26,14 +26,14 @@ import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages import { BreadCrumbConfig } from '@shared/components/breadcrumb'; import { assetRoutes } from '@home/pages/asset/asset-routing.module'; import { entityViewRoutes } from '@home/pages/entity-view/entity-view-routing.module'; +import { MenuId } from '@core/services/menu.models'; export const deviceRoutes: Routes = [ { path: 'devices', data: { breadcrumb: { - label: 'device.devices', - icon: 'devices_other' + menuId: MenuId.devices } }, children: [ diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts b/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts index 484bb5336b..1302bfa140 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts @@ -41,14 +41,14 @@ import { } from '@home/pages/rulechain/rulechain-routing.module'; import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; +import { MenuId } from '@core/services/menu.models'; const routes: Routes = [ { path: 'edgeManagement', data: { breadcrumb: { - label: 'edge.management', - icon: 'settings_input_antenna' + menuId: MenuId.edge_management } }, children: [ @@ -64,8 +64,7 @@ const routes: Routes = [ path: 'instances', data: { breadcrumb: { - label: 'edge.instances', - icon: 'router' + menuId: MenuId.edges } }, children: [ @@ -307,8 +306,7 @@ const routes: Routes = [ path: 'ruleChains', data: { breadcrumb: { - label: 'edge.rulechain-templates', - icon: 'settings_ethernet' + menuId: MenuId.rulechain_templates } }, children: [ diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-routing.module.ts b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-routing.module.ts index d08f48bf7b..4666b7234f 100644 --- a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-routing.module.ts @@ -24,14 +24,14 @@ import { EntityDetailsPageComponent } from '@home/components/entity/entity-detai import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; import { BreadCrumbConfig } from '@shared/components/breadcrumb'; +import { MenuId } from '@core/services/menu.models'; export const entityViewRoutes: Routes = [ { path: 'entityViews', data: { breadcrumb: { - label: 'entity-view.entity-views', - icon: 'view_quilt' + menuId: MenuId.entity_views } }, children: [ diff --git a/ui-ngx/src/app/modules/home/pages/features/features-routing.module.ts b/ui-ngx/src/app/modules/home/pages/features/features-routing.module.ts index 7be7e896e8..0fc21f90f7 100644 --- a/ui-ngx/src/app/modules/home/pages/features/features-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/features/features-routing.module.ts @@ -19,6 +19,7 @@ import { Authority } from '@shared/models/authority.enum'; import { NgModule } from '@angular/core'; import { otaUpdatesRoutes } from '@home/pages/ota-update/ota-update-routing.module'; import { vcRoutes } from '@home/pages/vc/vc-routing.module'; +import { MenuId } from '@core/services/menu.models'; const routes: Routes = [ { @@ -26,8 +27,7 @@ const routes: Routes = [ data: { auth: [Authority.TENANT_ADMIN], breadcrumb: { - label: 'feature.advanced-features', - icon: 'construction' + menuId: MenuId.features } }, children: [ diff --git a/ui-ngx/src/app/modules/home/pages/home-links/home-links-routing.module.ts b/ui-ngx/src/app/modules/home/pages/home-links/home-links-routing.module.ts index 303a1bce4e..153fba8c93 100644 --- a/ui-ngx/src/app/modules/home/pages/home-links/home-links-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/home-links/home-links-routing.module.ts @@ -34,6 +34,7 @@ import { import { EntityKeyType } from '@shared/models/query/query.models'; import { ResourcesService } from '@core/services/resources.service'; import { isDefinedAndNotNull } from '@core/utils'; +import { MenuId } from '@core/services/menu.models'; const sysAdminHomePageJson = '/assets/dashboard/sys_admin_home_page.json'; const tenantAdminHomePageJson = '/assets/dashboard/tenant_admin_home_page.json'; @@ -125,8 +126,7 @@ const routes: Routes = [ auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], title: 'home.home', breadcrumb: { - label: 'home.home', - icon: 'home' + menuId: MenuId.home } }, resolve: { diff --git a/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.html b/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.html index 132d0e2c65..81142c501f 100644 --- a/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.html +++ b/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.html @@ -28,7 +28,7 @@ {{place.icon}} - {{place.name}} + {{place.customTranslate ? (place.name | customTranslate) : (place.name | translate)}} diff --git a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts index 63a3aace21..0b1a70996e 100644 --- a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts +++ b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts @@ -43,6 +43,7 @@ import { EntitiesModule } from '@home/pages/entities/entities.module'; import { FeaturesModule } from '@home/pages/features/features.module'; import { NotificationModule } from '@home/pages/notification/notification.module'; import { AccountModule } from '@home/pages/account/account.module'; +import { ScadaSymbolModule } from '@home/pages/scada-symbol/scada-symbol.module'; @NgModule({ exports: [ @@ -72,7 +73,8 @@ import { AccountModule } from '@home/pages/account/account.module'; OtaUpdateModule, UserModule, VcModule, - AccountModule + AccountModule, + ScadaSymbolModule ] }) export class HomePagesModule { } diff --git a/ui-ngx/src/app/modules/home/pages/notification/notification-routing.module.ts b/ui-ngx/src/app/modules/home/pages/notification/notification-routing.module.ts index 41f49ee708..b1bf35bcc6 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/notification-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/notification-routing.module.ts @@ -25,6 +25,7 @@ import { RecipientTableConfigResolver } from '@home/pages/notification/recipient import { TemplateTableConfigResolver } from '@home/pages/notification/template/template-table-config.resolver'; import { RuleTableConfigResolver } from '@home/pages/notification/rule/rule-table-config.resolver'; import { SendNotificationButtonComponent } from '@home/components/notification/send-notification-button.component'; +import { MenuId } from '@core/services/menu.models'; const routes: Routes = [ { @@ -33,8 +34,7 @@ const routes: Routes = [ data: { auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER, Authority.SYS_ADMIN], breadcrumb: { - label: 'notification.notification-center', - icon: 'mdi:message-badge' + menuId: MenuId.notifications_center }, routerTabsHeaderComponent: SendNotificationButtonComponent }, @@ -54,8 +54,7 @@ const routes: Routes = [ auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER, Authority.SYS_ADMIN], title: 'notification.inbox', breadcrumb: { - label: 'notification.inbox', - icon: 'inbox' + menuId: MenuId.notification_inbox } }, resolve: { @@ -69,8 +68,7 @@ const routes: Routes = [ auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], title: 'notification.sent', breadcrumb: { - label: 'notification.sent', - icon: 'outbox' + menuId: MenuId.notification_sent } }, resolve: { @@ -84,8 +82,7 @@ const routes: Routes = [ auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], title: 'notification.templates', breadcrumb: { - label: 'notification.templates', - icon: 'mdi:message-draw' + menuId: MenuId.notification_templates } }, resolve: { @@ -99,8 +96,7 @@ const routes: Routes = [ auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], title: 'notification.recipients', breadcrumb: { - label: 'notification.recipients', - icon: 'contacts' + menuId: MenuId.notification_recipients }, }, resolve: { @@ -114,8 +110,7 @@ const routes: Routes = [ auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], title: 'notification.rules', breadcrumb: { - label: 'notification.rules', - icon: 'mdi:message-cog' + menuId: MenuId.notification_rules } }, resolve: { diff --git a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html index 16c6e1d7f9..e9e34c24bc 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html @@ -104,6 +104,7 @@ +
+
{{ panelTitle | translate }}
+
+
+
scada.behavior.id
+ + + +
+
+
scada.behavior.name
+ + + +
+
+
scada.behavior.hint
+ + + +
+
+
scada.behavior.group-title
+ + + +
+
+
scada.behavior.type
+ + + + {{ scadaSymbolBehaviorTypeTranslations.get(type) | translate }} + + + +
+
+
scada.behavior.value-type
+ + + +
+ + {{ valueTypesMap.get(behaviorFormGroup.get('valueType').value).name | translate }} +
+
+ + + {{ valueTypesMap.get(valueType).name | translate }} + +
+
+
+ + +
+
scada.behavior.true-label
+ + + +
+
+
scada.behavior.false-label
+ + + +
+
+
scada.behavior.state-label
+ + + +
+
+
+
scada.behavior.default-settings
+ + +
+
+ +
+
scada.behavior.default-settings
+ + +
+
+ +
+
scada.behavior.default-settings
+ + +
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.scss new file mode 100644 index 0000000000..3fbbd245a7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.scss @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../scss/constants'; + +.tb-scada-symbol-behavior-settings-panel { + width: 540px; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-lt-md} { + width: 90vw; + } + .tb-scada-symbol-behavior-settings-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + margin: -10px; + padding: 10px; + .mat-mdc-form-field.tb-value-type { + .mat-mdc-form-field-infix { + max-height: 40px; + } + mat-select-trigger { + .tb-value-type-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 10px; + .mat-icon { + color: rgba(0, 0, 0, 0.38); + vertical-align: bottom; + } + } + } + } + } + .tb-scada-symbol-behavior-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-scada-symbol-behavior-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.ts new file mode 100644 index 0000000000..f7ce9c7a0e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.ts @@ -0,0 +1,189 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { merge } from 'rxjs'; +import { + defaultGetValueSettings, defaultSetValueSettings, defaultWidgetActionSettings, + ScadaSymbolBehavior, + ScadaSymbolBehaviorType, + scadaSymbolBehaviorTypes, + scadaSymbolBehaviorTypeTranslations +} from '@app/modules/home/components/widget/lib/scada/scada-symbol.models'; +import { ValueType, valueTypesMap } from '@shared/models/constants'; +import { GetValueSettings, SetValueSettings, ValueToDataType } from '@shared/models/action-widget-settings.models'; +import { WidgetService } from '@core/http/widget.service'; +import { mergeDeep } from '@core/utils'; +import { WidgetAction, widgetType } from '@shared/models/widget.models'; +import { IAliasController } from '@core/api/widget-api.models'; +import { WidgetActionCallbacks } from '@home/components/widget/action/manage-widget-actions.component.models'; + +@Component({ + selector: 'tb-scada-symbol-behavior-panel', + templateUrl: './scada-symbol-behavior-panel.component.html', + styleUrls: ['./scada-symbol-behavior-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolBehaviorPanelComponent implements OnInit { + + widgetType = widgetType; + + ScadaSymbolBehaviorType = ScadaSymbolBehaviorType; + + ValueType = ValueType; + + ValueToDataType = ValueToDataType; + + scadaSymbolBehaviorTypes = scadaSymbolBehaviorTypes; + scadaSymbolBehaviorTypeTranslations = scadaSymbolBehaviorTypeTranslations; + + valueTypes = Object.keys(ValueType) as ValueType[]; + + valueTypesMap = valueTypesMap; + + @Input() + isAdd = false; + + @Input() + behavior: ScadaSymbolBehavior; + + @Input() + aliasController: IAliasController; + + @Input() + callbacks: WidgetActionCallbacks; + + @Input() + disabled: boolean; + + @Input() + popover: TbPopoverComponent; + + @Output() + behaviorSettingsApplied = new EventEmitter(); + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + panelTitle: string; + + behaviorFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService) { + } + + ngOnInit(): void { + this.panelTitle = this.isAdd ? 'scada.behavior.add-behavior' : 'scada.behavior.behavior-settings'; + this.behaviorFormGroup = this.fb.group( + { + id: [this.behavior.id, [Validators.required]], + name: [this.behavior.name, [Validators.required]], + hint: [this.behavior.hint, []], + group: [this.behavior.group, []], + type: [this.behavior.type, [Validators.required]], + valueType: [this.behavior.valueType, [Validators.required]], + trueLabel: [this.behavior.trueLabel, []], + falseLabel: [this.behavior.falseLabel, []], + stateLabel: [this.behavior.stateLabel, []], + defaultGetValueSettings: [this.behavior.defaultGetValueSettings, [Validators.required]], + defaultSetValueSettings: [this.behavior.defaultSetValueSettings, [Validators.required]], + defaultWidgetActionSettings: [this.behavior.defaultWidgetActionSettings, [Validators.required]] + } + ); + if (this.disabled) { + this.behaviorFormGroup.disable({emitEvent: false}); + } else { + merge(this.behaviorFormGroup.get('type').valueChanges, + this.behaviorFormGroup.get('valueType').valueChanges).subscribe(() => { + this.updateValidators(); + }); + this.updateValidators(); + } + } + + cancel() { + this.popover?.hide(); + } + + applyBehaviorSettings() { + const behavior = this.behaviorFormGroup.getRawValue(); + this.behaviorSettingsApplied.emit(behavior); + } + + private updateValidators() { + const type: ScadaSymbolBehaviorType = this.behaviorFormGroup.get('type').value; + const valueType: ValueType = this.behaviorFormGroup.get('valueType').value; + let defaultGetValueSettingsValue = this.behaviorFormGroup.get('defaultGetValueSettings').value; + let defaultSetValueSettingsValue = this.behaviorFormGroup.get('defaultSetValueSettings').value; + let defaultWidgetActionSettingsValue = this.behaviorFormGroup.get('defaultWidgetActionSettings').value; + this.behaviorFormGroup.disable({emitEvent: false}); + this.behaviorFormGroup.get('id').enable({emitEvent: false}); + this.behaviorFormGroup.get('name').enable({emitEvent: false}); + this.behaviorFormGroup.get('type').enable({emitEvent: false}); + this.behaviorFormGroup.get('hint').enable({emitEvent: false}); + this.behaviorFormGroup.get('group').enable({emitEvent: false}); + switch (type) { + case ScadaSymbolBehaviorType.value: + this.behaviorFormGroup.get('valueType').enable({emitEvent: false}); + this.behaviorFormGroup.get('defaultGetValueSettings').enable({emitEvent: false}); + if (valueType === ValueType.BOOLEAN) { + this.behaviorFormGroup.get('trueLabel').enable({emitEvent: false}); + this.behaviorFormGroup.get('falseLabel').enable({emitEvent: false}); + this.behaviorFormGroup.get('stateLabel').enable({emitEvent: false}); + } + if (!defaultGetValueSettingsValue) { + defaultGetValueSettingsValue = mergeDeep({} as GetValueSettings, defaultGetValueSettings(valueType)); + this.behaviorFormGroup.get('defaultGetValueSettings').patchValue(defaultGetValueSettingsValue, {emitEvent: true}); + } + if (defaultSetValueSettingsValue) { + this.behaviorFormGroup.get('defaultSetValueSettings').patchValue(null, {emitEvent: true}); + } + if (defaultWidgetActionSettingsValue) { + this.behaviorFormGroup.get('defaultWidgetActionSettings').patchValue(null, {emitEvent: true}); + } + break; + case ScadaSymbolBehaviorType.action: + this.behaviorFormGroup.get('valueType').enable({emitEvent: false}); + this.behaviorFormGroup.get('defaultSetValueSettings').enable({emitEvent: false}); + if (!defaultSetValueSettingsValue) { + defaultSetValueSettingsValue = mergeDeep({} as SetValueSettings, defaultSetValueSettings(valueType)); + this.behaviorFormGroup.get('defaultSetValueSettings').patchValue(defaultSetValueSettingsValue, {emitEvent: true}); + } + if (defaultGetValueSettingsValue) { + this.behaviorFormGroup.get('defaultGetValueSettings').patchValue(null, {emitEvent: true}); + } + if (defaultWidgetActionSettingsValue) { + this.behaviorFormGroup.get('defaultWidgetActionSettings').patchValue(null, {emitEvent: true}); + } + break; + case ScadaSymbolBehaviorType.widgetAction: + this.behaviorFormGroup.get('defaultWidgetActionSettings').enable({emitEvent: false}); + if (!defaultWidgetActionSettingsValue) { + defaultWidgetActionSettingsValue = mergeDeep({} as WidgetAction, defaultWidgetActionSettings); + this.behaviorFormGroup.get('defaultWidgetActionSettings').patchValue(defaultWidgetActionSettingsValue, {emitEvent: true}); + } + if (defaultGetValueSettingsValue) { + this.behaviorFormGroup.get('defaultGetValueSettings').patchValue(null, {emitEvent: true}); + } + if (defaultSetValueSettingsValue) { + this.behaviorFormGroup.get('defaultSetValueSettings').patchValue(null, {emitEvent: true}); + } + break; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.html new file mode 100644 index 0000000000..004b02bbd0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.html @@ -0,0 +1,50 @@ + + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.scss new file mode 100644 index 0000000000..d0592c431e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.scss @@ -0,0 +1,26 @@ +/** + * 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-scada-symbol-metadata-behavior-row { + .tb-id-field { + flex: 1 1 40%; + } + .tb-name-field { + flex: 1 1 40%; + } + .tb-type-field { + flex: 1 1 20%; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.ts new file mode 100644 index 0000000000..eb71f4ff9f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.ts @@ -0,0 +1,299 @@ +/// +/// 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, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + Renderer2, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + ValidatorFn, + Validators +} from '@angular/forms'; +import { + ScadaSymbolBehavior, + ScadaSymbolBehaviorType, + scadaSymbolBehaviorTypes, + scadaSymbolBehaviorTypeTranslations, updateBehaviorDefaultSettings +} from '@home/components/widget/lib/scada/scada-symbol.models'; +import { deepClone, isUndefinedOrNull } from '@core/utils'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { + ScadaSymbolBehaviorPanelComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component'; +import { ValueToDataType } from '@shared/models/action-widget-settings.models'; +import { + ScadaSymbolBehaviorsComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component'; +import { IAliasController } from '@core/api/widget-api.models'; +import { WidgetActionCallbacks } from '@home/components/widget/action/manage-widget-actions.component.models'; + +export const behaviorValid = (behavior: ScadaSymbolBehavior): boolean => { + if (!behavior.id || !behavior.name || !behavior.type) { + return false; + } + switch (behavior.type) { + case ScadaSymbolBehaviorType.value: + if (!behavior.valueType || !behavior.defaultGetValueSettings) { + return false; + } + break; + case ScadaSymbolBehaviorType.action: + if (!behavior.defaultSetValueSettings) { + return false; + } + if (behavior.defaultSetValueSettings.valueToData?.type === ValueToDataType.CONSTANT + && isUndefinedOrNull(behavior.defaultSetValueSettings.valueToData?.constantValue)) { + return false; + } + if (behavior.defaultSetValueSettings.valueToData?.type === ValueToDataType.FUNCTION + && isUndefinedOrNull(behavior.defaultSetValueSettings.valueToData?.valueToDataFunction)) { + return false; + } + break; + case ScadaSymbolBehaviorType.widgetAction: + break; + } + return true; +}; + +@Component({ + selector: 'tb-scada-symbol-metadata-behavior-row', + templateUrl: './scada-symbol-behavior-row.component.html', + styleUrls: ['./scada-symbol-behavior-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolBehaviorRowComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolBehaviorRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolBehaviorRowComponent implements ControlValueAccessor, OnInit, Validator { + + @ViewChild('idInput') + idInput: ElementRef; + + @ViewChild('editButton') + editButton: MatButton; + + scadaSymbolBehaviorTypes = scadaSymbolBehaviorTypes; + scadaSymbolBehaviorTypeTranslations = scadaSymbolBehaviorTypeTranslations; + + @Input() + disabled: boolean; + + @Input() + index: number; + + @Input() + aliasController: IAliasController; + + @Input() + callbacks: WidgetActionCallbacks; + + @Output() + behaviorRemoved = new EventEmitter(); + + behaviorRowFormGroup: UntypedFormGroup; + + modelValue: ScadaSymbolBehavior; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private cd: ChangeDetectorRef, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private behaviorsComponent: ScadaSymbolBehaviorsComponent) { + } + + ngOnInit() { + this.behaviorRowFormGroup = this.fb.group({ + id: [null, [this.behaviorIdValidator()]], + name: [null, [Validators.required]], + type: [null, [Validators.required]] + }); + this.behaviorRowFormGroup.valueChanges.subscribe( + () => this.updateModel() + ); + this.behaviorRowFormGroup.get('type').valueChanges.subscribe((newType: ScadaSymbolBehaviorType) => { + this.onTypeChanged(newType); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.behaviorRowFormGroup.disable({emitEvent: false}); + } else { + this.behaviorRowFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: ScadaSymbolBehavior): void { + this.modelValue = value; + this.behaviorRowFormGroup.patchValue( + { + id: value?.id, + name: value?.name, + type: value?.type + }, {emitEvent: false} + ); + this.cd.markForCheck(); + } + + editBehavior($event: Event, matButton: MatButton, add = false, editCanceled = () => {}) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const ctx: any = { + isAdd: add, + disabled: this.disabled, + aliasController: this.aliasController, + callbacks: this.callbacks, + behavior: deepClone(this.modelValue) + }; + const scadaSymbolBehaviorPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, ScadaSymbolBehaviorPanelComponent, ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + scadaSymbolBehaviorPanelPopover.tbComponentRef.instance.popover = scadaSymbolBehaviorPanelPopover; + scadaSymbolBehaviorPanelPopover.tbComponentRef.instance.behaviorSettingsApplied.subscribe((behavior) => { + scadaSymbolBehaviorPanelPopover.hide(); + this.behaviorRowFormGroup.patchValue( + { + id: behavior.id, + name: behavior.name, + type: behavior.type + }, {emitEvent: false} + ); + this.modelValue = behavior; + this.propagateChange(this.modelValue); + }); + scadaSymbolBehaviorPanelPopover.tbDestroy.subscribe(() => { + if (!behaviorValid(this.modelValue)) { + editCanceled(); + } + }); + } + } + + focus() { + this.idInput.nativeElement.scrollIntoView(); + this.idInput.nativeElement.focus(); + } + + onAdd(onCanceled: () => void) { + this.idInput.nativeElement.scrollIntoView(); + this.editBehavior(null, this.editButton, true, onCanceled); + } + + public validate(_c: UntypedFormControl) { + const idControl = this.behaviorRowFormGroup.get('id'); + if (idControl.hasError('behaviorIdNotUnique')) { + idControl.updateValueAndValidity({onlySelf: false, emitEvent: false}); + } + if (idControl.hasError('behaviorIdNotUnique')) { + this.behaviorRowFormGroup.get('id').markAsTouched(); + return { + behaviorIdNotUnique: true + }; + } + const behavior: ScadaSymbolBehavior = {...this.modelValue, ...this.behaviorRowFormGroup.value}; + if (!behaviorValid(behavior)) { + return { + behavior: true + }; + } + return null; + } + + private behaviorIdValidator(): ValidatorFn { + return control => { + if (!control.value) { + return { + required: true + }; + } + if (!this.behaviorsComponent.behaviorIdUnique(control.value, this.index)) { + return { + behaviorIdNotUnique: true + }; + } + return null; + }; + } + + private onTypeChanged(newType: ScadaSymbolBehaviorType) { + const prevModel = deepClone(this.modelValue); + this.modelValue = {...this.modelValue, ...{type: newType}}; + this.modelValue = updateBehaviorDefaultSettings(this.modelValue); + if (!behaviorValid(this.modelValue)) { + this.editBehavior(null, this.editButton, false, () => { + this.modelValue = prevModel; + this.behaviorRowFormGroup.patchValue( + { + type: prevModel.type + }, {emitEvent: true} + ); + }); + } + } + + private updateModel() { + const value: ScadaSymbolBehavior = this.behaviorRowFormGroup.value; + this.modelValue = {...this.modelValue, ...value}; + this.propagateChange(this.modelValue); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.html new file mode 100644 index 0000000000..7cefc6af63 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.html @@ -0,0 +1,65 @@ + +
+
+
+
scada.behavior.id
+
scada.behavior.name
+
scada.behavior.type
+
+
+
+
+ + +
+ +
+
+
+ +
+
+ +
+
+ + + {{ 'scada.behavior.no-behaviors' | translate }} + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.scss new file mode 100644 index 0000000000..11506a3c0e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.scss @@ -0,0 +1,47 @@ +/** + * 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-scada-symbol-behaviors { + flex: 1; + margin: 12px; + .tb-form-table-header-cell { + &.tb-id-header { + flex: 1 1 40%; + } + &.tb-name-header { + flex: 1 1 40%; + } + &.tb-type-header { + flex: 1 1 20%; + } + &.tb-actions-header { + width: 120px; + min-width: 120px; + &.disabled { + width: 40px; + min-width: 40px; + } + } + } + .tb-form-table { + overflow: hidden; + } + .tb-form-table-body { + overflow: auto; + tb-scada-symbol-metadata-behavior-row { + overflow: hidden; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.ts new file mode 100644 index 0000000000..0ad1e59e05 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.ts @@ -0,0 +1,219 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + forwardRef, + HostBinding, + Input, + OnInit, + QueryList, + ViewChildren, + ViewEncapsulation +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { + defaultGetValueSettings, + ScadaSymbolBehavior, + ScadaSymbolBehaviorType +} from '@home/components/widget/lib/scada/scada-symbol.models'; +import { ValueType } from '@shared/models/constants'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { + behaviorValid, + ScadaSymbolBehaviorRowComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component'; +import { GetValueSettings, ValueToDataType } from '@shared/models/action-widget-settings.models'; +import { TranslateService } from '@ngx-translate/core'; +import { mergeDeep } from '@core/utils'; +import { IAliasController } from '@core/api/widget-api.models'; +import { WidgetActionCallbacks } from '@home/components/widget/action/manage-widget-actions.component.models'; + +@Component({ + selector: 'tb-scada-symbol-metadata-behaviors', + templateUrl: './scada-symbol-behaviors.component.html', + styleUrls: ['./scada-symbol-behaviors.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolBehaviorsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolBehaviorsComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolBehaviorsComponent implements ControlValueAccessor, OnInit, Validator { + + @HostBinding('style.display') styleDisplay = 'flex'; + @HostBinding('style.overflow') styleOverflow = 'hidden'; + + @ViewChildren(ScadaSymbolBehaviorRowComponent) + behaviorRows: QueryList; + + @Input() + aliasController: IAliasController; + + @Input() + callbacks: WidgetActionCallbacks; + + @Input() + disabled: boolean; + + behaviorsFormGroup: UntypedFormGroup; + + errorText = ''; + + get dragEnabled(): boolean { + return !this.disabled && this.behaviorsFormArray().controls.length > 1; + } + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private translate: TranslateService) { + } + + ngOnInit() { + this.behaviorsFormGroup = this.fb.group({ + behaviors: this.fb.array([]) + }); + this.behaviorsFormGroup.valueChanges.subscribe( + () => { + let behaviors: ScadaSymbolBehavior[] = this.behaviorsFormGroup.get('behaviors').value; + if (behaviors) { + behaviors = behaviors.filter(b => behaviorValid(b)); + } + this.propagateChange(behaviors); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.behaviorsFormGroup.disable({emitEvent: false}); + } else { + this.behaviorsFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: ScadaSymbolBehavior[] | undefined): void { + const behaviors= value || []; + this.behaviorsFormGroup.setControl('behaviors', this.prepareBehaviorsFormArray(behaviors), {emitEvent: false}); + } + + public validate(c: UntypedFormControl) { + this.errorText = ''; + const behaviorsArray = this.behaviorsFormGroup.get('behaviors') as UntypedFormArray; + const notUniqueControls = + behaviorsArray.controls.filter(control => control.hasError('behaviorIdNotUnique')); + for (const control of notUniqueControls) { + control.updateValueAndValidity({onlySelf: false, emitEvent: false}); + if (control.hasError('behaviorIdNotUnique')) { + this.errorText = this.translate.instant('scada.behavior.not-unique-behavior-ids-error'); + } + } + const valid = this.behaviorsFormGroup.valid; + return valid ? null : { + behaviors: { + valid: false, + }, + }; + } + + public behaviorIdUnique(id: string, index: number): boolean { + const behaviorsArray = this.behaviorsFormGroup.get('behaviors') as UntypedFormArray; + for (let i = 0; i < behaviorsArray.controls.length; i++) { + if (i !== index) { + const otherControl = behaviorsArray.controls[i]; + if (id === otherControl.value.id) { + return false; + } + } + } + return true; + } + + behaviorDrop(event: CdkDragDrop) { + const behaviorsArray = this.behaviorsFormGroup.get('behaviors') as UntypedFormArray; + const behavior = behaviorsArray.at(event.previousIndex); + behaviorsArray.removeAt(event.previousIndex); + behaviorsArray.insert(event.currentIndex, behavior); + } + + behaviorsFormArray(): UntypedFormArray { + return this.behaviorsFormGroup.get('behaviors') as UntypedFormArray; + } + + trackByBehavior(index: number, behaviorControl: AbstractControl): any { + return behaviorControl; + } + + removeBehavior(index: number, emitEvent = true) { + (this.behaviorsFormGroup.get('behaviors') as UntypedFormArray).removeAt(index, {emitEvent}); + } + + addBehavior() { + const behavior: ScadaSymbolBehavior = { + id: '', + name: '', + type: ScadaSymbolBehaviorType.value, + valueType: ValueType.BOOLEAN, + defaultGetValueSettings: mergeDeep({} as GetValueSettings, defaultGetValueSettings(ValueType.BOOLEAN)) + }; + const behaviorsArray = this.behaviorsFormGroup.get('behaviors') as UntypedFormArray; + const behaviorControl = this.fb.control(behavior, []); + behaviorsArray.push(behaviorControl); + setTimeout(() => { + const behaviorRow = this.behaviorRows.get(this.behaviorRows.length-1); + behaviorRow.onAdd(() => { + this.removeBehavior(behaviorsArray.length-1); + }); + }); + } + + private prepareBehaviorsFormArray(behaviors: ScadaSymbolBehavior[] | undefined): UntypedFormArray { + const behaviorsControls: Array = []; + if (behaviors) { + behaviors.forEach((behavior) => { + behaviorsControls.push(this.fb.control(behavior, [])); + }); + } + return this.fb.array(behaviorsControls); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-components.module.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-components.module.ts new file mode 100644 index 0000000000..7507b4cdcb --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-components.module.ts @@ -0,0 +1,75 @@ +/// +/// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + ScadaSymbolMetadataTagComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component'; +import { + ScadaSymbolMetadataComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component'; +import { + ScadaSymbolMetadataTagsComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component'; +import { + ScadaSymbolBehaviorsComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component'; +import { + ScadaSymbolBehaviorRowComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component'; +import { + ScadaSymbolBehaviorPanelComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component'; +import { + ScadaSymbolPropertiesComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-properties.component'; +import { + ScadaSymbolPropertyRowComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component'; +import { + ScadaSymbolPropertyPanelComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component'; +import { WidgetSettingsCommonModule } from '@home/components/widget/lib/settings/common/widget-settings-common.module'; +import { + ScadaSymbolMetadataTagFunctionPanelComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component'; + +@NgModule({ + declarations: + [ + ScadaSymbolMetadataComponent, + ScadaSymbolMetadataTagComponent, + ScadaSymbolMetadataTagsComponent, + ScadaSymbolMetadataTagFunctionPanelComponent, + ScadaSymbolBehaviorsComponent, + ScadaSymbolBehaviorRowComponent, + ScadaSymbolBehaviorPanelComponent, + ScadaSymbolPropertiesComponent, + ScadaSymbolPropertyRowComponent, + ScadaSymbolPropertyPanelComponent + ], + imports: [ + CommonModule, + SharedModule, + WidgetSettingsCommonModule + ], + exports: [ + ScadaSymbolMetadataComponent + ] +}) +export class ScadaSymbolMetadataComponentsModule { } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.html new file mode 100644 index 0000000000..27c22df7a8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.html @@ -0,0 +1,55 @@ + + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.scss new file mode 100644 index 0000000000..31b00469ca --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.scss @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../scss/constants'; + +.tb-scada-symbol-metadata-tag-function-panel { + width: 800px; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-lt-md} { + width: 90vw; + } + .tb-scada-symbol-metadata-tag-function-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + margin: -10px; + padding: 10px; + } + .tb-scada-symbol-metadata-tag-function-title-container { + display: flex; + flex-direction: row; + gap: 16px; + align-items: center; + } + .tb-scada-symbol-metadata-tag-function-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-scada-symbol-metadata-tag-function-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.ts new file mode 100644 index 0000000000..d46ba364d7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.ts @@ -0,0 +1,126 @@ +/// +/// 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 { + AfterViewInit, + Component, + EventEmitter, + Input, + OnInit, + Output, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { WidgetService } from '@core/http/widget.service'; +import { TbEditorCompleter } from '@shared/models/ace/completion.models'; +import { TbHighlightRule } from '@shared/models/ace/ace.models'; +import { + scadaSymbolClickActionHighlightRules, + scadaSymbolClickActionPropertiesHighlightRules, + scadaSymbolElementStateRenderHighlightRules, + scadaSymbolElementStateRenderPropertiesHighlightRules +} from '@home/pages/scada-symbol/scada-symbol-editor.models'; +import { JsFuncComponent } from '@shared/components/js-func.component'; + +@Component({ + selector: 'tb-scada-symbol-metadata-tag-function-panel', + templateUrl: './scada-symbol-metadata-tag-function-panel.component.html', + styleUrls: ['./scada-symbol-metadata-tag-function-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolMetadataTagFunctionPanelComponent implements OnInit, AfterViewInit { + + @ViewChild('tagFunctionComponent') + tagFunctionComponent: JsFuncComponent; + + @Input() + tagFunction: string; + + @Input() + tagFunctionType: 'renderFunction' | 'clickAction'; + + @Input() + tag: string; + + @Input() + completer: TbEditorCompleter; + + @Input() + disabled: boolean; + + @Input() + popover: TbPopoverComponent; + + @Output() + tagFunctionApplied = new EventEmitter(); + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + tagFunctionFormGroup: UntypedFormGroup; + + panelTitle: string; + + tagFunctionArgs: string[]; + + tagFunctionHelpId: string; + + objectHighlightRules: TbHighlightRule[]; + + propertyHighlightRules: TbHighlightRule[]; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService) { + } + + ngOnInit(): void { + this.tagFunctionFormGroup = this.fb.group( + { + tagFunction: [this.tagFunction, []] + } + ); + if (this.disabled) { + this.tagFunctionFormGroup.disable({emitEvent: false}); + } + if (this.tagFunctionType === 'renderFunction') { + this.panelTitle = 'scada.state-render-function'; + this.tagFunctionArgs = ['ctx', 'element']; + this.objectHighlightRules = scadaSymbolElementStateRenderHighlightRules; + this.propertyHighlightRules = scadaSymbolElementStateRenderPropertiesHighlightRules; + this.tagFunctionHelpId = 'scada/tag_state_render_fn'; + } else if (this.tagFunctionType === 'clickAction') { + this.panelTitle = 'scada.tag.on-click-action'; + this.tagFunctionArgs = ['ctx', 'element', 'event']; + this.objectHighlightRules = scadaSymbolClickActionHighlightRules; + this.propertyHighlightRules = scadaSymbolClickActionPropertiesHighlightRules; + this.tagFunctionHelpId = 'scada/tag_click_action_fn'; + } + } + + ngAfterViewInit() { + this.tagFunctionComponent.focus(); + } + + cancel() { + this.popover?.hide(); + } + + applyTagFunction() { + const tagFunction: string = this.tagFunctionFormGroup.get('tagFunction').value; + this.tagFunctionApplied.emit(tagFunction); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.html new file mode 100644 index 0000000000..2b04dfaf13 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.html @@ -0,0 +1,48 @@ + + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.scss new file mode 100644 index 0000000000..08296a986e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.scss @@ -0,0 +1,35 @@ +/** + * 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-scada-symbol-metadata-tag-row { + .tb-tag-field { + flex: 1 1 70%; + } + .tb-state-render-function-field { + flex: 1 1 15%; + } + .tb-on-click-action-field { + flex: 1 1 15%; + } + .tb-state-render-function-field, .tb-on-click-action-field { + display: flex; + justify-content: flex-end; + .mdc-button { + .mat-mdc-button-touch-target { + height: 36px; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.ts new file mode 100644 index 0000000000..3894838bb6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.ts @@ -0,0 +1,200 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + forwardRef, + Input, + OnInit, + Renderer2, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { ScadaSymbolTag } from '@home/components/widget/lib/scada/scada-symbol.models'; +import { TbEditorCompleter } from '@shared/models/ace/completion.models'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { + ScadaSymbolMetadataTagFunctionPanelComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component'; + +@Component({ + selector: 'tb-scada-symbol-metadata-tag', + templateUrl: './scada-symbol-metadata-tag.component.html', + styleUrls: ['./scada-symbol-metadata-tag.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolMetadataTagComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolMetadataTagComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolMetadataTagComponent implements ControlValueAccessor, OnInit, Validator { + + @ViewChild('editStateRenderFunctionButton') + editStateRenderFunctionButton: MatButton; + + @ViewChild('editClickActionButton') + editClickActionButton: MatButton; + + @Input() + disabled: boolean; + + @Input() + elementStateRenderFunctionCompleter: TbEditorCompleter; + + @Input() + clickActionFunctionCompleter: TbEditorCompleter; + + tagFormGroup: UntypedFormGroup; + + modelValue: ScadaSymbolTag; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef) { + } + + ngOnInit() { + this.tagFormGroup = this.fb.group({ + tag: [null, []], + stateRenderFunction: [null, []], + clickAction: [null, []] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.tagFormGroup.disable({emitEvent: false}); + } else { + this.tagFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: ScadaSymbolTag): void { + this.modelValue = value; + const clickAction = value?.actions?.click?.actionFunction; + this.tagFormGroup.patchValue( + { + tag: value?.tag, + stateRenderFunction: value?.stateRenderFunction, + clickAction + }, {emitEvent: false} + ); + } + + public validate(_c: UntypedFormControl) { + const valid = this.tagFormGroup.valid; + return valid ? null : { + tag: { + valid: false, + }, + }; + } + + editTagStateRenderFunction(): void { + this.openTagFunction('renderFunction', this.editStateRenderFunctionButton); + } + + editClickAction(): void { + this.openTagFunction('clickAction', this.editClickActionButton); + } + + private openTagFunction(tagFunctionType: 'renderFunction' | 'clickAction', + button: MatButton) { + const trigger = button._elementRef.nativeElement; + trigger.scrollIntoView(); + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + let tagFunctionControl: AbstractControl; + let completer: TbEditorCompleter; + if (tagFunctionType === 'renderFunction') { + tagFunctionControl = this.tagFormGroup.get('stateRenderFunction'); + completer = this.elementStateRenderFunctionCompleter; + } else if (tagFunctionType === 'clickAction') { + tagFunctionControl = this.tagFormGroup.get('clickAction'); + completer = this.clickActionFunctionCompleter; + } + const ctx: any = { + tagFunction: tagFunctionControl.value, + tagFunctionType, + tag: this.tagFormGroup.get('tag').value, + completer, + disabled: this.disabled + }; + const scadaSymbolTagFunctionPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, ScadaSymbolMetadataTagFunctionPanelComponent, + ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + scadaSymbolTagFunctionPanelPopover.tbComponentRef.instance.popover = scadaSymbolTagFunctionPanelPopover; + scadaSymbolTagFunctionPanelPopover.tbComponentRef.instance.tagFunctionApplied.subscribe((tagFunction) => { + scadaSymbolTagFunctionPanelPopover.hide(); + tagFunctionControl.patchValue(tagFunction, {emitEvent: false}); + this.updateModel(); + }); + } + } + + private updateModel() { + const value = this.tagFormGroup.value; + this.modelValue = { + tag: value.tag, + stateRenderFunction: value.stateRenderFunction + }; + if (value.clickAction) { + this.modelValue.actions = { + click: { + actionFunction: value.clickAction + } + }; + } else { + this.modelValue.actions = null; + } + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.html new file mode 100644 index 0000000000..ec0f69a7f0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.html @@ -0,0 +1,40 @@ + + + + {{ 'scada.tag.no-tags' | translate }} + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.scss new file mode 100644 index 0000000000..1c4238a11d --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.scss @@ -0,0 +1,46 @@ +/** + * 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-scada-symbol-metadata-tags { + flex: 1; + margin: 12px; + .tb-form-table-header-cell { + &.tb-tag-header { + flex: 1 1 70%; + } + &.tb-state-render-function-header { + flex: 1 1 15%; + } + &.tb-on-click-action-header { + flex: 1 1 15%; + } + &.tb-state-render-function-header, &.tb-on-click-action-header { + display: flex; + justify-content: flex-end; + } + } + .tb-form-table { + overflow: hidden; + } + .tb-form-table-body { + overflow: auto; + tb-scada-symbol-metadata-tag { + overflow: hidden; + } + .mat-divider { + margin-top: 8px; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.ts new file mode 100644 index 0000000000..42dfe8b7e2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.ts @@ -0,0 +1,231 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + forwardRef, + HostBinding, + Input, + OnChanges, + OnInit, + QueryList, + SimpleChanges, + ViewChildren, + ViewEncapsulation +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { ScadaSymbolTag } from '@home/components/widget/lib/scada/scada-symbol.models'; +import { + ScadaSymbolMetadataTagComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component'; +import { TbEditorCompleter } from '@shared/models/ace/completion.models'; + +const tagIsEmpty = (tag: ScadaSymbolTag): boolean => + !tag.stateRenderFunction && !tag.actions?.click?.actionFunction; + +@Component({ + selector: 'tb-scada-symbol-metadata-tags', + templateUrl: './scada-symbol-metadata-tags.component.html', + styleUrls: ['./scada-symbol-metadata-tags.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolMetadataTagsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolMetadataTagsComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolMetadataTagsComponent implements ControlValueAccessor, OnInit, Validator, OnChanges { + + @HostBinding('style.display') styleDisplay = 'flex'; + @HostBinding('style.overflow') styleOverflow = 'hidden'; + + @ViewChildren(ScadaSymbolMetadataTagComponent) + metadataTags: QueryList; + + @Input() + disabled: boolean; + + @Input() + tags: string[]; + + @Input() + elementStateRenderFunctionCompleter: TbEditorCompleter; + + @Input() + clickActionFunctionCompleter: TbEditorCompleter; + + tagsFormGroup: UntypedFormGroup; + + private modelValue: ScadaSymbolTag[]; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder) { + } + + ngOnInit() { + this.tagsFormGroup = this.fb.group({ + tags: this.fb.array([]) + }); + const tagsResult = this.setupTags(); + this.tagsFormGroup.setControl('tags', this.prepareTagsFormArray(tagsResult.tags), {emitEvent: false}); + + this.tagsFormGroup.valueChanges.subscribe( + () => { + let value: ScadaSymbolTag[] = this.tagsFormGroup.get('tags').value; + if (value) { + value = value.filter(t => !tagIsEmpty(t)); + } + this.modelValue = value; + this.propagateChange(this.modelValue); + } + ); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (['tags'].includes(propName)) { + const tagsResult = this.setupTags(this.modelValue); + const tagsControls = this.prepareTagsFormArray(tagsResult.tags); + if (tagsResult.emitEvent) { + setTimeout(() => { + this.tagsFormGroup.setControl('tags', tagsControls, {emitEvent: true}); + this.setDisabledState(this.disabled); + }); + } else { + this.tagsFormGroup.setControl('tags', tagsControls, {emitEvent: false}); + this.setDisabledState(this.disabled); + } + } + } + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.tagsFormGroup.disable({emitEvent: false}); + } else { + this.tagsFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: ScadaSymbolTag[] | undefined): void { + this.modelValue = value || []; + const tagsResult= this.setupTags(this.modelValue); + this.tagsFormGroup.setControl('tags', this.prepareTagsFormArray(tagsResult.tags), {emitEvent: false}); + this.setDisabledState(this.disabled); + } + + public validate(_c: UntypedFormControl) { + const valid = this.tagsFormGroup.valid; + return valid ? null : { + tags: { + valid: false, + }, + }; + } + + tagsFormArray(): UntypedFormArray { + return this.tagsFormGroup.get('tags') as UntypedFormArray; + } + + trackByTag(_index: number, tagControl: AbstractControl): any { + return tagControl; + } + + editTagStateRenderFunction(tag: string): void { + setTimeout(() => { + const tags: ScadaSymbolTag[] = this.tagsFormGroup.get('tags').value; + const index = tags.findIndex(t => t.tag === tag); + const tagComponent = this.metadataTags.get(index); + tagComponent?.editTagStateRenderFunction(); + }); + } + + editTagClickAction(tag: string): void { + setTimeout(() => { + const tags: ScadaSymbolTag[] = this.tagsFormGroup.get('tags').value; + const index = tags.findIndex(t => t.tag === tag); + const tagComponent = this.metadataTags.get(index); + tagComponent?.editClickAction(); + }); + } + + private setupTags(existing?: ScadaSymbolTag[]): {tags: ScadaSymbolTag[]; emitEvent: boolean} { + existing = (existing || []).filter(t => !tagIsEmpty(t)); + const result = (this.tags || []).sort().map(tag => ({ + tag, + stateRenderFunction: null, + actions: null + })); + for (const tag of existing) { + const found = result.find(t => t.tag === tag.tag); + if (found) { + found.stateRenderFunction = tag.stateRenderFunction; + if (tag.actions?.click?.actionFunction) { + found.actions = { + click: { + actionFunction: tag.actions.click.actionFunction + } + }; + } + } + } + const tagRemoved = !!existing.find(existingTag => + !result.find(t => t.tag === existingTag.tag)); + return { + tags: result, + emitEvent: tagRemoved + }; + } + + private prepareTagsFormArray(tags: ScadaSymbolTag[] | undefined): UntypedFormArray { + const tagsControls: Array = []; + if (tags) { + tags.forEach((tag) => { + tagsControls.push(this.fb.control(tag, [])); + }); + } + return this.fb.array(tagsControls); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.html new file mode 100644 index 0000000000..9e5d22fa94 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.html @@ -0,0 +1,115 @@ + + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.scss new file mode 100644 index 0000000000..370984230b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.scss @@ -0,0 +1,116 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../scss/constants'; + +.tb-scada-symbol-metadata { + display: flex; + flex-direction: column; + gap: 8px; + .tb-scada-symbol-metadata-header { + padding: 24px 24px 0; + display: flex; + gap: 12px; + flex-direction: column-reverse; + align-items: flex-start; + @media #{$mat-gt-sm} { + gap: 0; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + .tb-scada-symbol-metadata-header-components { + width: 100%; + flex: 1; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + } + .tb-scada-symbol-metadata-content { + display: flex; + & > .mat-content { + padding-top: 8px; + @media #{$mat-xs} { + padding-left: 8px; + padding-right: 8px; + } + } + flex: 1; + overflow: auto; + & > div { + padding: 16px; + } + .mat-content { + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-xs} { + gap: 8px; + } + &.overflow-hidden { + overflow: hidden; + } + } + .tb-form-panel { + .mat-expansion-panel.tb-settings { + & > .mat-expansion-panel-header { + font-weight: 400; + } + } + } + } +} + +.tb-js-func { + .ace_tb { + &.ace_scada-symbol { + &-ctx { + color: #C52F00; + &-properties, &-property { + color: #185F2A; + } + &-values, &-value { + color: #2CAA00; + } + &-tags, &-tag { + color: #DF8600; + } + &-api, &-api-method { + color: #7214D0; + } + &-svg, &-svg-method { + color: #c24c1a; + } + &-properties, &-values, &-tags, &-api { + // font-style: italic; + // font-weight: bold; + } + &-property, &-value, &-tag, &-api-method { + font-weight: 700; + } + } + &-svg, &-element, &-event { + color: #C52F00; + // font-weight: bold; + &-properties { + color: #7214D0; + font-weight: 700; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts new file mode 100644 index 0000000000..f5ecaab3d2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts @@ -0,0 +1,247 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + forwardRef, + Input, + OnChanges, + OnInit, + SimpleChanges, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { PageComponent } from '@shared/components/page.component'; +import { emptyMetadata, ScadaSymbolMetadata } from '@home/components/widget/lib/scada/scada-symbol.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ToggleHeaderOption } from '@shared/components/toggle-header.component'; +import { TranslateService } from '@ngx-translate/core'; +import { + ScadaSymbolMetadataTagsComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component'; +import { TbEditorCompleter } from '@shared/models/ace/completion.models'; +import { + clickActionFunctionCompletions, + elementStateRenderFunctionCompletions, + generalStateRenderFunctionCompletions, + scadaSymbolContextCompletion, + scadaSymbolGeneralStateRenderHighlightRules, + scadaSymbolGeneralStateRenderPropertiesHighlightRules +} from '@home/pages/scada-symbol/scada-symbol-editor.models'; +import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; +import { IAliasController } from '@core/api/widget-api.models'; +import { WidgetActionCallbacks } from '@home/components/widget/action/manage-widget-actions.component.models'; + +@Component({ + selector: 'tb-scada-symbol-metadata', + templateUrl: './scada-symbol-metadata.component.html', + styleUrls: ['./scada-symbol-metadata.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolMetadataComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolMetadataComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolMetadataComponent extends PageComponent implements OnInit, OnChanges, ControlValueAccessor, Validator { + + @ViewChild('symbolMetadataTags') + symbolMetadataTags: ScadaSymbolMetadataTagsComponent; + + @Input() + aliasController: IAliasController; + + @Input() + callbacks: WidgetActionCallbacks; + + @Input() + disabled: boolean; + + @Input() + tags: string[]; + + private modelValue: ScadaSymbolMetadata; + + private propagateChange = null; + + public metadataFormGroup: UntypedFormGroup; + + headerOptions: ToggleHeaderOption[] = [ + { + name: this.translate.instant('scada.general'), + value: 'general' + }, + { + name: this.translate.instant('scada.tags'), + value: 'tags' + }, + { + name: this.translate.instant('scada.behavior.behavior'), + value: 'behavior' + }, + { + name: this.translate.instant('scada.properties'), + value: 'properties' + } + ]; + + selectedOption = 'general'; + + generalStateRenderFunctionCompleter: TbEditorCompleter; + elementStateRenderFunctionCompleter: TbEditorCompleter; + clickActionFunctionCompleter: TbEditorCompleter; + + scadaSymbolGeneralStateRenderHighlightRules = scadaSymbolGeneralStateRenderHighlightRules; + + scadaSymbolGeneralStateRenderPropertiesHighlightRules = scadaSymbolGeneralStateRenderPropertiesHighlightRules; + + constructor(protected store: Store, + private fb: UntypedFormBuilder, + private translate: TranslateService, + private customTranslate: CustomTranslatePipe) { + super(store); + } + + ngOnInit(): void { + this.metadataFormGroup = this.fb.group({ + title: [null, [Validators.required]], + description: [null], + searchTags: [null], + widgetSizeX: [null, [Validators.min(1), Validators.max(24), Validators.required]], + widgetSizeY: [null, [Validators.min(1), Validators.max(24), Validators.required]], + stateRenderFunction: [null], + tags: [null], + behavior: [null], + properties: [null] + }); + this.updateFunctionCompleters(emptyMetadata()); + + this.metadataFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (['tags'].includes(propName)) { + this.updateFunctionCompleters(this.modelValue); + } + } + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.metadataFormGroup.disable({emitEvent: false}); + } else { + this.metadataFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: ScadaSymbolMetadata): void { + this.modelValue = value; + this.metadataFormGroup.patchValue( + { + title: value?.title, + description: value?.description, + searchTags: value?.searchTags, + widgetSizeX: value?.widgetSizeX || 3, + widgetSizeY: value?.widgetSizeY || 3, + stateRenderFunction: value?.stateRenderFunction, + tags: value?.tags, + behavior: value?.behavior, + properties: value?.properties + }, {emitEvent: false} + ); + this.updateFunctionCompleters(value); + } + + editTagStateRenderFunction(tag: string): void { + this.selectedOption = 'tags'; + this.symbolMetadataTags.editTagStateRenderFunction(tag); + } + + editTagClickAction(tag: string): void { + this.selectedOption = 'tags'; + this.symbolMetadataTags.editTagClickAction(tag); + } + + public validate(c: UntypedFormControl) { + const valid = this.metadataFormGroup.valid; + return valid ? null : { + metadata: { + valid: false, + }, + }; + } + + private updateModel() { + const metadata: ScadaSymbolMetadata = this.metadataFormGroup.getRawValue(); + this.modelValue = metadata; + this.propagateChange(this.modelValue); + this.updateFunctionCompleters(metadata); + } + + private updateFunctionCompleters(metadata: ScadaSymbolMetadata) { + const contextCompleter = scadaSymbolContextCompletion(metadata, this.tags, this.customTranslate); + const generalStateRender = generalStateRenderFunctionCompletions(contextCompleter); + if (!this.generalStateRenderFunctionCompleter) { + this.generalStateRenderFunctionCompleter = new TbEditorCompleter(generalStateRender); + } else { + this.generalStateRenderFunctionCompleter.updateCompletions(generalStateRender); + } + const elementStateRender = elementStateRenderFunctionCompletions(contextCompleter); + if (!this.elementStateRenderFunctionCompleter) { + this.elementStateRenderFunctionCompleter = new TbEditorCompleter(elementStateRender); + } else { + this.elementStateRenderFunctionCompleter.updateCompletions(elementStateRender); + } + const clickAction = clickActionFunctionCompletions(contextCompleter); + if (!this.clickActionFunctionCompleter) { + this.clickActionFunctionCompleter = new TbEditorCompleter(clickAction); + } else { + this.clickActionFunctionCompleter.updateCompletions(clickAction); + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.html new file mode 100644 index 0000000000..1b553a5130 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.html @@ -0,0 +1,64 @@ + +
+
+
+
scada.property.id
+
scada.property.name
+
scada.property.type
+
+
+
+
+ + +
+ +
+
+
+ +
+
+ +
+
+ + + {{ 'scada.property.no-properties' | translate }} + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.scss new file mode 100644 index 0000000000..094ea0668b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.scss @@ -0,0 +1,47 @@ +/** + * 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-scada-symbol-properties { + flex: 1; + margin: 12px; + .tb-form-table-header-cell { + &.tb-id-header { + flex: 1 1 40%; + } + &.tb-name-header { + flex: 1 1 40%; + } + &.tb-type-header { + flex: 1 1 20%; + } + &.tb-actions-header { + width: 120px; + min-width: 120px; + &.disabled { + width: 40px; + min-width: 40px; + } + } + } + .tb-form-table { + overflow: hidden; + } + .tb-form-table-body { + overflow: auto; + tb-scada-symbol-metadata-property-row { + overflow: hidden; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.ts new file mode 100644 index 0000000000..840302161b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.ts @@ -0,0 +1,214 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + forwardRef, + HostBinding, + Input, + OnInit, + QueryList, + ViewChildren, + ViewEncapsulation +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { ScadaSymbolProperty, ScadaSymbolPropertyType } from '@home/components/widget/lib/scada/scada-symbol.models'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { + propertyValid, + ScadaSymbolPropertyRowComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-scada-symbol-metadata-properties', + templateUrl: './scada-symbol-properties.component.html', + styleUrls: ['./scada-symbol-properties.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolPropertiesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolPropertiesComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolPropertiesComponent implements ControlValueAccessor, OnInit, Validator { + + @HostBinding('style.display') styleDisplay = 'flex'; + @HostBinding('style.overflow') styleOverflow = 'hidden'; + + @ViewChildren(ScadaSymbolPropertyRowComponent) + propertyRows: QueryList; + + @Input() + disabled: boolean; + + booleanPropertyIds: string[] = []; + + propertiesFormGroup: UntypedFormGroup; + + errorText = ''; + + get dragEnabled(): boolean { + return !this.disabled && this.propertiesFormArray().controls.length > 1; + } + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private translate: TranslateService) { + } + + ngOnInit() { + this.propertiesFormGroup = this.fb.group({ + properties: this.fb.array([]) + }); + this.propertiesFormGroup.valueChanges.subscribe( + () => { + let properties: ScadaSymbolProperty[] = this.propertiesFormGroup.get('properties').value; + if (properties) { + properties = properties.filter(p => propertyValid(p)); + } + this.booleanPropertyIds = properties.filter(p => p.type === ScadaSymbolPropertyType.switch).map(p => p.id); + properties.forEach((p, i) => { + if (p.disableOnProperty && !this.booleanPropertyIds.includes(p.disableOnProperty)) { + p.disableOnProperty = null; + const controls = this.propertiesFormArray().controls; + controls[i].patchValue(p, {emitEvent: false}); + } + }); + this.propagateChange(properties); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.propertiesFormGroup.disable({emitEvent: false}); + } else { + this.propertiesFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: ScadaSymbolProperty[] | undefined): void { + const properties= value || []; + this.propertiesFormGroup.setControl('properties', this.preparePropertiesFormArray(properties), {emitEvent: false}); + this.booleanPropertyIds = properties.filter(p => p.type === ScadaSymbolPropertyType.switch).map(p => p.id); + } + + public validate(c: UntypedFormControl) { + this.errorText = ''; + const propertiesArray = this.propertiesFormGroup.get('properties') as UntypedFormArray; + const notUniqueControls = + propertiesArray.controls.filter(control => control.hasError('propertyIdNotUnique')); + for (const control of notUniqueControls) { + control.updateValueAndValidity({onlySelf: false, emitEvent: false}); + if (control.hasError('propertyIdNotUnique')) { + this.errorText = this.translate.instant('scada.property.not-unique-property-ids-error'); + } + } + const valid = this.propertiesFormGroup.valid; + return valid ? null : { + properties: { + valid: false, + }, + }; + } + + public propertyIdUnique(id: string, index: number): boolean { + const propertiesArray = this.propertiesFormGroup.get('properties') as UntypedFormArray; + for (let i = 0; i < propertiesArray.controls.length; i++) { + if (i !== index) { + const otherControl = propertiesArray.controls[i]; + if (id === otherControl.value.id) { + return false; + } + } + } + return true; + } + + propertyDrop(event: CdkDragDrop) { + const propertiesArray = this.propertiesFormGroup.get('properties') as UntypedFormArray; + const property = propertiesArray.at(event.previousIndex); + propertiesArray.removeAt(event.previousIndex); + propertiesArray.insert(event.currentIndex, property); + } + + propertiesFormArray(): UntypedFormArray { + return this.propertiesFormGroup.get('properties') as UntypedFormArray; + } + + trackByProperty(index: number, propertyControl: AbstractControl): any { + return propertyControl; + } + + removeProperty(index: number, emitEvent = true) { + (this.propertiesFormGroup.get('properties') as UntypedFormArray).removeAt(index, {emitEvent}); + } + + addProperty() { + const property: ScadaSymbolProperty = { + id: '', + name: '', + type: ScadaSymbolPropertyType.text, + default: '' + }; + const propertiesArray = this.propertiesFormGroup.get('properties') as UntypedFormArray; + const propertyControl = this.fb.control(property, []); + propertiesArray.push(propertyControl); + setTimeout(() => { + const propertyRow = this.propertyRows.get(this.propertyRows.length-1); + propertyRow.onAdd(() => { + this.removeProperty(propertiesArray.length-1); + }); + }); + } + + private preparePropertiesFormArray(properties: ScadaSymbolProperty[] | undefined): UntypedFormArray { + const propertiesControls: Array = []; + if (properties) { + properties.forEach((property) => { + propertiesControls.push(this.fb.control(property, [])); + }); + } + return this.fb.array(propertiesControls); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html new file mode 100644 index 0000000000..9fd461c229 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html @@ -0,0 +1,185 @@ + +
+
{{ panelTitle | translate }}
+
+
+
scada.property.id
+ + + +
+
+
scada.property.name
+ + + +
+
+
scada.property.type
+ + + + {{ scadaSymbolPropertyTypeTranslations.get(type) | translate }} + + + +
+
+
scada.property.default-value
+ + + + + + + + + + + + + + +
+
+
scada.property.number-settings
+
+
scada.property.min
+ + + + +
scada.property.max
+ + + + +
scada.property.step
+ + + +
+
+
+ + {{ 'scada.property.value-required' | translate }} + +
+
+ + + + {{ 'scada.property.advanced-ui-settings' | translate }} + + + +
+
scada.property.sub-label
+ + + +
+
+ + {{ 'scada.property.vertical-divider-after' | translate }} + +
+
+
scada.property.input-field-suffix
+ + + +
+
+
scada.property.disable-on-property
+ + + + {{ prop }} + + + +
+
+
scada.property.property-row-classes
+ + + + {{ clazz }} + + + +
+
+
scada.property.property-field-classes
+ + + + {{ clazz }} + + + +
+
+
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.scss new file mode 100644 index 0000000000..c4b5b153d9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.scss @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../scss/constants'; + +.tb-scada-symbol-property-settings-panel { + width: 540px; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-lt-md} { + width: 90vw; + } + .tb-scada-symbol-property-settings-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + margin: -10px; + padding: 10px; + } + .tb-scada-symbol-property-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-scada-symbol-property-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.ts new file mode 100644 index 0000000000..4c59787c0f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.ts @@ -0,0 +1,136 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { + ScadaSymbolProperty, + scadaSymbolPropertyFieldClasses, + scadaSymbolPropertyRowClasses, + ScadaSymbolPropertyType, + scadaSymbolPropertyTypes, + scadaSymbolPropertyTypeTranslations +} from '@home/components/widget/lib/scada/scada-symbol.models'; +import { defaultPropertyValue } from '@home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component'; +import { ValueType } from '@shared/models/constants'; + +@Component({ + selector: 'tb-scada-symbol-property-panel', + templateUrl: './scada-symbol-property-panel.component.html', + styleUrls: ['./scada-symbol-property-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolPropertyPanelComponent implements OnInit { + + ValueType = ValueType; + + ScadaSymbolPropertyType = ScadaSymbolPropertyType; + + scadaSymbolPropertyTypes = scadaSymbolPropertyTypes; + scadaSymbolPropertyTypeTranslations = scadaSymbolPropertyTypeTranslations; + + scadaSymbolPropertyRowClasses = scadaSymbolPropertyRowClasses; + + scadaSymbolPropertyFieldClasses = scadaSymbolPropertyFieldClasses; + + @Input() + isAdd = false; + + @Input() + property: ScadaSymbolProperty; + + @Input() + booleanPropertyIds: string[]; + + @Input() + disabled: boolean; + + @Input() + popover: TbPopoverComponent; + + @Output() + propertySettingsApplied = new EventEmitter(); + + panelTitle: string; + + propertyFormGroup: UntypedFormGroup; + + private propertyType: ScadaSymbolPropertyType; + + constructor(private fb: UntypedFormBuilder) { + } + + ngOnInit(): void { + this.panelTitle = this.isAdd ? 'scada.property.add-property' : 'scada.property.property-settings'; + this.propertyType = this.property.type; + this.propertyFormGroup = this.fb.group( + { + id: [this.property.id, [Validators.required]], + name: [this.property.name, [Validators.required]], + type: [this.property.type, [Validators.required]], + default: [this.property.default, []], + required: [this.property.required, []], + subLabel: [this.property.subLabel, []], + divider: [this.property.divider, []], + fieldSuffix: [this.property.fieldSuffix, []], + disableOnProperty: [this.property.disableOnProperty, []], + rowClass: [(this.property.rowClass || '').split(' '), []], + fieldClass: [(this.property.fieldClass || '').split(' '), []], + min: [this.property.min, []], + max: [this.property.max, []], + step: [this.property.step, [Validators.min(0)]] + } + ); + if (this.disabled) { + this.propertyFormGroup.disable({emitEvent: false}); + } else { + this.propertyFormGroup.get('type').valueChanges.subscribe(() => { + this.updateValidators(); + }); + this.updateValidators(); + } + } + + cancel() { + this.popover?.hide(); + } + + applyPropertySettings() { + const property = this.propertyFormGroup.getRawValue(); + property.rowClass = (property.rowClass || []).join(' '); + property.fieldClass = (property.fieldClass || []).join(' '); + this.propertySettingsApplied.emit(property); + } + + private updateValidators() { + const type: ScadaSymbolPropertyType = this.propertyFormGroup.get('type').value; + if (type === ScadaSymbolPropertyType.number) { + this.propertyFormGroup.get('min').enable({emitEvent: false}); + this.propertyFormGroup.get('max').enable({emitEvent: false}); + this.propertyFormGroup.get('step').enable({emitEvent: false}); + } else { + this.propertyFormGroup.get('min').disable({emitEvent: false}); + this.propertyFormGroup.get('max').disable({emitEvent: false}); + this.propertyFormGroup.get('step').disable({emitEvent: false}); + } + if (this.propertyType !== type) { + const defaultValue = defaultPropertyValue(type); + this.propertyFormGroup.get('default').patchValue(defaultValue, {emitEvent: false}); + this.propertyType = type; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.html new file mode 100644 index 0000000000..bb63e69d68 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.html @@ -0,0 +1,50 @@ + + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.scss new file mode 100644 index 0000000000..5c0b4f0166 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.scss @@ -0,0 +1,26 @@ +/** + * 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-scada-symbol-metadata-property-row { + .tb-id-field { + flex: 1 1 40%; + } + .tb-name-field { + flex: 1 1 40%; + } + .tb-type-field { + flex: 1 1 20%; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.ts new file mode 100644 index 0000000000..3c5ea6cb07 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.ts @@ -0,0 +1,281 @@ +/// +/// 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, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + Renderer2, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + ValidatorFn, + Validators +} from '@angular/forms'; +import { + ScadaSymbolProperty, + ScadaSymbolPropertyType, + scadaSymbolPropertyTypes, + scadaSymbolPropertyTypeTranslations +} from '@home/components/widget/lib/scada/scada-symbol.models'; +import { deepClone } from '@core/utils'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { constantColor, Font } from '@shared/models/widget-settings.models'; +import { + ScadaSymbolPropertyPanelComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component'; +import { + ScadaSymbolPropertiesComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-properties.component'; + +export const propertyValid = (property: ScadaSymbolProperty): boolean => !(!property.id || !property.name || !property.type); + +export const defaultPropertyValue = (type: ScadaSymbolPropertyType): any => { + switch (type) { + case ScadaSymbolPropertyType.text: + return ''; + case ScadaSymbolPropertyType.number: + return 0; + case ScadaSymbolPropertyType.switch: + return false; + case ScadaSymbolPropertyType.color: + return '#000'; + case ScadaSymbolPropertyType.color_settings: + return constantColor('#000'); + case ScadaSymbolPropertyType.font: + return { + size: 12, + sizeUnit: 'px', + family: 'Roboto', + weight: 'normal', + style: 'normal', + lineHeight: '1' + } as Font; + case ScadaSymbolPropertyType.units: + return ''; + } +}; + +@Component({ + selector: 'tb-scada-symbol-metadata-property-row', + templateUrl: './scada-symbol-property-row.component.html', + styleUrls: ['./scada-symbol-property-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolPropertyRowComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolPropertyRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolPropertyRowComponent implements ControlValueAccessor, OnInit, Validator { + + @ViewChild('idInput') + idInput: ElementRef; + + @ViewChild('editButton') + editButton: MatButton; + + scadaSymbolPropertyTypes = scadaSymbolPropertyTypes; + scadaSymbolPropertyTypeTranslations = scadaSymbolPropertyTypeTranslations; + + @Input() + disabled: boolean; + + @Input() + index: number; + + @Input() + booleanPropertyIds: string[]; + + @Output() + propertyRemoved = new EventEmitter(); + + propertyRowFormGroup: UntypedFormGroup; + + modelValue: ScadaSymbolProperty; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private cd: ChangeDetectorRef, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private propertiesComponent: ScadaSymbolPropertiesComponent) { + } + + ngOnInit() { + this.propertyRowFormGroup = this.fb.group({ + id: [null, [this.propertyIdValidator()]], + name: [null, [Validators.required]], + type: [null, [Validators.required]] + }); + this.propertyRowFormGroup.valueChanges.subscribe( + () => this.updateModel() + ); + this.propertyRowFormGroup.get('type').valueChanges.subscribe((newType: ScadaSymbolPropertyType) => { + this.onTypeChanged(newType); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.propertyRowFormGroup.disable({emitEvent: false}); + } else { + this.propertyRowFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: ScadaSymbolProperty): void { + this.modelValue = value; + this.propertyRowFormGroup.patchValue( + { + id: value?.id, + name: value?.name, + type: value?.type + }, {emitEvent: false} + ); + this.cd.markForCheck(); + } + + editProperty($event: Event, matButton: MatButton, add = false, editCanceled = () => {}) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const ctx: any = { + isAdd: add, + disabled: this.disabled, + booleanPropertyIds: this.booleanPropertyIds, + property: deepClone(this.modelValue) + }; + const scadaSymbolPropertyPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, ScadaSymbolPropertyPanelComponent, ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + scadaSymbolPropertyPanelPopover.tbComponentRef.instance.popover = scadaSymbolPropertyPanelPopover; + scadaSymbolPropertyPanelPopover.tbComponentRef.instance.propertySettingsApplied.subscribe((property) => { + scadaSymbolPropertyPanelPopover.hide(); + this.propertyRowFormGroup.patchValue( + { + id: property.id, + name: property.name, + type: property.type + }, {emitEvent: false} + ); + this.modelValue = property; + this.propagateChange(this.modelValue); + }); + scadaSymbolPropertyPanelPopover.tbDestroy.subscribe(() => { + if (!propertyValid(this.modelValue)) { + editCanceled(); + } + }); + } + } + + focus() { + this.idInput.nativeElement.scrollIntoView(); + this.idInput.nativeElement.focus(); + } + + onAdd(onCanceled: () => void) { + this.idInput.nativeElement.scrollIntoView(); + this.editProperty(null, this.editButton, true, onCanceled); + } + + public validate(c: UntypedFormControl) { + const idControl = this.propertyRowFormGroup.get('id'); + if (idControl.hasError('propertyIdNotUnique')) { + idControl.updateValueAndValidity({onlySelf: false, emitEvent: false}); + } + if (idControl.hasError('propertyIdNotUnique')) { + this.propertyRowFormGroup.get('id').markAsTouched(); + return { + propertyIdNotUnique: true + }; + } + const property: ScadaSymbolProperty = {...this.modelValue, ...this.propertyRowFormGroup.value}; + if (!propertyValid(property)) { + return { + property: true + }; + } + return null; + } + + private propertyIdValidator(): ValidatorFn { + return control => { + if (!control.value) { + return { + required: true + }; + } + if (!this.propertiesComponent.propertyIdUnique(control.value, this.index)) { + return { + propertyIdNotUnique: true + }; + } + return null; + }; + } + + private onTypeChanged(newType: ScadaSymbolPropertyType) { + this.modelValue = {...this.modelValue, ...{type: newType}}; + this.modelValue.default = defaultPropertyValue(newType); + } + + private updateModel() { + const value: ScadaSymbolProperty = this.propertyRowFormGroup.value; + this.modelValue = {...this.modelValue, ...value}; + this.propagateChange(this.modelValue); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html new file mode 100644 index 0000000000..4eaaf6d718 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html @@ -0,0 +1,67 @@ + +
+
+ +
+
+
+
+ + +
+
+ +
+
+
+
+ +
+
+ +
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.scss new file mode 100644 index 0000000000..c400a32fef --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.scss @@ -0,0 +1,224 @@ +/** + * 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-scada-symbol-editor-shape { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} +.tb-scada-symbol-editor-tooltips { + position: absolute; + inset: 0; + pointer-events: none; +} +.tb-scada-symbol-editor-buttons { + pointer-events: none; + position: absolute; + left: 20px; + top: 20px; + right: 20px; + display: flex; + justify-content: space-between; + align-items: flex-start; + + .tb-primary-fill { + background: #fff; + border-radius: 50%; + &:before { + opacity: 0.1; + } + } + + .tb-scada-symbol-editor-view-buttons { + display: flex; + flex-direction: column; + gap: 8px; + z-index: 101; + } + + .tb-scada-symbol-editor-zoom-buttons { + display: flex; + flex-direction: column; + z-index: 101; + background: #fff; + border-radius: 24px; + &.tb-primary-fill { + border-radius: 24px; + &:before { + opacity: 0.1; + } + } + } + + .tb-scada-symbol-editor-upload-buttons { + display: flex; + flex-direction: row; + gap: 8px; + z-index: 101; + } +} + +.tooltipster-sidetip.scada-symbol { + position: fixed; + .tooltipster-box { + user-select: none; + background: rgba(0, 0, 0, 0.54); + border: 1px solid rgba(0, 0, 0, 0); + border-radius: 8px; + display: flex; + align-items: center; + .tooltipster-content { + padding: 4px 8px; + font-size: 12px; + line-height: 12px; + font-weight: 500; + color: #ffffff; + } + } + &.tb-active { + .tooltipster-box { + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.38); + box-shadow: 0 0 10px 6px rgba(0, 0, 0, .2); + .tooltipster-content { + color: rgba(0, 0, 0, 0.76); + } + } + .tooltipster-arrow { + .tooltipster-arrow-uncropped { + .tooltipster-arrow-background { + border-width: 12px; + } + } + } + &.tooltipster-top { + .tooltipster-arrow { + bottom: -1px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-top-color: rgba(0, 0, 0, 0.38); + } + .tooltipster-arrow-background { + border-top-color: #fff; + left: -2px; + } + } + } + } + &.tooltipster-bottom { + .tooltipster-arrow { + top: -1px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-bottom-color: rgba(0, 0, 0, 0.38); + } + .tooltipster-arrow-background { + border-bottom-color: #fff; + left: -2px; + top: -1px; + } + } + } + } + &.tooltipster-left { + .tooltipster-arrow { + right: -1px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-left-color: rgba(0, 0, 0, 0.38); + } + .tooltipster-arrow-background { + border-left-color: #fff; + top: -2px; + } + } + } + } + &.tooltipster-right { + .tooltipster-arrow { + left: -1px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-right-color: rgba(0, 0, 0, 0.38); + } + .tooltipster-arrow-background { + border-right-color: #fff; + top: -2px; + left: -1px; + } + } + } + } + } + &.tooltipster-left, &.tooltipster-right { + .tooltipster-box { + min-height: 36px; + } + } + &.tooltipster-top { + .tooltipster-arrow { + bottom: -2px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-top-color: rgba(0, 0, 0, 0.54); + } + .tooltipster-arrow-background { + border-top-color: transparent; + } + } + } + } + &.tooltipster-bottom { + .tooltipster-arrow { + top: -2px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-bottom-color: rgba(0, 0, 0, 0.54); + } + .tooltipster-arrow-background { + border-bottom-color: transparent; + } + } + } + } + &.tooltipster-left { + .tooltipster-arrow { + right: -2px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-left-color: rgba(0, 0, 0, 0.54); + } + .tooltipster-arrow-background { + border-left-color: transparent; + } + } + } + } + &.tooltipster-right { + .tooltipster-arrow { + left: -2px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-right-color: rgba(0, 0, 0, 0.54); + } + .tooltipster-arrow-background { + border-right-color: transparent; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts new file mode 100644 index 0000000000..9228c3e561 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts @@ -0,0 +1,165 @@ +/// +/// 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 { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { + ScadaSymbolEditObject, + ScadaSymbolEditObjectCallbacks +} from '@home/pages/scada-symbol/scada-symbol-editor.models'; +import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; + +export interface ScadaSymbolEditorData { + scadaSymbolContent: string; +} + +@Component({ + selector: 'tb-scada-symbol-editor', + templateUrl: './scada-symbol-editor.component.html', + styleUrls: ['./scada-symbol-editor.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewInit, OnChanges { + + @ViewChild('scadaSymbolShape', {static: false}) + scadaSymbolShape: ElementRef; + + @ViewChild('tooltipsContainer', {static: false}) + tooltipsContainer: ElementRef; + + @ViewChild('tooltipsContainerComponent', {static: true}) + tooltipsContainerComponent: TbAnchorComponent; + + @Input() + data: ScadaSymbolEditorData; + + @Input() + editObjectCallbacks: ScadaSymbolEditObjectCallbacks; + + @Input() + readonly: boolean; + + @Output() + updateScadaSymbol = new EventEmitter(); + + @Output() + downloadScadaSymbol = new EventEmitter(); + + scadaSymbolEditObject: ScadaSymbolEditObject; + + zoomInDisabled = false; + zoomOutDisabled = false; + + @Input() + showHiddenElements = false; + + @Output() + showHiddenElementsChange = new EventEmitter(); + + displayShowHidden = false; + + constructor(private cd: ChangeDetectorRef) { + } + + ngOnInit(): void { + } + + ngAfterViewInit() { + this.editObjectCallbacks.onZoom = () => { + this.updateZoomButtonsState(); + }; + this.editObjectCallbacks.hasHiddenElements = (hasHidden) => { + this.displayShowHidden = hasHidden; + if (hasHidden) { + this.scadaSymbolEditObject.showHiddenElements(this.showHiddenElements); + } + this.cd.markForCheck(); + }; + this.scadaSymbolEditObject = new ScadaSymbolEditObject(this.scadaSymbolShape.nativeElement, + this.tooltipsContainer.nativeElement, + this.tooltipsContainerComponent.viewContainerRef, this.editObjectCallbacks, this.readonly); + if (this.data) { + this.updateContent(this.data.scadaSymbolContent); + } + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'data') { + if (this.scadaSymbolEditObject) { + setTimeout(() => { + this.updateContent(this.data.scadaSymbolContent); + }); + } + } else if (propName === 'readonly') { + this.scadaSymbolEditObject.setReadOnly(this.readonly); + } + } + } + } + + ngOnDestroy() { + this.scadaSymbolEditObject.destroy(); + } + + getContent(): string { + return this.scadaSymbolEditObject?.getContent(); + } + + zoomIn() { + this.scadaSymbolEditObject.zoomIn(); + } + + zoomOut() { + this.scadaSymbolEditObject.zoomOut(); + } + + toggleShowHidden() { + this.showHiddenElements = !this.showHiddenElements; + this.showHiddenElementsChange.emit(this.showHiddenElements); + this.scadaSymbolEditObject.showHiddenElements(this.showHiddenElements); + } + + private updateContent(content: string) { + this.displayShowHidden = false; + this.scadaSymbolEditObject.setContent(content); + setTimeout(() => { + this.updateZoomButtonsState(); + }); + } + + private updateZoomButtonsState() { + this.zoomInDisabled = this.scadaSymbolEditObject.zoomInDisabled(); + this.zoomOutDisabled = this.scadaSymbolEditObject.zoomOutDisabled(); + this.cd.markForCheck(); + } +} + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts new file mode 100644 index 0000000000..f3caf1dbfc --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts @@ -0,0 +1,1341 @@ +/// +/// 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 { ImageResourceInfo } from '@shared/models/resource.models'; +import * as svgjs from '@svgdotjs/svg.js'; +import { Box, Element, Rect, Style, SVG, Svg, Timeline } from '@svgdotjs/svg.js'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { ViewContainerRef } from '@angular/core'; +import { forkJoin, from } from 'rxjs'; +import { + setupAddTagPanelTooltip, + setupTagPanelTooltip +} from '@home/pages/scada-symbol/scada-symbol-tooltip.components'; +import { + ScadaSymbolBehavior, + ScadaSymbolBehaviorType, + scadaSymbolContentData, + ScadaSymbolMetadata, + ScadaSymbolProperty, + ScadaSymbolPropertyType +} from '@home/components/widget/lib/scada/scada-symbol.models'; +import { TbEditorCompletion, TbEditorCompletions } from '@shared/models/ace/completion.models'; +import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; +import { TbHighlightRule } from '@shared/models/ace/ace.models'; +import { ValueType } from '@shared/models/constants'; +import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; +import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; +import ITooltipsterHelper = JQueryTooltipster.ITooltipsterHelper; +import ITooltipPosition = JQueryTooltipster.ITooltipPosition; + +export interface ScadaSymbolData { + imageResource: ImageResourceInfo; + scadaSymbolContent: string; +} + +export interface ScadaSymbolEditObjectCallbacks { + tagHasStateRenderFunction: (tag: string) => boolean; + tagHasClickAction: (tag: string) => boolean; + editTagStateRenderFunction: (tag: string) => void; + editTagClickAction: (tag: string) => void; + tagsUpdated: (tags: string[]) => void; + hasHiddenElements?: (hasHidden: boolean) => void; + onSymbolEditObjectDirty: (dirty: boolean) => void; + onZoom?: () => void; +} + +const minSymbolZoom = 0.75; +const maxSymbolZoom = 4; + +export class ScadaSymbolEditObject { + + public svgShape: Svg; + private svgRootNodePart: string; + private box: Box; + private elements: ScadaSymbolElement[] = []; + private readonly shapeResize$: ResizeObserver; + private performSetup = false; + private hoverFilterStyle: Style; + private showHidden = false; + public scale = 1; + + public tags: string[] = []; + + private zoomFactor = 0.34; + + constructor(private rootElement: HTMLElement, + public tooltipsContainer: HTMLElement, + public viewContainerRef: ViewContainerRef, + private callbacks: ScadaSymbolEditObjectCallbacks, + public readonly: boolean) { + this.shapeResize$ = new ResizeObserver(() => { + this.resize(); + }); + } + + public setReadOnly(readonly: boolean) { + this.readonly = readonly; + } + + public setContent(svgContent: string) { + this.shapeResize$.unobserve(this.rootElement); + if (this.svgShape) { + this.destroyElements(); + this.svgShape.remove(); + } + this.scale = 1; + this.showHidden = false; + const contentData = scadaSymbolContentData(svgContent); + this.svgRootNodePart = contentData.svgRootNode; + this.svgShape = SVG().svg(contentData.innerSvg); + this.svgShape.node.style.overflow = 'visible'; + this.svgShape.node.style['user-select'] = 'none'; + const origSvg = SVG(`${contentData.svgRootNode}\n${contentData.innerSvg}\n`); + if (origSvg.type === 'svg') { + this.box = (origSvg as Svg).viewbox(); + if (origSvg.fill()) { + this.svgShape.fill(origSvg.fill()); + } + } else { + this.box = this.svgShape.bbox(); + } + origSvg.remove(); + this.svgShape.size(this.box.width, this.box.height); + this.svgShape.viewbox(`0 0 ${this.box.width} ${this.box.height}`); + this.svgShape.style().attr('tb:inner', true).rule('.tb-element', {cursor: 'pointer', transition: '0.2s filter ease-in-out'}); + this.svgShape.addTo(this.rootElement); + this.updateHoverFilterStyle(); + this.performSetup = true; + this.shapeResize$.observe(this.rootElement); + } + + public getContent(): string { + if (this.svgShape) { + this.elements.forEach(e => e.restoreOrigVisibility()); + const svgContent = this.svgShape.svg((e: Element) => { + if (e.node.hasAttribute('tb:inner')) { + return false; + } else { + e.node.classList.remove('tb-element', 'tooltipstered'); + if (!e.node.classList.length) { + e.node.removeAttribute('class'); + } + e.attr('svgjs:data', null); + } + }, false); + this.showHiddenElements(this.showHidden); + return `${this.svgRootNodePart}\n${svgContent}\n`; + } else { + return null; + } + } + + public cancelEdit() { + this.elements.filter(e => e.isEditing()).forEach(e => e.stopEdit(true)); + } + + public zoomIn() { + const level = Math.min(Math.pow(1 + this.zoomFactor, 1.2) * this.svgShape.zoom(), maxSymbolZoom); + this.zoomAnimate(level); + } + + public zoomOut() { + const level = Math.max(Math.pow(1 + this.zoomFactor, -1.2) * this.svgShape.zoom(), minSymbolZoom); + this.zoomAnimate(level); + } + + public showHiddenElements(show: boolean) { + this.showHidden = show; + this.elements.forEach(e => show ? e.showInvisible() : e.hideInvisible()); + } + + private zoomAnimate(level: number, animationMs = 200) { + if (level !== this.svgShape.zoom()) { + this.hideTooltips(); + const runner = this.svgShape.animate(animationMs).ease('<>'); + runner + .zoom(level) + .during(() => { + const box = this.restrictToMargins(this.svgShape.viewbox()); + this.svgShape.viewbox(box); + }) + .after(() => { + this.postZoom(this.callbacks.onZoom); + }); + } + } + + public zoomInDisabled() { + return Number(this.svgShape.zoom().toFixed(5)) >= maxSymbolZoom; + } + + public zoomOutDisabled() { + return Number(this.svgShape.zoom().toFixed(5)) <= minSymbolZoom; + } + + private doSetup() { + this.setupZoomPan(); + (window as any).SVG = svgjs; + forkJoin([ + from(import('tooltipster')), + from(import('tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.min.js')) + ]).subscribe(() => { + this.setupElements(); + }); + } + + private setupZoomPan() { + this.svgShape.panZoom({ + zoomMin: minSymbolZoom, + zoomMax: maxSymbolZoom, + zoomFactor: this.zoomFactor + }); + this.svgShape.on('zoom', (e) => { + const { + detail: { level, focus } + } = e as any; + this.svgShape.zoom(level, focus); + this.postZoom(this.callbacks.onZoom, e); + }); + this.svgShape.on('panning', (e) => { + const box = (e as any).detail.box; + this.svgShape.viewbox(this.restrictToMargins(box)); + e.preventDefault(); + }); + this.svgShape.on('panStart', (_e) => { + this.hideTooltips(); + this.svgShape.node.style.cursor = 'grab'; + }); + this.svgShape.on('panEnd', (_e) => { + this.restoreTooltips(); + this.svgShape.node.style.cursor = 'default'; + }); + this.svgShape.zoom(minSymbolZoom); + } + + private postZoom(callback?: () => void, e?: any) { + const box = this.restrictToMargins(this.svgShape.viewbox()); + this.svgShape.viewbox(box); + setTimeout(() => { + this.updateTooltipPositions(); + }); + e?.preventDefault(); + if (callback) { + callback(); + } + } + + private restrictToMargins(box: Box): Box { + const marginX = Math.max(box.width - this.box.width, 0); + const marginY = Math.max(box.height - this.box.height, 0); + if (box.x < -marginX) { + box.x = -marginX; + } else if ((box.x + box.width) > (this.box.width + marginX)) { + box.x = this.box.width + marginX - box.width; + } + if (box.y < -marginY) { + box.y = -marginY; + } else if ((box.y + box.height) > (this.box.height + marginY)) { + box.y = this.box.height + marginY - box.height; + } + return box; + } + + private setupElements() { + this.svgShape.children().forEach(child => { + this.addElement(child); + }); + const overlappingGroups: ScadaSymbolElement[][] = []; + for (const el of this.elements) { + for (const other of this.elements) { + if (el !== other && el.overlappingCenters(other)) { + let overlappingGroup: ScadaSymbolElement[]; + for (const list of overlappingGroups) { + if (list.includes(other) || list.includes(el)) { + overlappingGroup = list; + break; + } + } + if (!overlappingGroup) { + overlappingGroup = [el, other]; + overlappingGroups.push(overlappingGroup); + } else { + if (!overlappingGroup.includes(el)) { + overlappingGroup.push(el); + } else if (!overlappingGroup.includes(other)){ + overlappingGroup.push(other); + } + } + } + } + } + for (const group of overlappingGroups) { + const centers = group.map(e => e.box.cy); + const center = centers.reduce((a, b) => a + b, 0) / centers.length; + const textElement = group.find(e => e.isText()); + const slots = textElement ? group.length % 2 === 0 ? (group.length + 1) : group.length : group.length; + if (textElement) { + textElement.setInnerTooltipOffset(0, center); + group.splice(group.indexOf(textElement), 1); + } + let offset = - (elementTooltipMinHeight * slots) / 2 + elementTooltipMinHeight / 2; + for (const element of group) { + if (textElement && offset === 0) { + offset += elementTooltipMinHeight; + } + element.setInnerTooltipOffset(offset, center); + offset += elementTooltipMinHeight; + } + } + for (const el of this.elements) { + el.init(); + } + this.updateTags(); + const hasHidden = this.elements.some(e => e.invisible); + if (this.callbacks.hasHiddenElements) { + this.callbacks.hasHiddenElements(hasHidden); + } + } + + private addElement(e: Element, parentInvisible = false) { + if (hasBBox(e)) { + const invisible = parentInvisible || !e.visible(); + const scadaSymbolElement = new ScadaSymbolElement(this, e, invisible); + this.elements.push(scadaSymbolElement); + e.children().forEach(child => { + if (!(child.type === 'tspan' && e.type === 'text')) { + this.addElement(child, invisible); + } + }, true); + } + } + + public destroy() { + if (this.shapeResize$) { + this.shapeResize$.disconnect(); + } + this.destroyElements(); + } + + private destroyElements() { + this.elements.forEach(e => { + e.destroy(); + }); + this.elements.length = 0; + } + + private resize() { + if (this.svgShape) { + const targetWidth = this.rootElement.getBoundingClientRect().width; + const targetHeight = this.rootElement.getBoundingClientRect().height; + if (targetWidth && targetHeight) { + const svgAspect = this.box.width / this.box.height; + const shapeAspect = targetWidth / targetHeight; + let scale: number; + if (svgAspect > shapeAspect) { + scale = targetWidth / this.box.width; + } else { + scale = targetHeight / this.box.height; + } + if (this.scale !== scale) { + this.scale = scale; + this.svgShape.node.style.transform = `scale(${this.scale})`; + this.updateHoverFilterStyle(); + this.updateTooltipPositions(); + } + if (this.performSetup) { + this.performSetup = false; + this.doSetup(); + } + } + } + } + + private updateHoverFilterStyle() { + if (this.hoverFilterStyle) { + this.hoverFilterStyle.remove(); + } + const whiteBlur = (2.8 / this.scale).toFixed(2); + const blackBlur = (1.2 / this.scale).toFixed(2); + this.hoverFilterStyle = + this.svgShape.style().attr('tb:inner', true).rule('.hovered', + { + filter: + `drop-shadow(0px 0px ${whiteBlur}px white) drop-shadow(0px 0px ${whiteBlur}px white) + drop-shadow(0px 0px ${whiteBlur}px white) drop-shadow(0px 0px ${whiteBlur}px white) + drop-shadow(0px 0px ${blackBlur}px black)` + } + ); + } + + private updateTooltipPositions() { + for (const e of this.elements) { + e.updateTooltipPosition(); + } + } + + private hideTooltips() { + for (const e of this.elements) { + e.hideTooltip(); + } + } + + private restoreTooltips() { + for (const e of this.elements) { + e.restoreTooltip(); + } + } + + public updateTags() { + this.tags = this.elements + .filter(e => e.hasTag()) + .map(e => e.tag) + .filter((v, i, a) => a.indexOf(v) === i) + .sort(); + this.callbacks.tagsUpdated(this.tags); + } + + public tagHasStateRenderFunction(tag: string): boolean { + return this.callbacks.tagHasStateRenderFunction(tag); + } + + public tagHasClickAction(tag: string): boolean { + return this.callbacks.tagHasClickAction(tag); + } + + public editTagStateRenderFunction(tag: string) { + this.callbacks.editTagStateRenderFunction(tag); + } + + public editTagClickAction(tag: string) { + this.callbacks.editTagClickAction(tag); + } + + public setDirty(dirty: boolean) { + this.callbacks.onSymbolEditObjectDirty(dirty); + } + +} + +const hasBBox = (e: Element): boolean => { + try { + if (e.bbox) { + let box = e.bbox(); + if (!box.width && !box.height && !e.visible()) { + e.show(); + box = e.bbox(); + e.hide(); + } + return !!box.width || !!box.height; + } else { + return false; + } + } catch (_e) { + return false; + } +}; + +const elementTooltipMinHeight = 36 + 8; +const elementTooltipMinWidth = 100; + +const groupRectStroke = 2; +const groupRectPadding = 2; + +export class ScadaSymbolElement { + + private highlightRect: Rect; + private highlightRectTimeline: Timeline; + + public tooltip: ITooltipsterInstance; + + public tag: string; + + private editing = false; + private onCancelEdit: () => void; + + private innerTooltipOffset = 0; + + public readonly box: Box; + + private highlighted = false; + + private tooltipMouseX: number; + private tooltipMouseY: number; + + private origVisibility = true; + + get readonly(): boolean { + return this.editObject.readonly; + } + + constructor(private editObject: ScadaSymbolEditObject, + public element: Element, + public invisible = false) { + this.tag = element.attr('tb:tag'); + this.origVisibility = element.visible(); + if (this.invisible) { + element.show(); + } + this.box = element.rbox(this.editObject.svgShape); +/* if (element.visible()) { + this.box = element.rbox(this.editObject.svgShape); + if (parentGroup && parentGroup.invisible) { + this.invisible = true; + } + } else { + element.show(); + this.box = element.rbox(this.editObject.svgShape); + element.hide(); + this.invisible = true; + }*/ + } + + public init() { + if (this.isGroup()) { + this.highlightRect = + this.editObject.svgShape + .rect(this.box.width + groupRectPadding * 2, this.box.height + groupRectPadding * 2) + .x(this.box.x - groupRectPadding) + .y(this.box.y - groupRectPadding) + .attr({ + 'tb:inner': true, + fill: 'none', + rx: this.unscaled(6), + stroke: 'rgba(0, 0, 0, 0.38)', + 'stroke-dasharray': '1', + 'stroke-width': this.unscaled(groupRectStroke), + opacity: 0}); + this.highlightRectTimeline = new Timeline(); + this.highlightRect.timeline(this.highlightRectTimeline); + this.highlightRect.hide(); + } else { + this.element.addClass('tb-element'); + } + this.element.on('mouseenter', (_event) => { + this.highlight(); + }); + this.element.on('mouseleave', (_event) => { + this.unhighlight(); + }); + if (!this.invisible) { + this.setupTooltips(); + } + this.hideInvisible(); + } + + private setupTooltips() { + if (this.hasTag()) { + this.createTagTooltip(); + } else if (!this.readonly) { + this.createAddTagTooltip(); + } + } + + public showInvisible() { + if (this.invisible) { + this.element.show(); + if (!this.tooltip) { + this.setupTooltips(); + } + } + } + + public hideInvisible() { + if (this.invisible) { + this.element.hide(); + if (this.tooltip) { + this.tooltip.destroy(); + this.tooltip = null; + } + } + } + + public restoreOrigVisibility() { + if (this.origVisibility) { + this.element.show(); + } else { + this.element.hide(); + } + } + + public overlappingCenters(otherElement: ScadaSymbolElement): boolean { + if (this.isGroup() || otherElement.isGroup()) { + return false; + } + return Math.abs(this.box.cx - otherElement.box.cx) * this.editObject.scale < elementTooltipMinWidth && + Math.abs(this.box.cy - otherElement.box.cy) * this.editObject.scale < elementTooltipMinHeight; + } + + public highlight() { + if (!this.highlighted) { + this.highlighted = true; + if (this.isGroup()) { + this.highlightRectTimeline.finish(); + this.highlightRect + .attr({ + rx: this.unscaled(6), + 'stroke-width': this.unscaled(groupRectStroke) + }); + this.highlightRect.show(); + this.highlightRect.animate(300).attr({opacity: 1}); + } else { + this.element.addClass('hovered'); + } + if (this.hasTag()) { + this.tooltip.reposition(); + $(this.tooltip.elementTooltip()).addClass('tb-active'); + } + } + } + + public unhighlight() { + if (this.highlighted) { + this.highlighted = false; + if (this.isGroup()) { + this.highlightRectTimeline.finish(); + this.highlightRect.animate(300).attr({opacity: 0}).after(() => { + this.highlightRect.hide(); + }); + } else { + this.element.removeClass('hovered'); + } + if (this.hasTag() && !this.editing) { + $(this.tooltip.elementTooltip()).removeClass('tb-active'); + } + } + } + + public clearTag() { + this.tooltip.destroy(); + this.tag = null; + this.element.attr('tb:tag', null); + this.unhighlight(); + this.createAddTagTooltip(); + this.editObject.setDirty(true); + this.editObject.updateTags(); + } + + public setTag(tag: string) { + this.tooltip.destroy(); + this.tag = tag; + this.element.attr('tb:tag', tag); + this.createTagTooltip(); + this.editObject.setDirty(true); + this.editObject.updateTags(); + } + + public hasStateRenderFunction(): boolean { + if (this.hasTag()) { + return this.editObject.tagHasStateRenderFunction(this.tag); + } else { + return false; + } + } + + public hasClickAction(): boolean { + if (this.hasTag()) { + return this.editObject.tagHasClickAction(this.tag); + } else { + return false; + } + } + + public editStateRenderFunction() { + if (this.hasTag()) { + this.editObject.editTagStateRenderFunction(this.tag); + } + } + + public editClickAction() { + if (this.hasTag()) { + this.editObject.editTagClickAction(this.tag); + } + } + + public startEdit(onCancelEdit: () => void) { + if (!this.editing) { + this.editObject.cancelEdit(); + this.editing = true; + this.onCancelEdit = onCancelEdit; + } + } + + public stopEdit(cancel = false) { + if (this.editing) { + this.editing = false; + if (cancel && this.onCancelEdit) { + this.onCancelEdit(); + } + this.onCancelEdit = null; + if (this.hasTag() && !this.highlighted) { + $(this.tooltip.elementTooltip()).removeClass('tb-active'); + } + } + } + + public isEditing() { + return this.editing; + } + + public updateTooltipPosition() { + if (this.tooltip && !this.tooltip.status().destroyed) { + this.tooltip.reposition(); + const tooltipElement = this.tooltip.elementTooltip(); + if (tooltipElement) { + tooltipElement.style.visibility = null; + } + } + } + + public hideTooltip() { + if (this.tooltip && !this.tooltip.status().destroyed) { + const tooltipElement = this.tooltip.elementTooltip(); + if (tooltipElement) { + tooltipElement.style.visibility = 'hidden'; + } + } + } + + public restoreTooltip() { + this.updateTooltipPosition(); + } + + public setInnerTooltipOffset(offset: number, center: number) { + this.innerTooltipOffset = offset + (center - this.box.cy) * this.editObject.scale; + } + + public destroy() { + if (this.tooltip) { + this.tooltip.destroy(); + } + } + + public get tooltipContainer(): JQuery { + return $(this.editObject.tooltipsContainer); + } + + private unscaled(size: number): number { + return size / (this.editObject.scale * this.editObject.svgShape.zoom()); + } + + private scaled(size: number): number { + return size * (this.editObject.scale * this.editObject.svgShape.zoom()); + } + + private createTagTooltip() { + const el = $(this.element.node); + el.tooltipster( + { + parent: this.tooltipContainer, + zIndex: 100, + arrow: this.isGroup(), + distance: this.isGroup() ? (this.scaled(groupRectPadding) + groupRectStroke) : 6, + theme: ['scada-symbol'], + delay: 0, + animationDuration: 0, + interactive: true, + trigger: 'custom', + side: 'top', + trackOrigin: false, + content: '', + functionPosition: (instance, helper, position) => + this.innerTagTooltipPosition(instance, helper, position), + functionReady: (_instance, helper) => { + const tooltipEl = $(helper.tooltip); + tooltipEl.on('mouseenter', () => { + this.highlight(); + }); + tooltipEl.on('mouseleave', () => { + this.unhighlight(); + }); + } + } + ); + this.tooltip = el.tooltipster('instance'); + this.setupTagPanel(); + } + + private setupTagPanel() { + setupTagPanelTooltip(this, this.editObject.viewContainerRef); + } + + private createAddTagTooltip() { + const el = $(this.element.node); + el.tooltipster( + { + parent: this.tooltipContainer, + zIndex: 100, + arrow: true, + theme: ['scada-symbol', 'tb-active'], + delay: [0, 300], + interactive: true, + trigger: 'hover', + side: ['top', 'left', 'bottom', 'right'], + trackOrigin: false, + content: '', + functionBefore: (instance, helper) => { + const mouseEvent = (helper.event as MouseEvent); + this.tooltipMouseX = mouseEvent.clientX; + this.tooltipMouseY = mouseEvent.clientY; + let side: TooltipPositioningSide; + if (this.isGroup()) { + side = 'top'; + } else { + side = this.calculateTooltipSide(helper.origin.getBoundingClientRect(), + mouseEvent.clientX, mouseEvent.clientY); + } + instance.option('side', side); + }, + functionPosition: (instance, helper, position) => + this.innerAddTagTooltipPosition(instance, helper, position), + functionReady: (_instance, helper) => { + const tooltipEl = $(helper.tooltip); + tooltipEl.on('mouseenter', () => { + this.highlight(); + }); + tooltipEl.on('mouseleave', () => { + this.unhighlight(); + }); + } + } + ); + this.tooltip = el.tooltipster('instance'); + this.setupAddTagPanel(); + } + + private setupAddTagPanel() { + setupAddTagPanelTooltip(this, this.editObject.viewContainerRef); + } + + private innerTagTooltipPosition(_instance: ITooltipsterInstance, helper: ITooltipsterHelper, + position: ITooltipPosition): ITooltipPosition { + const clientRect = helper.origin.getBoundingClientRect(); + if (!this.isGroup()) { + position.coord.top = clientRect.top + (clientRect.height - position.size.height) / 2 + + this.innerTooltipOffset * this.editObject.svgShape.zoom(); + position.coord.left = clientRect.left + (clientRect.width - position.size.width) / 2; + } else { + position.distance = this.scaled(groupRectPadding) + groupRectStroke; + position.coord.top = clientRect.top - position.size.height - (this.scaled(groupRectPadding) + groupRectStroke); + } + return position; + } + + private innerAddTagTooltipPosition(_instance: ITooltipsterInstance, + _helper: ITooltipsterHelper, position: ITooltipPosition): ITooltipPosition { + const distance = 10; + switch (position.side) { + case 'right': + position.coord.top = this.tooltipMouseY - position.size.height / 2; + position.coord.left = this.tooltipMouseX + distance; + position.target = this.tooltipMouseY; + break; + case 'top': + position.coord.top = this.tooltipMouseY - position.size.height - distance; + position.coord.left = this.tooltipMouseX - position.size.width / 2; + position.target = this.tooltipMouseX; + if (this.isGroup()) { + position.coord.top -= elementTooltipMinHeight; + } + break; + case 'left': + position.coord.top = this.tooltipMouseY - position.size.height / 2; + position.coord.left = this.tooltipMouseX - position.size.width - distance; + position.target = this.tooltipMouseY; + break; + case 'bottom': + position.coord.top = this.tooltipMouseY + distance; + position.coord.left = this.tooltipMouseX - position.size.width / 2; + position.target = this.tooltipMouseX; + break; + } + return position; + } + + private calculateTooltipSide(clientRect: DOMRect, mouseX: number, mouseY: number): TooltipPositioningSide { + let side: TooltipPositioningSide; + const cx = clientRect.left + clientRect.width / 2; + const cy = clientRect.top + clientRect.height / 2; + if (clientRect.width > clientRect.height) { + if (Math.abs(cx - mouseX) > clientRect.width / 4) { + if (mouseX < cx) { + side = 'left'; + } else { + side = 'right'; + } + } else { + if (mouseY < cy) { + side = 'top'; + } else { + side = 'bottom'; + } + } + } else { + if (Math.abs(cy - mouseY) > clientRect.height / 4) { + if (mouseY < cy) { + side = 'top'; + } else { + side = 'bottom'; + } + } else { + if (mouseX < cx) { + side = 'left'; + } else { + side = 'right'; + } + } + } + return side; + } + + public hasTag() { + return !!this.tag; + } + + public isGroup() { + return this.element.type === 'g'; + } + + public isText() { + return this.element.type === 'text'; + } + +} + +const scadaSymbolCtxObjectHighlightRules: TbHighlightRule[] = [ + { + class: 'scada-symbol-ctx', + regex: /(?<=\W|^)(ctx)(?=\W|$)\b/ + } +]; + +export const scadaSymbolGeneralStateRenderHighlightRules: TbHighlightRule[] = + scadaSymbolCtxObjectHighlightRules.concat({ + class: 'scada-symbol-svg', + regex: /(?<=\W|^)(svg)(?=\W|$)\b/ + }); + +export const scadaSymbolElementStateRenderHighlightRules: TbHighlightRule[] = + scadaSymbolCtxObjectHighlightRules.concat({ + class: 'scada-symbol-element', + regex: /(?<=\W|^)(element)(?=\W|$)\b/ + }); + +export const scadaSymbolClickActionHighlightRules: TbHighlightRule[] = + scadaSymbolElementStateRenderHighlightRules.concat({ + class: 'scada-symbol-event', + regex: /(?<=\W|^)(event)(?=\W|$)\b/ + }); + +const scadaSymbolCtxPropertyHighlightRules: TbHighlightRule[] = [ + { + class: 'scada-symbol-ctx-properties', + regex: /(?<=ctx\.)(properties)\b/ + }, + { + class: 'scada-symbol-ctx-tags', + regex: /(?<=ctx\.)(tags)\b/ + }, + { + class: 'scada-symbol-ctx-values', + regex: /(?<=ctx\.)(values)\b/ + }, + { + class: 'scada-symbol-ctx-api', + regex: /(?<=ctx\.)(api)\b/ + }, + { + class: 'scada-symbol-ctx-svg', + regex: /(?<=ctx\.)(svg)\b/ + }, + { + class: 'scada-symbol-ctx-property', + regex: /(?<=ctx\.properties\.)([a-zA-Z$_\u00a1-\uffff][a-zA-Z\d$_\u00a1-\uffff]*)\b/ + }, + { + class: 'scada-symbol-ctx-tag', + regex: /(?<=ctx\.tags\.)([a-zA-Z$_\u00a1-\uffff][a-zA-Z\d$_\u00a1-\uffff]*)\b/ + }, + { + class: 'scada-symbol-ctx-value', + regex: /(?<=ctx\.values\.)([a-zA-Z$_\u00a1-\uffff][a-zA-Z\d$_\u00a1-\uffff]*)\b/ + }, + { + class: 'scada-symbol-ctx-api-method', + regex: /(?<=ctx\.api\.)([a-zA-Z$_\u00a1-\uffff][a-zA-Z\d$_\u00a1-\uffff]*)\b/ + }, + { + class: 'scada-symbol-ctx-svg-method', + regex: /(?<=ctx\.svg\.)([a-zA-Z$_\u00a1-\uffff][a-zA-Z\d$_\u00a1-\uffff]*)\b/ + } +]; + +export const scadaSymbolGeneralStateRenderPropertiesHighlightRules: TbHighlightRule[] = + scadaSymbolCtxPropertyHighlightRules.concat({ + class: 'scada-symbol-svg-properties', + regex: /(?<=svg\.)([a-zA-Z$_\u00a1-\uffff][a-zA-Z\d$_\u00a1-\uffff]*)\b/ + }); + +export const scadaSymbolElementStateRenderPropertiesHighlightRules: TbHighlightRule[] = + scadaSymbolCtxPropertyHighlightRules.concat({ + class: 'scada-symbol-element-properties', + regex: /(?<=element\.)([a-zA-Z$_\u00a1-\uffff][a-zA-Z\d$_\u00a1-\uffff]*)\b/ + }); + +export const scadaSymbolClickActionPropertiesHighlightRules: TbHighlightRule[] = + scadaSymbolElementStateRenderPropertiesHighlightRules.concat({ + class: 'scada-symbol-event-properties', + regex: /(?<=event\.)([a-zA-Z$_\u00a1-\uffff][a-zA-Z\d$_\u00a1-\uffff]*)\b/ + }); + +export const generalStateRenderFunctionCompletions = (ctxCompletion: TbEditorCompletion): TbEditorCompletions => ({ + ctx: ctxCompletion, + svg: { + meta: 'argument', + type: 'SVG.Svg', + description: 'A root svg node. Instance of SVG.Svg.' + } + }); + +export const elementStateRenderFunctionCompletions = (ctxCompletion: TbEditorCompletion): TbEditorCompletions => ({ + ctx: ctxCompletion, + element: { + meta: 'argument', + type: 'Element', + description: 'SVG element.
' + + 'See Manipulating section to manipulate the element.
' + + 'See Animating section to animate the element.' + } + }); + +export const clickActionFunctionCompletions = (ctxCompletion: TbEditorCompletion): TbEditorCompletions => { + const completions = elementStateRenderFunctionCompletions(ctxCompletion); + completions.event = { + meta: 'argument', + type: 'Event', + description: 'DOM event.' + }; + return completions; +}; + +export const scadaSymbolContextCompletion = (metadata: ScadaSymbolMetadata, tags: string[], + customTranslate: CustomTranslatePipe): TbEditorCompletion => { + const properties: TbEditorCompletion = { + meta: 'object', + type: 'object', + description: 'An object holding all defined SCADA symbol properties.', + children: {} + }; + for (const property of metadata.properties) { + properties.children[property.id] = scadaSymbolPropertyCompletion(property, customTranslate); + } + const values: TbEditorCompletion = { + meta: 'object', + type: 'object', + description: 'An object holding all values obtained using behaviors of type "Value"', + children: {} + }; + const getValues = metadata.behavior.filter(b => b.type === ScadaSymbolBehaviorType.value); + for (const value of getValues) { + values.children[value.id] = scadaSymbolValueCompletion(value, customTranslate); + } + const tagsCompletions: TbEditorCompletion = { + meta: 'object', + type: 'object', + description: 'An object holding all tagged SVG elements grouped by tags (object keys).', + children: {} + }; + if (tags?.length) { + for (const tag of tags) { + tagsCompletions.children[tag] = { + meta: 'property', + description: 'An array of SVG elements.', + type: 'Array' + }; + } + } + return { + meta: 'argument', + type: 'ScadaSymbolContext', + description: 'Context of the SCADA symbol.', + children: { + api: { + meta: 'object', + type: 'ScadaSymbolApi', + description: 'SCADA symbol API', + children: { + animate: { + meta: 'function', + description: 'Finishes any previous animation and starts new animation for SVG element.', + args: [ + { + name: 'element', + description: 'SVG element', + type: 'Element' + }, + { + name: 'duration', + description: 'Animation duration in milliseconds', + type: 'number' + } + ], + return: { + description: 'Instance of SVG.Runner which has the same methods as any element and additional methods to control the runner.', + type: 'SVG.Runner' + } + }, + resetAnimation: { + meta: 'function', + description: 'Stops animation if any and restore SVG element initial state, resets animation timeline.', + args: [ + { + name: 'element', + description: 'SVG element', + type: 'Element' + }, + ] + }, + finishAnimation: { + meta: 'function', + description: 'Finishes animation if any, SVG element state updated according to the end animation values, ' + + 'resets animation timeline.', + args: [ + { + name: 'element', + description: 'SVG element', + type: 'Element' + }, + ] + }, + generateElementId: { + meta: 'function', + description: 'Generates new unique element id.', + args: [], + return: { + type: 'string', + description: 'Newly generated element id.' + } + }, + formatValue: { + meta: 'function', + description: 'Formats numeric value according to specified decimals and units', + args: [ + { + name: 'value', + description: 'Numeric value to be formatted', + type: 'any' + }, + { + name: 'dec', + description: 'Number of decimal digits', + type: 'number', + optional: true + }, + { + name: 'units', + description: 'Units to append to the formatted value', + type: 'string', + optional: true + }, + { + name: 'showZeroDecimals', + description: 'Whether to keep zero decimal digits', + type: 'boolean', + optional: true + } + ], + return: { + type: 'string', + description: 'Formatted value' + } + }, + text: { + meta: 'function', + description: 'Set text to element(s). Only applicable for elements of type ' + + 'SVG.Text or ' + + 'SVG.Tspan.', + args: [ + { + name: 'element', + description: 'SVG element or an array of SVG elements', + type: 'Element | Array<Element>' + }, + { + name: 'text', + description: 'Text to be set', + type: 'string' + } + ] + }, + font: { + meta: 'function', + description: 'Set element(s) text font and color. Only applicable for elements of type ' + + 'SVG.Text or ' + + 'SVG.Tspan.', + args: [ + { + name: 'element', + description: 'SVG element or an array of SVG elements', + type: 'Element | Array<Element>' + }, + { + name: 'font', + description: 'Font settings object used to apply text element font', + type: 'Font' + }, + { + name: 'color', + description: 'Color string used to apply text color of the element', + type: 'string' + } + ] + }, + disable: { + meta: 'function', + description: 'Disables element(s). Disabled element doesn\'t accept any user interaction. ' + + 'For ex. if disabled element has click action, no click action will be performed on user click.', + args: [ + { + name: 'element', + description: 'SVG element or an array of SVG elements', + type: 'Element | Array<Element>' + } + ] + }, + enable: { + meta: 'function', + description: 'Enables disabled element(s). Enabled element accepts user interaction. ' + + 'For ex. if element has click action, click action will be performed on user click.', + args: [ + { + name: 'element', + description: 'SVG element or an array of SVG elements', + type: 'Element | Array<Element>' + } + ] + }, + callAction: { + meta: 'function', + description: 'Invokes action specified by behavior of type "Action" found by behaviorId.', + args: [ + { + name: 'event', + description: 'DOM event', + type: 'Event' + }, + { + name: 'behaviorId', + description: 'Id of the "Action" behavior', + type: 'string' + }, + { + name: 'value', + description: 'Optional value passed to behavior', + type: 'any', + optional: true + }, + { + name: 'observer', + description: 'Optional observer callback', + type: 'Partial<Observer<void>>', + optional: true + } + ] + }, + setValue: { + meta: 'function', + description: 'Updates value by valueId. See ctx.values for reference. ' + + 'Value update triggers all render functions.', + args: [ + { + name: 'valueId', + description: 'Id of the value to be updated', + type: 'string' + }, + { + name: 'value', + description: 'New value to be set', + type: 'any' + } + ] + } + } + }, + properties, + values, + tags: tagsCompletions, + svg: { + meta: 'argument', + type: 'SVG.Svg', + description: 'A root svg node. Instance of SVG.Svg.' + } + } + }; +}; + +const scadaSymbolPropertyCompletion = (property: ScadaSymbolProperty, customTranslate: CustomTranslatePipe): TbEditorCompletion => { + let description = customTranslate.transform(property.name, property.name); + if (property.subLabel) { + description += ` ${customTranslate.transform(property.subLabel, property.subLabel)}`; + } + return { + meta: 'property', + description, + type: scadaSymbolPropertyCompletionType(property.type) + }; +}; + +const scadaSymbolValueCompletion = (value: ScadaSymbolBehavior, customTranslate: CustomTranslatePipe): TbEditorCompletion => { + const description = customTranslate.transform(value.name, value.name); + return { + meta: 'property', + description, + type: scadaSymbolValueCompletionType(value.valueType) + }; +}; + +const scadaSymbolPropertyCompletionType = (type: ScadaSymbolPropertyType): string => { + switch (type) { + case ScadaSymbolPropertyType.text: + return 'string'; + case ScadaSymbolPropertyType.number: + return 'number'; + case ScadaSymbolPropertyType.switch: + return 'boolean'; + case ScadaSymbolPropertyType.color: + return 'color string'; + case ScadaSymbolPropertyType.color_settings: + return 'ColorProcessor'; + case ScadaSymbolPropertyType.font: + return 'Font'; + case ScadaSymbolPropertyType.units: + return 'units string'; + } +}; + +const scadaSymbolValueCompletionType = (type: ValueType): string => { + switch (type) { + case ValueType.STRING: + return 'string'; + case ValueType.INTEGER: + case ValueType.DOUBLE: + return 'number'; + case ValueType.BOOLEAN: + return 'boolean'; + case ValueType.JSON: + return 'object'; + } +}; diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss new file mode 100644 index 0000000000..43613b6d40 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss @@ -0,0 +1,65 @@ +/** + * 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-scada-symbol-tooltip-panel { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + &.column { + flex-direction: column; + } + &.flex-start { + align-items: start; + } + + .tb-confirm-text { + font-size: 14px; + line-height: 16px; + text-align: center; + font-weight: 400; + } + + .mdc-button { + line-height: 20px; + font-size: 12px; + &.mat-mdc-button-base { + height: 20px; + .mat-mdc-button-touch-target { + height: 20px; + } + } + } + + .mat-mdc-form-field.tb-inline-field { + font-size: 12px; + line-height: 12px; + .mat-mdc-text-field-wrapper { + padding: 0 8px; + .mat-mdc-form-field-flex { + .mat-mdc-form-field-infix { + padding-top: 4px; + padding-bottom: 4px; + min-height: 20px; + .mat-mdc-input-element { + font-size: 12px; + line-height: 12px; + height: 12px; + } + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts new file mode 100644 index 0000000000..ef811e9746 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts @@ -0,0 +1,519 @@ +/// +/// 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 { + AfterViewInit, + Component, + ComponentRef, + Directive, + ElementRef, + EventEmitter, + Input, + NgModule, OnDestroy, + OnInit, + Output, + Type, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { ScadaSymbolElement } from '@home/pages/scada-symbol/scada-symbol-editor.models'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { ENTER } from '@angular/cdk/keycodes'; +import Timeout = NodeJS.Timeout; +import { MatButton } from '@angular/material/button'; +import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; +import { TranslateService } from '@ngx-translate/core'; + +@Directive() +abstract class ScadaSymbolPanelComponent implements AfterViewInit { + + @Input() + symbolElement: ScadaSymbolElement; + + @Output() + viewInited = new EventEmitter(); + + protected constructor(public element: ElementRef) { + } + + ngAfterViewInit() { + this.viewInited.emit(); + } +} + +@Component({ + template: `
+ {{ symbolElement?.element?.type }}{{ symbolElement?.invisible ? ' (' + ('scada.hidden' | translate) + ')' : '' }} + +
`, + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None +}) +class ScadaSymbolAddTagPanelComponent extends ScadaSymbolPanelComponent { + + @Output() + addTag = new EventEmitter(); + + constructor(public element: ElementRef) { + super(element); + } + + public onAddTag() { + this.addTag.emit(); + } + +} + +@Component({ + template: `
+ {{ (isAdd ? 'scada.tag.enter-tag' : 'scada.tag.update-tag' ) | translate }}: + + + + + +
`, + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None +}) +class ScadaSymbolTagInputPanelComponent extends ScadaSymbolPanelComponent implements AfterViewInit, OnDestroy { + + @ViewChild('tagField') + tagField: ElementRef; + + @Input() + isAdd: boolean; + + @Input() + tag: string; + + @Output() + apply = new EventEmitter(); + + @Output() + cancel = new EventEmitter(); + + private closed = false; + + private blurTimeout: Timeout; + + constructor(public element: ElementRef) { + super(element); + } + + ngAfterViewInit() { + super.ngAfterViewInit(); + setTimeout(() => { + this.tagField.nativeElement.focus(); + }); + } + + ngOnDestroy() { + if (this.blurTimeout) { + clearTimeout(this.blurTimeout); + this.blurTimeout = null; + } + } + + public tagEnter($event: KeyboardEvent) { + if ($event.keyCode === ENTER) { + $event.preventDefault(); + if (this.tag) { + this.closed = true; + this.apply.emit(this.tag); + } + } + } + + public onApply() { + this.closed = true; + if (this.tag) { + this.apply.emit(this.tag); + } else { + this.cancel.emit(); + } + } + + public onCancel() { + this.closed = true; + this.cancel.emit(); + } + + public onBlur() { + this.blurTimeout = setTimeout(() => { + if (!this.closed) { + this.closed = true; + this.cancel.emit(); + } + }, 300); + } + +} + +@Component({ + template: `
+ {{ symbolElement?.element?.type }}{{ symbolElement?.invisible ? ' (' + ('scada.hidden' | translate) + ')' : '' }}: + {{ symbolElement?.tag }} + + + +
`, + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None +}) +class ScadaSymbolTagPanelComponent extends ScadaSymbolPanelComponent implements OnInit, AfterViewInit { + + @ViewChild('tagSettingsButton', {read: ElementRef}) + tagSettingsButton: ElementRef; + + @ViewChild('removeTagButton', {read: ElementRef}) + removeTagButton: ElementRef; + + @Output() + updateTag = new EventEmitter(); + + @Output() + removeTag = new EventEmitter(); + + displayTagSettings = true; + + constructor(public element: ElementRef, + private container: ViewContainerRef) { + super(element); + } + + ngOnInit() { + if (this.symbolElement?.readonly) { + this.displayTagSettings = (this.symbolElement?.hasStateRenderFunction() || this.symbolElement?.hasClickAction()); + } + } + + ngAfterViewInit() { + super.ngAfterViewInit(); + setTimeout(() => { + + if (this.displayTagSettings) { + const tagSettingsButton = $(this.tagSettingsButton.nativeElement); + tagSettingsButton.tooltipster( + { + parent: this.symbolElement.tooltipContainer, + zIndex: 200, + arrow: true, + theme: ['scada-symbol', 'tb-active'], + interactive: true, + trigger: 'click', + trackOrigin: true, + trackerInterval: 100, + side: 'top', + content: '' + } + ); + + const scadaSymbolTagSettingsTooltip = tagSettingsButton.tooltipster('instance'); + setTooltipComponent(this.symbolElement, this.container, ScadaSymbolTagSettingsComponent, scadaSymbolTagSettingsTooltip); + } + + if (!this.symbolElement.readonly) { + const removeTagButton = $(this.removeTagButton.nativeElement); + removeTagButton.tooltipster( + { + parent: this.symbolElement.tooltipContainer, + zIndex: 200, + arrow: true, + theme: ['scada-symbol', 'tb-active'], + interactive: true, + trigger: 'click', + trackOrigin: true, + trackerInterval: 100, + side: 'top', + content: '' + } + ); + + const scadaSymbolRemoveTagTooltip = removeTagButton.tooltipster('instance'); + const scadaSymbolRemoveTagCompRef = + setTooltipComponent(this.symbolElement, this.container, ScadaSymbolRemoveTagConfirmComponent, scadaSymbolRemoveTagTooltip); + scadaSymbolRemoveTagCompRef.instance.removeTag.subscribe(() => { + scadaSymbolRemoveTagTooltip.destroy(); + this.removeTag.emit(); + }); + scadaSymbolRemoveTagCompRef.instance.cancel.subscribe(() => { + scadaSymbolRemoveTagTooltip.close(); + }); + scadaSymbolRemoveTagTooltip.on('ready', () => { + scadaSymbolRemoveTagCompRef.instance.yesButton.focus(); + }); + } + }); + } + + public onUpdateTag() { + this.updateTag.emit(); + } +} + +@Component({ + template: `
+
+
+ + +
+
`, + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None +}) +class ScadaSymbolRemoveTagConfirmComponent extends ScadaSymbolPanelComponent implements OnInit, AfterViewInit { + + @ViewChild('yesButton') + yesButton: MatButton; + + deleteText: string; + + @Output() + cancel = new EventEmitter(); + + @Output() + removeTag = new EventEmitter(); + + constructor(public element: ElementRef, + private translate: TranslateService) { + super(element); + } + + ngOnInit() { + this.deleteText = this.translate.instant('scada.tag.delete-tag-text', + {tag: this.symbolElement?.tag, elementType: this.symbolElement?.element?.type}); + } + + public onCancel() { + this.cancel.emit(); + } + + public onRemoveTag() { + this.removeTag.emit(); + } +} + +@Component({ + template: `
+
scada.state-render-function
+ + +
scada.tag.on-click-action
+ + +
`, + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None +}) +class ScadaSymbolTagSettingsComponent extends ScadaSymbolPanelComponent implements OnInit, AfterViewInit { + + hasStateRenderFunction = false; + + hasClickAction = false; + + constructor(public element: ElementRef) { + super(element); + } + + ngOnInit() { + this.updateFunctionsState(); + } + + private updateFunctionsState() { + this.hasStateRenderFunction = this.symbolElement.hasStateRenderFunction(); + this.hasClickAction = this.symbolElement.hasClickAction(); + } + + editStateRenderFunction() { + this.symbolElement.editStateRenderFunction(); + } + + editClickAction() { + this.symbolElement.editClickAction(); + } +} + +@NgModule({ + declarations: + [ + ScadaSymbolAddTagPanelComponent, + ScadaSymbolTagInputPanelComponent, + ScadaSymbolTagPanelComponent, + ScadaSymbolRemoveTagConfirmComponent, + ScadaSymbolTagSettingsComponent + ], + imports: [ + CommonModule, + SharedModule + ] +}) +export class ScadaSymbolTooltipComponentsModule { } + +export const setupAddTagPanelTooltip = (symbolElement: ScadaSymbolElement, container: ViewContainerRef) => { + symbolElement.stopEdit(); + symbolElement.tooltip.off('close'); + const componentRef = setTooltipComponent(symbolElement, container, ScadaSymbolAddTagPanelComponent); + componentRef.instance.addTag.subscribe(() => { + componentRef.destroy(); + setupTagInputPanelTooltip(symbolElement, container, true); + }); +}; + +export const setupTagPanelTooltip = (symbolElement: ScadaSymbolElement, container: ViewContainerRef) => { + symbolElement.stopEdit(); + symbolElement.unhighlight(); + const componentRef = setTooltipComponent(symbolElement, container, ScadaSymbolTagPanelComponent); + componentRef.instance.updateTag.subscribe(() => { + componentRef.destroy(); + setupTagInputPanelTooltip(symbolElement, container, false); + }); + componentRef.instance.removeTag.subscribe(() => { + componentRef.destroy(); + symbolElement.clearTag(); + }); + symbolElement.tooltip.open(); +}; + +const setupTagInputPanelTooltip = (symbolElement: ScadaSymbolElement, container: ViewContainerRef, isAdd: boolean) => { + + symbolElement.startEdit(() => { + if (isAdd) { + symbolElement.unhighlight(); + symbolElement.tooltip.close(); + } else { + componentRef.destroy(); + setupTagPanelTooltip(symbolElement, container); + } + }); + + const componentRef = setTooltipComponent(symbolElement, container, ScadaSymbolTagInputPanelComponent); + + componentRef.instance.isAdd = isAdd; + if (!isAdd) { + componentRef.instance.tag = symbolElement.tag; + } + componentRef.instance.apply.subscribe((newTag) => { + componentRef.destroy(); + if (isAdd) { + symbolElement.tooltip.off('close'); + } + symbolElement.setTag(newTag); + }); + componentRef.instance.cancel.subscribe(() => { + symbolElement.stopEdit(true); + }); + if (isAdd) { + symbolElement.tooltip.option('delay', [0, 10000000]); + symbolElement.tooltip.on('close', () => { + componentRef.destroy(); + symbolElement.tooltip.option('delay', [0, 300]); + setupAddTagPanelTooltip(symbolElement, container); + }); + } +}; + +const setTooltipComponent = (symbolElement: ScadaSymbolElement, + container: ViewContainerRef, + componentType: Type, + tooltip?: ITooltipsterInstance): ComponentRef => { + if (!tooltip) { + tooltip = symbolElement.tooltip; + } + const componentRef = container.createComponent(componentType); + componentRef.instance.symbolElement = symbolElement; + componentRef.instance.viewInited.subscribe(() => { + if (tooltip.status().open) { + tooltip.reposition(); + } + }); + tooltip.on('destroyed', () => { + componentRef.destroy(); + }); + const parentElement = componentRef.instance.element.nativeElement; + const content = parentElement.firstChild; + parentElement.removeChild(content); + parentElement.style.display = 'none'; + tooltip.content(content); + return componentRef; +}; diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html new file mode 100644 index 0000000000..6662509fa6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html @@ -0,0 +1,151 @@ + +
+
+
+ + + +
+ +
+
+
+ + + + +
+
+
+
+
scada.symbol.symbol
+ + +
+ + +
+
+
+
+ + + + +
+
+
+ + + + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.scss new file mode 100644 index 0000000000..cbfe6e6846 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.scss @@ -0,0 +1,74 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import '../../../../../scss/constants'; + +.tb-scada-symbol-editor { + width: 100%; + height: 100%; + .tb-scada-symbol-editor-details-drawer { + width: 50%; + .tb-scada-symbol-editor-preview-content { + display: flex; + flex-direction: column; + gap: 8px; + .tb-scada-symbol-editor-preview-header { + padding: 24px 24px 0; + display: flex; + gap: 12px; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + .tb-scada-symbol-editor-preview-settings { + & > .mat-content { + padding-top: 8px; + @media #{$mat-xs} { + padding-left: 8px; + padding-right: 8px; + } + } + flex: 1; + overflow: auto; + & > div { + padding: 16px; + } + .mat-content { + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-xs} { + gap: 8px; + } + } + } + } + } + .tb-scada-symbol-editor-content { + flex: 1; + min-width: 0; + min-height: 0; + max-width: 50%; + background: #fff; + &.preview { + #gridster-parent { + #gridster-background { + background-color: #eee; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts new file mode 100644 index 0000000000..a7c6eee118 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts @@ -0,0 +1,477 @@ +/// +/// 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 { + AfterViewInit, + ChangeDetectorRef, + Component, + EventEmitter, + HostBinding, + OnDestroy, + OnInit, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ActivatedRoute } from '@angular/router'; +import { map, switchMap, takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { ScadaSymbolData, ScadaSymbolEditObjectCallbacks } from '@home/pages/scada-symbol/scada-symbol-editor.models'; +import { + parseScadaSymbolMetadataFromContent, + ScadaSymbolMetadata, + ScadaSymbolObjectSettings, + updateScadaSymbolMetadataInContent +} from '@home/components/widget/lib/scada/scada-symbol.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { createFileFromContent, deepClone } from '@core/utils'; +import { + ScadaSymbolEditorComponent, + ScadaSymbolEditorData +} from '@home/pages/scada-symbol/scada-symbol-editor.component'; +import { ImageService } from '@core/http/image.service'; +import { imageResourceType, IMAGES_URL_PREFIX, TB_IMAGE_PREFIX } from '@shared/models/resource.models'; +import { HasDirtyFlag } from '@core/guards/confirm-on-exit.guard'; +import { IAliasController, IStateController, StateParams } from '@core/api/widget-api.models'; +import { EntityAliases } from '@shared/models/alias.models'; +import { Filters } from '@shared/models/query/query.models'; +import { AliasController } from '@core/api/alias-controller'; +import { EntityService } from '@core/http/entity.service'; +import { UtilsService } from '@core/services/utils.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Widget, WidgetConfig, widgetType, WidgetTypeDetails } from '@shared/models/widget.models'; +import { + scadaSymbolWidgetDefaultSettings, + ScadaSymbolWidgetSettings +} from '@home/components/widget/lib/scada/scada-symbol-widget.models'; +import { WidgetActionCallbacks } from '@home/components/widget/action/manage-widget-actions.component.models'; +import { + ScadaSymbolMetadataComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { + UploadImageDialogComponent, + UploadImageDialogData, UploadImageDialogResult +} from '@shared/components/image/upload-image-dialog.component'; +import { MatDialog } from '@angular/material/dialog'; +import { BackgroundType, colorBackground } from '@shared/models/widget-settings.models'; +import { GridType } from 'angular-gridster2'; +import { + SaveWidgetTypeAsDialogComponent, SaveWidgetTypeAsDialogData, + SaveWidgetTypeAsDialogResult +} from '@home/pages/widget/save-widget-type-as-dialog.component'; +import { WidgetService } from '@core/http/widget.service'; +import { de } from 'date-fns/locale'; + +@Component({ + selector: 'tb-scada-symbol', + templateUrl: './scada-symbol.component.html', + styleUrls: ['./scada-symbol.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolComponent extends PageComponent + implements OnInit, OnDestroy, AfterViewInit, HasDirtyFlag, ScadaSymbolEditObjectCallbacks { + + widgetType = widgetType; + + GridType = GridType; + + @HostBinding('style.width') width = '100%'; + @HostBinding('style.height') height = '100%'; + + @ViewChild('symbolEditor', {static: false}) + symbolEditor: ScadaSymbolEditorComponent; + + @ViewChild('symbolMetadata') + symbolMetadata: ScadaSymbolMetadataComponent; + + symbolData: ScadaSymbolData; + symbolEditorData: ScadaSymbolEditorData; + + previewMode = false; + previewMetadata: ScadaSymbolMetadata; + + scadaSymbolFormGroup: UntypedFormGroup; + + scadaPreviewFormGroup: UntypedFormGroup; + + private destroy$ = new Subject(); + + private origSymbolData: ScadaSymbolData; + + updateBreadcrumbs = new EventEmitter(); + + aliasController: IAliasController; + + widgetActionCallbacks: WidgetActionCallbacks = { + fetchDashboardStates: () => [], + fetchCellClickColumns: () => [] + }; + + previewWidget: Widget; + + previewWidgets: Array = []; + + tags: string[]; + + editObjectCallbacks: ScadaSymbolEditObjectCallbacks = this; + + symbolEditorDirty = false; + + private previewScadaSymbolObjectSettings: ScadaSymbolObjectSettings; + + private forcePristine = false; + + private authUser = getCurrentAuthUser(this.store); + + readonly: boolean; + + showHiddenElements = false; + + get isDirty(): boolean { + return (this.scadaSymbolFormGroup.dirty || this.symbolEditorDirty) && !this.forcePristine; + } + + set isDirty(value: boolean) { + this.forcePristine = !value; + } + + constructor(protected store: Store, + private route: ActivatedRoute, + private fb: UntypedFormBuilder, + private cd: ChangeDetectorRef, + private entityService: EntityService, + private utils: UtilsService, + private translate: TranslateService, + private imageService: ImageService, + private widgetService: WidgetService, + private dialog: MatDialog) { + super(store); + } + + ngOnInit(): void { + this.scadaSymbolFormGroup = this.fb.group({ + metadata: [null] + }); + this.scadaPreviewFormGroup = this.fb.group({ + scadaSymbolObjectSettings: [null] + }); + + const entitiAliases: EntityAliases = {}; + + // @ts-ignore + const stateController: IStateController = { + getStateParams: (): StateParams => ({}) + }; + + const filters: Filters = {}; + + this.aliasController = new AliasController(this.utils, + this.entityService, + this.translate, + () => stateController, entitiAliases, filters); + + this.route.data.pipe( + takeUntil(this.destroy$) + ).subscribe( + () => { + this.reset(); + this.init(this.route.snapshot.data.symbolData); + } + ); + } + + ngAfterViewInit() { + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.destroy$.next(); + this.destroy$.complete(); + } + + onApplyScadaSymbolConfig() { + if (this.scadaSymbolFormGroup.valid) { + const metadata: ScadaSymbolMetadata = this.scadaSymbolFormGroup.get('metadata').value; + const scadaSymbolContent = this.prepareScadaSymbolContent(metadata); + const file = createFileFromContent(scadaSymbolContent, this.symbolData.imageResource.fileName, + this.symbolData.imageResource.descriptor.mediaType); + const type = imageResourceType(this.symbolData.imageResource); + let imageInfoObservable = + this.imageService.updateImage(type, this.symbolData.imageResource.resourceKey, file); + if (metadata.title !== this.symbolData.imageResource.title) { + imageInfoObservable = imageInfoObservable.pipe( + switchMap(imageInfo => { + imageInfo.title = metadata.title; + return this.imageService.updateImageInfo(imageInfo); + }) + ); + } + imageInfoObservable.pipe( + switchMap(imageInfo => this.imageService.getImageString( + `${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(imageInfo.resourceKey)}`).pipe( + map(content => ({ + imageResource: imageInfo, + scadaSymbolContent: content + })) + )) + ).subscribe(data => { + this.init(data); + this.updateBreadcrumbs.emit(); + }); + } + } + + onRevertScadaSymbolConfig() { + this.init(this.origSymbolData); + } + + enterPreviewMode() { + this.previewMetadata = this.scadaSymbolFormGroup.get('metadata').value; + this.symbolData.scadaSymbolContent = this.prepareScadaSymbolContent(this.previewMetadata); + this.previewScadaSymbolObjectSettings = { + behavior: {}, + properties: {} + }; + this.scadaPreviewFormGroup.patchValue({ + scadaSymbolObjectSettings: this.previewScadaSymbolObjectSettings + }, {emitEvent: false}); + this.scadaPreviewFormGroup.markAsPristine(); + const settings: ScadaSymbolWidgetSettings = {...scadaSymbolWidgetDefaultSettings, + ...{ + simulated: true, + scadaSymbolUrl: null, + scadaSymbolContent: this.symbolData.scadaSymbolContent, + scadaSymbolObjectSettings: this.previewScadaSymbolObjectSettings, + padding: '0', + background: colorBackground('rgba(0,0,0,0)') + } + }; + this.previewWidget = { + typeFullFqn: 'system.scada_symbol', + type: widgetType.rpc, + sizeX: this.previewMetadata.widgetSizeX || 3, + sizeY: this.previewMetadata.widgetSizeY || 3, + row: 0, + col: 0, + config: { + settings, + showTitle: false, + dropShadow: false, + padding: '0', + margin: '0', + backgroundColor: 'rgba(0,0,0,0)' + } + }; + this.previewWidgets = [this.previewWidget]; + this.previewMode = true; + } + + exitPreviewMode() { + this.symbolEditorData = { + scadaSymbolContent: this.symbolData.scadaSymbolContent + }; + this.previewMode = false; + } + + onRevertPreviewSettings() { + this.scadaPreviewFormGroup.patchValue({ + scadaSymbolObjectSettings: this.previewScadaSymbolObjectSettings + }, {emitEvent: false}); + this.scadaPreviewFormGroup.markAsPristine(); + } + + onApplyPreviewSettings() { + this.scadaPreviewFormGroup.markAsPristine(); + this.previewScadaSymbolObjectSettings = this.scadaPreviewFormGroup.get('scadaSymbolObjectSettings').value; + this.updatePreviewWidgetSettings(); + } + + tagsUpdated(tags: string[]) { + this.tags = tags; + } + + tagHasStateRenderFunction(tag: string): boolean { + const metadata: ScadaSymbolMetadata = this.scadaSymbolFormGroup.get('metadata').value; + if (metadata.tags) { + const found = metadata.tags.find(t => t.tag === tag); + return !!found?.stateRenderFunction; + } + return false; + } + + tagHasClickAction(tag: string): boolean { + const metadata: ScadaSymbolMetadata = this.scadaSymbolFormGroup.get('metadata').value; + if (metadata.tags) { + const found = metadata.tags.find(t => t.tag === tag); + return !!found?.actions?.click?.actionFunction; + } + return false; + } + + editTagStateRenderFunction(tag: string) { + this.symbolMetadata.editTagStateRenderFunction(tag); + } + + editTagClickAction(tag: string) { + this.symbolMetadata.editTagClickAction(tag); + } + + onSymbolEditObjectDirty(dirty: boolean) { + this.symbolEditorDirty = dirty; + } + + updateScadaSymbol() { + this.dialog.open(UploadImageDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + imageSubType: this.symbolData.imageResource.resourceSubType, + image: this.symbolData.imageResource + } + }).afterClosed().subscribe((result) => { + if (result?.scadaSymbolContent) { + this.symbolData.scadaSymbolContent = result.scadaSymbolContent; + this.symbolEditorData = { + scadaSymbolContent: this.symbolData.scadaSymbolContent + }; + this.symbolEditorDirty = true; + } + }); + } + + downloadScadaSymbol() { + let metadata: ScadaSymbolMetadata; + if (this.scadaSymbolFormGroup.valid) { + metadata = this.scadaSymbolFormGroup.get('metadata').value; + } else { + metadata = parseScadaSymbolMetadataFromContent(this.origSymbolData.scadaSymbolContent); + } + const linkElement = document.createElement('a'); + const scadaSymbolContent = this.prepareScadaSymbolContent(metadata); + const blob = new Blob([scadaSymbolContent], { type: this.symbolData.imageResource.descriptor.mediaType }); + const url = URL.createObjectURL(blob); + linkElement.setAttribute('href', url); + linkElement.setAttribute('download', this.symbolData.imageResource.fileName); + const clickEvent = new MouseEvent('click', + { + view: window, + bubbles: true, + cancelable: false + } + ); + linkElement.dispatchEvent(clickEvent); + } + + createWidget() { + const metadata: ScadaSymbolMetadata = this.scadaSymbolFormGroup.get('metadata').value; + this.dialog.open(SaveWidgetTypeAsDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + title: metadata.title, + dialogTitle: 'scada.create-widget-from-symbol', + saveAsActionTitle: 'action.create' + } + }).afterClosed().subscribe( + (saveWidgetAsData) => { + if (saveWidgetAsData) { + this.widgetService.getWidgetType('system.scada_symbol').subscribe( + (widgetTemplate) => { + const symbolUrl = TB_IMAGE_PREFIX + this.symbolData.imageResource.link; + const widget: WidgetTypeDetails = { + image: symbolUrl, + description: metadata.description, + tags: metadata.searchTags, + ...widgetTemplate + }; + widget.fqn = undefined; + widget.id = undefined; + widget.name = saveWidgetAsData.widgetName; + const descriptor = widget.descriptor; + descriptor.sizeX = metadata.widgetSizeX; + descriptor.sizeY = metadata.widgetSizeY; + descriptor.controllerScript = descriptor.controllerScript + .replace(/previewWidth: '\d*px'/gm, `previewWidth: '${metadata.widgetSizeX * 100}px'`); + descriptor.controllerScript = descriptor.controllerScript + .replace(/previewHeight: '\d*px'/gm, `previewHeight: '${metadata.widgetSizeY * 100 + 20}px'`); + const config: WidgetConfig = JSON.parse(descriptor.defaultConfig); + config.title = saveWidgetAsData.widgetName; + config.settings = config.settings || {}; + config.settings.scadaSymbolUrl = symbolUrl; + descriptor.defaultConfig = JSON.stringify(config); + this.widgetService.saveWidgetType(widget).subscribe((saved) => { + if (saveWidgetAsData.widgetBundleId) { + this.widgetService.addWidgetFqnToWidgetBundle(saveWidgetAsData.widgetBundleId, saved.fqn).subscribe(); + } + }); + } + ); + } + } + ); + } + + private updatePreviewWidgetSettings() { + this.previewWidget = deepClone(this.previewWidget); + this.previewWidget.config.settings.scadaSymbolObjectSettings = this.previewScadaSymbolObjectSettings; + this.previewWidgets = [this.previewWidget]; + } + + private prepareScadaSymbolContent(metadata: ScadaSymbolMetadata): string { + const svgContent = this.symbolEditor.getContent(); + return updateScadaSymbolMetadataInContent(svgContent, metadata); + } + + private reset(): void { + if (this.symbolMetadata) { + this.symbolMetadata.selectedOption = 'general'; + } + this.previewMode = false; + } + + private init(data: ScadaSymbolData) { + if (this.authUser.authority === Authority.TENANT_ADMIN) { + this.readonly = data.imageResource.tenantId.id === NULL_UUID; + } else { + this.readonly = this.authUser.authority !== Authority.SYS_ADMIN; + } + this.origSymbolData = data; + this.symbolData = deepClone(data); + this.symbolEditorData = { + scadaSymbolContent: this.symbolData.scadaSymbolContent + }; + const metadata = parseScadaSymbolMetadataFromContent(this.symbolData.scadaSymbolContent); + this.scadaSymbolFormGroup.patchValue({ + metadata + }, {emitEvent: false}); + if (this.readonly) { + this.scadaSymbolFormGroup.disable({emitEvent: false}); + } else { + this.scadaSymbolFormGroup.enable({emitEvent: false}); + } + this.scadaSymbolFormGroup.markAsPristine(); + this.symbolEditorDirty = false; + this.cd.markForCheck(); + } +} + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts new file mode 100644 index 0000000000..0955f1b5f9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts @@ -0,0 +1,44 @@ +/// +/// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@app/shared/shared.module'; +import { HomeComponentsModule } from '@modules/home/components/home-components.module'; +import { ScadaSymbolComponent } from '@home/pages/scada-symbol/scada-symbol.component'; +import { ScadaSymbolEditorComponent } from '@home/pages/scada-symbol/scada-symbol-editor.component'; +import { ScadaSymbolTooltipComponentsModule } from '@home/pages/scada-symbol/scada-symbol-tooltip.components'; +import { WidgetSettingsCommonModule } from '@home/components/widget/lib/settings/common/widget-settings-common.module'; +import { + ScadaSymbolMetadataComponentsModule +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-metadata-components.module'; + +@NgModule({ + declarations: + [ + ScadaSymbolEditorComponent, + ScadaSymbolComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + ScadaSymbolMetadataComponentsModule, + ScadaSymbolTooltipComponentsModule, + WidgetSettingsCommonModule + ] +}) +export class ScadaSymbolModule { } diff --git a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-routing.module.ts b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-routing.module.ts index c414238733..a9fb7c961c 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-routing.module.ts @@ -24,14 +24,14 @@ import { EntityDetailsPageComponent } from '@home/components/entity/entity-detai import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; import { BreadCrumbConfig } from '@shared/components/breadcrumb'; +import { MenuId } from '@core/services/menu.models'; const routes: Routes = [ { path: 'tenantProfiles', data: { breadcrumb: { - label: 'tenant-profile.tenant-profiles', - icon: 'mdi:alpha-t-box' + menuId: MenuId.tenant_profiles } }, children: [ diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts b/ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts index b8ea897b66..141a799dce 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts @@ -25,14 +25,14 @@ import { EntityDetailsPageComponent } from '@home/components/entity/entity-detai import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; import { BreadCrumbConfig } from '@shared/components/breadcrumb'; +import { MenuId } from '@core/services/menu.models'; const routes: Routes = [ { path: 'tenants', data: { breadcrumb: { - label: 'tenant.tenants', - icon: 'supervisor_account' + menuId: MenuId.tenants } }, children: [ diff --git a/ui-ngx/src/app/modules/home/pages/vc/vc-routing.module.ts b/ui-ngx/src/app/modules/home/pages/vc/vc-routing.module.ts index 2708a42c11..1d4112021a 100644 --- a/ui-ngx/src/app/modules/home/pages/vc/vc-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/vc/vc-routing.module.ts @@ -19,6 +19,7 @@ import { RouterModule, Routes } from '@angular/router'; import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; import { Authority } from '@shared/models/authority.enum'; import { VersionControlComponent } from '@home/components/vc/version-control.component'; +import { MenuId } from '@core/services/menu.models'; export const vcRoutes: Routes = [ { @@ -29,8 +30,7 @@ export const vcRoutes: Routes = [ auth: [Authority.TENANT_ADMIN], title: 'version-control.version-control', breadcrumb: { - label: 'version-control.version-control', - icon: 'history' + menuId: MenuId.version_control } } } diff --git a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html index cb2b03a845..8d0f6bd6f5 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html @@ -15,9 +15,9 @@ limitations under the License. --> -
+ -

widget.save-widget-as

+

{{ dialogTitle }}

diff --git a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts index e5a0fa3712..3685b553ef 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts @@ -14,8 +14,8 @@ /// limitations under the License. /// -import { Component, OnInit } from '@angular/core'; -import { MatDialogRef } from '@angular/material/dialog'; +import { Component, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; @@ -29,6 +29,12 @@ export interface SaveWidgetTypeAsDialogResult { widgetBundleId?: string; } +export interface SaveWidgetTypeAsDialogData { + dialogTitle?: string; + title?: string; + saveAsActionTitle?: string; +} + @Component({ selector: 'tb-save-widget-type-as-dialog', templateUrl: './save-widget-type-as-dialog.component.html', @@ -39,9 +45,12 @@ export class SaveWidgetTypeAsDialogComponent extends saveWidgetTypeAsFormGroup: FormGroup; bundlesScope: string; + dialogTitle = 'widget.save-widget-as'; + saveAsActionTitle = 'action.saveAs'; constructor(protected store: Store, protected router: Router, + @Inject(MAT_DIALOG_DATA) private data: SaveWidgetTypeAsDialogData, public dialogRef: MatDialogRef, public fb: FormBuilder) { super(store, router, dialogRef); @@ -52,11 +61,18 @@ export class SaveWidgetTypeAsDialogComponent extends } else { this.bundlesScope = 'system'; } + + if (this.data?.dialogTitle) { + this.dialogTitle = this.data.dialogTitle; + } + if (this.data?.saveAsActionTitle) { + this.saveAsActionTitle = this.data.saveAsActionTitle; + } } ngOnInit(): void { this.saveWidgetTypeAsFormGroup = this.fb.group({ - title: [null, [Validators.required]], + title: [this.data?.title, [Validators.required]], widgetsBundle: [null] }); } diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html index 11329df694..19f306fad8 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html @@ -257,11 +257,18 @@ [(ngModel)]="widget.tags" (ngModelChange)="isDirty = true"> - - {{ 'widget.deprecated' | translate }} - +
+ + {{ 'widget.scada' | translate }} + + + {{ 'widget.deprecated' | translate }} + +
widget.settings-form-selector { + this.saveWidgetPending = true; const widgetsBundleId = this.route.snapshot.params.widgetsBundleId as string; if (widgetsBundleId && !id) { return this.widgetService.addWidgetFqnToWidgetBundle(widgetsBundleId, widgetTypeDetails.fqn).pipe( @@ -579,7 +583,13 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe ); } return of(widgetTypeDetails); - }) + }), + catchError((err) => { + if (id && err.status === HttpStatusCode.Conflict) { + return this.widgetService.getWidgetTypeById(id.id); + } + return throwError(() => err); + }), ).subscribe({ next: (widgetTypeDetails) => { this.saveWidgetPending = false; @@ -612,7 +622,7 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe config.title = this.widget.widgetName; this.widget.defaultConfig = JSON.stringify(config); this.isDirty = false; - this.widgetService.saveWidgetTypeDetails(this.widget, undefined, undefined).pipe( + this.widgetService.saveWidgetTypeDetails(this.widget, undefined, undefined, undefined).pipe( mergeMap((widget) => { if (saveWidgetAsData.widgetBundleId) { return this.widgetService.addWidgetFqnToWidgetBundle(saveWidgetAsData.widgetBundleId, widget.fqn).pipe( diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts index c808bae163..046737ac10 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts @@ -33,6 +33,7 @@ import { WidgetTypesTableConfigResolver } from '@home/pages/widget/widget-types- import { WidgetsBundleWidgetsComponent } from '@home/pages/widget/widgets-bundle-widgets.component'; import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; +import { MenuId } from '@core/services/menu.models'; export interface WidgetEditorData { widgetTypeDetails: WidgetTypeDetails; @@ -108,8 +109,7 @@ const widgetTypesRoutes: Routes = [ path: 'widget-types', data: { breadcrumb: { - label: 'widget.widgets', - icon: 'now_widgets' + menuId: MenuId.widget_types } }, children: [ @@ -157,8 +157,7 @@ const widgetsBundlesRoutes: Routes = [ path: 'widgets-bundles', data: { breadcrumb: { - label: 'widgets-bundle.widgets-bundles', - icon: 'now_widgets' + menuId: MenuId.widgets_bundles } }, children: [ @@ -235,8 +234,7 @@ export const widgetsLibraryRoutes: Routes = [ data: { auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], breadcrumb: { - label: 'widget.widget-library', - icon: 'now_widgets' + menuId: MenuId.widget_library } }, children: [ diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html index 20581ca3df..f5f6f5268f 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html @@ -67,9 +67,14 @@ label="{{ 'widget.tags' | translate }}" formControlName="tags"> - - {{ 'widget.deprecated' | translate }} - +
+ + {{ 'widget.scada' | translate }} + + + {{ 'widget.deprecated' | translate }} + +
diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.ts index 79dbab63cf..b442fb7482 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.ts @@ -53,6 +53,7 @@ export class WidgetTypeComponent extends EntityComponent { image: [entity ? entity.image : ''], description: [entity ? entity.description : '', Validators.maxLength(1024)], tags: [entity ? entity.tags : []], + scada: [entity ? entity.scada : false], deprecated: [entity ? entity.deprecated : false] } ); @@ -64,6 +65,7 @@ export class WidgetTypeComponent extends EntityComponent { image: entity.image, description: entity.description, tags: entity.tags, + scada: entity.scada, deprecated: entity.deprecated }); } diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html index 5e3e4fe6ab..f95d085688 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html @@ -63,6 +63,9 @@ {{descriptionInput.value?.length || 0}}/1024 + + {{ 'widgets-bundle.scada' | translate }} + widgets-bundle.order diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts index 845ece44f0..ca0912ea11 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts @@ -51,6 +51,7 @@ export class WidgetsBundleComponent extends EntityComponent { title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], image: [entity ? entity.image : ''], description: [entity ? entity.description : '', Validators.maxLength(1024)], + scada: [entity ? entity.scada : false], order: [entity ? entity.order : null] } ); @@ -61,6 +62,7 @@ export class WidgetsBundleComponent extends EntityComponent { title: entity.title, image: entity.image, description: entity.description, + scada: entity.scada, order: entity.order }); } diff --git a/ui-ngx/src/app/shared/components/breadcrumb.component.html b/ui-ngx/src/app/shared/components/breadcrumb.component.html index 6045f38ff2..a80d3b375d 100644 --- a/ui-ngx/src/app/shared/components/breadcrumb.component.html +++ b/ui-ngx/src/app/shared/components/breadcrumb.component.html @@ -17,9 +17,7 @@ -->

- {{ breadcrumb.ignoreTranslate - ? (breadcrumb.labelFunction ? breadcrumb.labelFunction() : utils.customTranslation(breadcrumb.label, breadcrumb.label)) - : (breadcrumb.label | translate) }} + {{ breadcrumb.labelFunction ? breadcrumb.labelFunction() : (breadcrumb.customTranslate ? (breadcrumb.label | customTranslate) : (breadcrumb.label | translate)) }}

@@ -40,7 +38,5 @@ {{ breadcrumb.icon }} - {{ breadcrumb.ignoreTranslate - ? (breadcrumb.labelFunction ? breadcrumb.labelFunction() : utils.customTranslation(breadcrumb.label, breadcrumb.label)) - : (breadcrumb.label | translate) }} + {{ breadcrumb.labelFunction ? breadcrumb.labelFunction() : (breadcrumb.customTranslate ? (breadcrumb.label | customTranslate) : (breadcrumb.label | translate)) }} diff --git a/ui-ngx/src/app/shared/components/breadcrumb.component.ts b/ui-ngx/src/app/shared/components/breadcrumb.component.ts index b5255bb0d1..c0899d7ef7 100644 --- a/ui-ngx/src/app/shared/components/breadcrumb.component.ts +++ b/ui-ngx/src/app/shared/components/breadcrumb.component.ts @@ -14,16 +14,18 @@ /// limitations under the License. /// -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { BehaviorSubject, Subject, Subscription } from 'rxjs'; import { BreadCrumb, BreadCrumbConfig } from './breadcrumb'; import { ActivatedRoute, ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router'; -import { distinctUntilChanged, filter, map, tap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, first, map, switchMap } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; import { guid } from '@core/utils'; import { BroadcastService } from '@core/services/broadcast.service'; import { ActiveComponentService } from '@core/services/active-component.service'; import { UtilsService } from '@core/services/utils.service'; +import { MenuSection, menuSectionMap } from '@core/services/menu.models'; +import { MenuService } from '@core/services/menu.service'; @Component({ selector: 'tb-breadcrumb', @@ -44,7 +46,9 @@ export class BreadcrumbComponent implements OnInit, OnDestroy { this.activeComponentValue = activeComponent; if (this.activeComponentValue && this.activeComponentValue.updateBreadcrumbs) { this.updateBreadcrumbsSubscription = this.activeComponentValue.updateBreadcrumbs.subscribe(() => { - this.breadcrumbs$.next(this.buildBreadCrumbs(this.activatedRoute.snapshot)); + this.menuService.availableMenuSections().pipe(first()).subscribe((sections) => { + this.breadcrumbs$.next(this.buildBreadCrumbs(this.activatedRoute.snapshot, sections)); + }); }); } } @@ -54,7 +58,8 @@ export class BreadcrumbComponent implements OnInit, OnDestroy { routerEventsSubscription = this.router.events.pipe( filter((event) => event instanceof NavigationEnd ), distinctUntilChanged(), - map( () => this.buildBreadCrumbs(this.activatedRoute.snapshot) ) + switchMap(() => this.menuService.availableMenuSections().pipe(first())), + map( (sections) => this.buildBreadCrumbs(this.activatedRoute.snapshot, sections) ) ).subscribe(breadcrumns => this.breadcrumbs$.next(breadcrumns) ); activeComponentSubscription = this.activeComponentService.onActiveComponentChanged().subscribe(comp => this.setActiveComponent(comp)); @@ -69,6 +74,7 @@ export class BreadcrumbComponent implements OnInit, OnDestroy { private activeComponentService: ActiveComponentService, private cd: ChangeDetectorRef, private translate: TranslateService, + private menuService: MenuService, public utils: UtilsService) { } @@ -96,7 +102,8 @@ export class BreadcrumbComponent implements OnInit, OnDestroy { return child; } - buildBreadCrumbs(route: ActivatedRouteSnapshot, breadcrumbs: Array = [], + buildBreadCrumbs(route: ActivatedRouteSnapshot, availableMenuSections: MenuSection[], + breadcrumbs: Array = [], lastChild?: ActivatedRouteSnapshot): Array { if (!lastChild) { lastChild = this.lastChild(route); @@ -105,24 +112,27 @@ export class BreadcrumbComponent implements OnInit, OnDestroy { if (route.routeConfig && route.routeConfig.data) { const breadcrumbConfig = route.routeConfig.data.breadcrumb as BreadCrumbConfig; if (breadcrumbConfig && !breadcrumbConfig.skip) { - let label; - let labelFunction; - let ignoreTranslate; + let labelFunction: () => string; + let section: MenuSection = null; + if (breadcrumbConfig.menuId) { + section = availableMenuSections.find(menu => menu.id === breadcrumbConfig.menuId); + if (!section) { + section = menuSectionMap.get(breadcrumbConfig.menuId); + } + } + const label = section?.name || breadcrumbConfig.label || 'home.home'; + const customTranslate = section?.customTranslate || false; if (breadcrumbConfig.labelFunction) { labelFunction = () => this.activeComponentValue ? - breadcrumbConfig.labelFunction(route, this.translate, this.activeComponentValue, lastChild.data) : breadcrumbConfig.label; - ignoreTranslate = true; - } else { - label = breadcrumbConfig.label || 'home.home'; - ignoreTranslate = false; + breadcrumbConfig.labelFunction(route, this.translate, this.activeComponentValue, lastChild.data) : label; } - const icon = breadcrumbConfig.icon || 'home'; + const icon = section?.icon || breadcrumbConfig.icon || 'home'; const link = [ route.pathFromRoot.map(v => v.url.map(segment => segment.toString()).join('/')).join('/') ]; const breadcrumb = { id: guid(), label, + customTranslate, labelFunction, - ignoreTranslate, icon, link, queryParams: null @@ -131,12 +141,12 @@ export class BreadcrumbComponent implements OnInit, OnDestroy { } } if (route.firstChild) { - return this.buildBreadCrumbs(route.firstChild, newBreadcrumbs, lastChild); + return this.buildBreadCrumbs(route.firstChild, availableMenuSections, newBreadcrumbs, lastChild); } return newBreadcrumbs; } - trackByBreadcrumbs(index: number, breadcrumb: BreadCrumb){ + trackByBreadcrumbs(_index: number, breadcrumb: BreadCrumb){ return breadcrumb.id; } } diff --git a/ui-ngx/src/app/shared/components/breadcrumb.ts b/ui-ngx/src/app/shared/components/breadcrumb.ts index 175a0b374d..1f5ad82820 100644 --- a/ui-ngx/src/app/shared/components/breadcrumb.ts +++ b/ui-ngx/src/app/shared/components/breadcrumb.ts @@ -17,11 +17,12 @@ import { ActivatedRouteSnapshot, Params } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { HasUUID } from '@shared/models/id/has-uuid'; +import { MenuId } from '@core/services/menu.models'; export interface BreadCrumb extends HasUUID{ label: string; + customTranslate: boolean; labelFunction?: () => string; - ignoreTranslate: boolean; icon: string; link: any[]; queryParams: Params; @@ -31,7 +32,8 @@ export type BreadCrumbLabelFunction = (route: ActivatedRouteSnapshot, transla export interface BreadCrumbConfig { labelFunction: BreadCrumbLabelFunction; - label: string; - icon: string; + menuId?: MenuId; + label?: string; + icon?: string; skip: boolean; } diff --git a/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.html new file mode 100644 index 0000000000..ba09311895 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.html @@ -0,0 +1,55 @@ + + +

+ {{ data.message }} +

+ + +
+
+
+ + +
diff --git a/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.scss b/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.scss new file mode 100644 index 0000000000..02c2e6bd00 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.scss @@ -0,0 +1,26 @@ +/** + * 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. + */ +$conflict-dialog-width: 530px; + +:host { + .main-label { + padding-left: 8px; + } + + .message-container { + max-width: #{$conflict-dialog-width}; + } +} diff --git a/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.ts new file mode 100644 index 0000000000..37275bd79c --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.ts @@ -0,0 +1,65 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { SharedModule } from '@shared/shared.module'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { CommonModule } from '@angular/common'; +import { entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityInfoData } from '@shared/models/entity.models'; + +interface EntityConflictDialogData { + message: string; + entity: EntityInfoData; +} + +@Component({ + selector: 'tb-entity-conflict-dialog', + templateUrl: 'entity-conflict-dialog.component.html', + styleUrls: ['./entity-conflict-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + SharedModule, + ], +}) +export class EntityConflictDialogComponent { + readonly entityTypeTranslations = entityTypeTranslations; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: EntityConflictDialogData, + private dialogRef: MatDialogRef, + private importExportService: ImportExportService, + ) {} + + onCancel(): void { + this.dialogRef.close(); + } + + onDiscard(): void { + this.dialogRef.close(false); + } + + onConfirm(): void { + this.dialogRef.close(true); + } + + onLinkClick(event: MouseEvent): void { + event.preventDefault(); + this.importExportService.exportEntity(this.data.entity); + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts index 4e33944f29..2108551031 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts @@ -25,11 +25,17 @@ import { SimpleChanges, ViewChild } from '@angular/core'; -import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + ValidationErrors, + Validators +} from '@angular/forms'; import { Observable } from 'rxjs'; import { filter, map, mergeMap, share, tap } from 'rxjs/operators'; -import { Store } from '@ngrx/store'; -import { AppState } from '@app/core/core.state'; import { TranslateService } from '@ngx-translate/core'; import { EntityType } from '@shared/models/entity-type.models'; import { BaseData } from '@shared/models/base-data'; @@ -39,6 +45,7 @@ import { MatAutocomplete } from '@angular/material/autocomplete'; import { MatChipGrid } from '@angular/material/chips'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { SubscriptSizing } from '@angular/material/form-field'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ selector: 'tb-entity-list', @@ -49,6 +56,11 @@ import { SubscriptSizing } from '@angular/material/form-field'; provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => EntityListComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => EntityListComponent), + multi: true } ] }) @@ -56,7 +68,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV entityListFormGroup: UntypedFormGroup; - modelValue: Array | null; + private modelValue: Array | null; @Input() entityType: EntityType; @@ -95,6 +107,10 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV @Input() hint: string; + @Input() + @coerceBoolean() + syncIdsWithDB = false; + @ViewChild('entityInput') entityInput: ElementRef; @ViewChild('entityAutocomplete') matAutocomplete: MatAutocomplete; @ViewChild('chipList', {static: true}) chipList: MatChipGrid; @@ -108,17 +124,16 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV private propagateChange = (v: any) => { }; - constructor(private store: Store, - public translate: TranslateService, + constructor(public translate: TranslateService, private entityService: EntityService, private fb: UntypedFormBuilder) { this.entityListFormGroup = this.fb.group({ - entities: [this.entities, this.required ? [Validators.required] : []], + entities: [this.entities], entity: [null] }); } - updateValidators() { + private updateValidators() { this.entityListFormGroup.get('entities').setValidators(this.required ? [Validators.required] : []); this.entityListFormGroup.get('entities').updateValueAndValidity(); } @@ -179,6 +194,10 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV (entities) => { this.entities = entities; this.entityListFormGroup.get('entities').setValue(this.entities); + if (this.syncIdsWithDB && this.modelValue.length !== entities.length) { + this.modelValue = entities.map(entity => entity.id.id); + this.propagateChange(this.modelValue); + } } ); } else { @@ -189,7 +208,13 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV this.dirty = true; } - reset() { + validate(): ValidationErrors | null { + return this.entityListFormGroup.valid ? null : { + entities: {valid: false} + }; + } + + private reset() { this.entities = []; this.entityListFormGroup.get('entities').setValue(this.entities); this.modelValue = null; @@ -201,7 +226,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV this.dirty = true; } - add(entity: BaseData): void { + private add(entity: BaseData): void { if (!this.modelValue || this.modelValue.indexOf(entity.id.id) === -1) { if (!this.modelValue) { this.modelValue = []; @@ -214,7 +239,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV this.clear(); } - remove(entity: BaseData) { + public remove(entity: BaseData) { let index = this.entities.indexOf(entity); if (index >= 0) { this.entities.splice(index, 1); @@ -229,11 +254,11 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV } } - displayEntityFn(entity?: BaseData): string | undefined { + public displayEntityFn(entity?: BaseData): string | undefined { return entity ? entity.name : undefined; } - fetchEntities(searchText?: string): Observable>> { + private fetchEntities(searchText?: string): Observable>> { this.searchText = searchText; return this.entityService.getEntitiesByNameFilter(this.entityType, searchText, @@ -241,14 +266,14 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV map((data) => data ? data : [])); } - onFocus() { + public onFocus() { if (this.dirty) { this.entityListFormGroup.get('entity').updateValueAndValidity({onlySelf: true, emitEvent: true}); this.dirty = false; } } - clear(value: string = '') { + private clear(value: string = '') { this.entityInput.nativeElement.value = value; this.entityListFormGroup.get('entity').patchValue(value, {emitEvent: true}); setTimeout(() => { @@ -257,8 +282,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV }, 0); } - textIsNotEmpty(text: string): boolean { + public textIsNotEmpty(text: string): boolean { return (text && text.length > 0); } - } diff --git a/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.html b/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.html index 32b66fd2b7..f2413f0ad7 100644 --- a/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.html +++ b/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + - +
diff --git a/ui-ngx/src/app/shared/components/image-input.component.ts b/ui-ngx/src/app/shared/components/image-input.component.ts index acbff9844f..5bf98c65a9 100644 --- a/ui-ngx/src/app/shared/components/image-input.component.ts +++ b/ui-ngx/src/app/shared/components/image-input.component.ts @@ -53,6 +53,9 @@ import { ImagePipe } from '@shared/pipe/image.pipe'; }) export class ImageInputComponent extends PageComponent implements AfterViewInit, OnDestroy, ControlValueAccessor { + @Input() + accept = 'image/*'; + @Input() label: string; @@ -85,6 +88,9 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit, @Input() inputId = this.utils.guid(); + @Input() + allowedExtensions: string; + @Input() @coerceBoolean() processImageApiLink = false; @@ -141,17 +147,19 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit, ); return false; } - const reader = new FileReader(); - reader.onload = (_loadEvent) => { - if (typeof reader.result === 'string' && reader.result.startsWith('data:image/')) { - this.imageUrl = reader.result; - this.safeImageUrl = this.sanitizer.bypassSecurityTrustUrl(this.imageUrl); - this.file = file; - this.fileName = fileName; - this.updateModel(); - } - }; - reader.readAsDataURL(file); + if (this.filterFile(flowFile)) { + const reader = new FileReader(); + reader.onload = (_loadEvent) => { + if (typeof reader.result === 'string' && reader.result.startsWith('data:image/')) { + this.imageUrl = reader.result; + this.safeImageUrl = this.sanitizer.bypassSecurityTrustUrl(this.imageUrl); + this.file = file; + this.fileName = fileName; + this.updateModel(); + } + }; + reader.readAsDataURL(file); + } } }); } @@ -198,6 +206,14 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit, this.fileNameChanged.emit(this.fileName); } + private filterFile(file: flowjs.FlowFile): boolean { + if (this.allowedExtensions) { + return this.allowedExtensions.split(',').indexOf(file.getExtension()) > -1; + } else { + return true; + } + } + clearImage() { this.imageUrl = null; this.safeImageUrl = null; diff --git a/ui-ngx/src/app/shared/components/image/gallery-image-input.component.ts b/ui-ngx/src/app/shared/components/image/gallery-image-input.component.ts index 9b81a0eaba..5894ce9bb4 100644 --- a/ui-ngx/src/app/shared/components/image/gallery-image-input.component.ts +++ b/ui-ngx/src/app/shared/components/image/gallery-image-input.component.ts @@ -26,11 +26,15 @@ import { isBase64DataImageUrl, isImageResourceUrl, prependTbImagePrefix, - removeTbImagePrefix + removeTbImagePrefix, + ResourceSubType } from '@shared/models/resource.models'; import { ImageService } from '@core/http/image.service'; import { MatDialog } from '@angular/material/dialog'; -import { ImageGalleryDialogComponent } from '@shared/components/image/image-gallery-dialog.component'; +import { + ImageGalleryDialogComponent, + ImageGalleryDialogData +} from '@shared/components/image/image-gallery-dialog.component'; export enum ImageLinkType { none = 'none', @@ -120,21 +124,26 @@ export class GalleryImageInputComponent extends PageComponent implements OnInit, this.detectLinkType(); if (this.linkType === ImageLinkType.resource) { const params = extractParamsFromImageResourceUrl(this.imageUrl); - this.loadingImageResource = true; - this.imageService.getImageInfo(params.type, params.key, {ignoreLoading: true, ignoreErrors: true}).subscribe( - { - next: (res) => { - this.imageResource = res; - this.loadingImageResource = false; - this.cd.markForCheck(); - }, - error: () => { - this.reset(); - this.loadingImageResource = false; - this.cd.markForCheck(); + if (params) { + this.loadingImageResource = true; + this.imageService.getImageInfo(params.type, params.key, {ignoreLoading: true, ignoreErrors: true}).subscribe( + { + next: (res) => { + this.imageResource = res; + this.loadingImageResource = false; + this.cd.markForCheck(); + }, + error: () => { + this.reset(); + this.loadingImageResource = false; + this.cd.markForCheck(); + } } - } - ); + ); + } else { + this.reset(); + this.cd.markForCheck(); + } } else if (this.linkType === ImageLinkType.base64) { this.cd.markForCheck(); } else if (this.linkType === ImageLinkType.external) { @@ -188,11 +197,14 @@ export class GalleryImageInputComponent extends PageComponent implements OnInit, if ($event) { $event.stopPropagation(); } - this.dialog.open(ImageGalleryDialogComponent, { autoFocus: false, disableClose: false, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + imageSubType: ResourceSubType.IMAGE + } }).afterClosed().subscribe((image) => { if (image) { this.linkType = ImageLinkType.resource; diff --git a/ui-ngx/src/app/shared/components/image/image-dialog.component.ts b/ui-ngx/src/app/shared/components/image/image-dialog.component.ts index 40e7d2ec89..5d3f14612a 100644 --- a/ui-ngx/src/app/shared/components/image/image-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/image/image-dialog.component.ts @@ -25,7 +25,7 @@ import { ImageService } from '@core/http/image.service'; import { ImageResource, ImageResourceInfo, imageResourceType } from '@shared/models/resource.models'; import { UploadImageDialogComponent, - UploadImageDialogData + UploadImageDialogData, UploadImageDialogResult } from '@shared/components/image/upload-image-dialog.component'; import { UrlHolder } from '@shared/pipe/image.pipe'; import { ImportExportService } from '@shared/import-export/import-export.service'; @@ -142,19 +142,20 @@ export class ImageDialogComponent extends $event.stopPropagation(); } this.dialog.open(UploadImageDialogComponent, { + UploadImageDialogResult>(UploadImageDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { + imageSubType: this.image.resourceSubType, image: this.image } }).afterClosed().subscribe((result) => { - if (result) { + if (result?.image) { this.imageChanged = true; - this.image = result; + this.image = result.image; let url; - if (result.base64) { - url = result.base64; + if (result.image.base64) { + url = result.image.base64; } else { url = this.image.link; } diff --git a/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.html b/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.html index 8e24e2ffb2..3c4b6b62fd 100644 --- a/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.html +++ b/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.html @@ -24,6 +24,7 @@ , protected router: Router, - private imageService: ImageService, - private dialog: MatDialog, + @Inject(MAT_DIALOG_DATA) public data: ImageGalleryDialogData, public dialogRef: MatDialogRef) { super(store, router, dialogRef); } diff --git a/ui-ngx/src/app/shared/components/image/image-gallery.component.html b/ui-ngx/src/app/shared/components/image/image-gallery.component.html index 21b10e8c66..d9768baf8a 100644 --- a/ui-ngx/src/app/shared/components/image/image-gallery.component.html +++ b/ui-ngx/src/app/shared/components/image/image-gallery.component.html @@ -22,7 +22,7 @@ [fxShow]="!textSearchMode && (mode === 'grid' || dataSource?.selection.isEmpty())" [ngClass.lt-lg]="{'multi-row': !isSysAdmin}">
- image.gallery + {{ (isScada ? 'scada.symbols' : 'image.gallery') | translate }}
@@ -46,7 +46,7 @@ {{ 'image.include-system-images' | translate }} + (ngModelChange)="includeSystemImagesChanged($event)">{{ (isScada ? 'scada.include-system-symbols' : 'image.include-system-images') | translate }}
@@ -68,7 +68,7 @@ @@ -77,7 +77,7 @@ mat-icon-button color="primary" (click)="uploadImage()" - matTooltip="{{'image.upload-image' | translate }}" + matTooltip="{{ (isScada ? 'scada.upload-symbol' : 'image.upload-image' ) | translate }}" matTooltipPosition="above"> add @@ -87,18 +87,18 @@ mat-flat-button color="primary" (click)="uploadImage()"> - {{ 'image.upload-image' | translate }} + {{ (isScada ? 'scada.upload-symbol' : 'image.upload-image' ) | translate }}
{{ 'image.include-system-images' | translate }} + (ngModelChange)="includeSystemImagesChanged($event)">{{ (isScada ? 'scada.include-system-symbols' : 'image.include-system-images') | translate }}
@@ -106,7 +106,7 @@   + placeholder="{{ (isScada ? 'scada.search' : 'image.search' ) | translate }}"/> - -
@@ -318,7 +318,7 @@
-
image.no-images
+
{{ (isScada ? 'scada.no-symbols' : 'image.no-images') | translate }}
@@ -326,18 +326,18 @@
- +
+
+ +
+
+
+ + +
{{ (disabled ? 'scada.no-symbol' : 'scada.no-symbol-selected') | translate }}
+
+ + + diff --git a/ui-ngx/src/app/shared/components/image/scada-symbol-input.component.scss b/ui-ngx/src/app/shared/components/image/scada-symbol-input.component.scss new file mode 100644 index 0000000000..2392f8185d --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/scada-symbol-input.component.scss @@ -0,0 +1,149 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../scss/constants"; + +$containerHeight: 96px !default; + +:host { + .tb-container { + margin-top: 0; + padding: 0 0 16px; + display: flex; + flex-direction: column; + gap: 8px; + label.tb-title { + display: block; + padding-bottom: 0; + } + } + + .tb-scada-symbol-select-container { + width: 100%; + height: $containerHeight; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + display: flex; + align-items: center; + &.disabled { + width: $containerHeight; + .tb-scada-symbol-container { + width: $containerHeight - 2px; + border-right: none; + } + } + } + + .tb-scada-symbol-container { + width: $containerHeight - 1px; + height: $containerHeight - 2px; + padding: 8px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 4px; + border-right: 1px solid rgba(0, 0, 0, 0.12); + background: #fff; + overflow: hidden; + } + + .tb-scada-symbol-preview { + width: auto; + max-width: $containerHeight - 2px; + height: auto; + max-height: $containerHeight - 2px; + } + + .tb-no-symbol { + text-align: center; + color: rgba(0, 0, 0, 0.38); + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.4px; + } + + .tb-scada-symbol-info-container { + display: flex; + flex: 1; + align-self: stretch; + padding: 0 8px; + justify-content: flex-end; + align-items: center; + gap: 4px; + .tb-resource-scada-symbol-container { + padding: 8px; + display: flex; + flex: 1; + align-self: stretch; + justify-content: center; + align-items: flex-start; + flex-direction: column; + gap: 4px; + .tb-scada-symbol-title { + overflow: hidden; + text-overflow: ellipsis; + -webkit-line-clamp: 2; + display: -webkit-box; + -webkit-box-orient: vertical; + font-size: 18px; + font-style: normal; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + } + &.loading { + align-items: center; + } + } + + .tb-scada-symbol-clear-btn { + color: rgba(0,0,0,0.38); + } + } + + .tb-scada-symbol-select-buttons-container { + display: flex; + flex: 1; + align-self: stretch; + padding: 8px; + gap: 8px; + justify-content: center; + align-items: flex-start; + .tb-scada-symbol-select-button { + width: 100%; + height: 100%; + align-self: stretch; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 4px; + padding: 8px; + line-height: normal; + font-size: 12px; + @media #{$mat-gt-xs} { + padding: 16px; + } + .mat-icon { + width: 24px; + height: 24px; + font-size: 24px; + margin: 0; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/image/scada-symbol-input.component.ts b/ui-ngx/src/app/shared/components/image/scada-symbol-input.component.ts new file mode 100644 index 0000000000..78aca19ce5 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/scada-symbol-input.component.ts @@ -0,0 +1,198 @@ +/// +/// 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, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, } from '@angular/forms'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { + extractParamsFromImageResourceUrl, + IMAGE_BASE64_URL_PREFIX, + ImageResourceInfo, + prependTbImagePrefix, + removeTbImagePrefix, + ResourceSubType +} from '@shared/models/resource.models'; +import { ImageService } from '@core/http/image.service'; +import { MatDialog } from '@angular/material/dialog'; +import { + ImageGalleryDialogComponent, + ImageGalleryDialogData +} from '@shared/components/image/image-gallery-dialog.component'; +import { ScadaSymbolMetadata } from '@home/components/widget/lib/scada/scada-symbol.models'; +import { stringToBase64 } from '@core/utils'; + +export enum ScadaSymbolLinkType { + none = 'none', + content = 'content', + resource = 'resource' +} + +@Component({ + selector: 'tb-scada-symbol-input', + templateUrl: './scada-symbol-input.component.html', + styleUrls: ['./scada-symbol-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolInputComponent), + multi: true + } + ] +}) +export class ScadaSymbolInputComponent extends PageComponent implements OnInit, OnDestroy, ControlValueAccessor { + + @Input() + label: string; + + @Input() + @coerceBoolean() + required = false; + + @Input() + disabled: boolean; + + @Input() + scadaSymbolContent: string; + + @Input() + scadaSymbolMetadata: ScadaSymbolMetadata; + + scadaSymbolUrl: string; + + imageResource: ImageResourceInfo; + + loadingImageResource = false; + + ScadaSymbolLinkType = ScadaSymbolLinkType; + + linkType: ScadaSymbolLinkType = ScadaSymbolLinkType.none; + + private propagateChange = null; + + constructor(protected store: Store, + private imageService: ImageService, + private dialog: MatDialog, + private cd: ChangeDetectorRef) { + super(store); + } + + ngOnInit() { + if (this.scadaSymbolContent && this.scadaSymbolMetadata) { + this.scadaSymbolUrl = IMAGE_BASE64_URL_PREFIX + 'svg+xml;base64,' + stringToBase64(this.scadaSymbolContent); + this.linkType = ScadaSymbolLinkType.content; + } + } + + ngOnDestroy() { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.detectLinkType(); + } + } + + writeValue(value: string): void { + value = removeTbImagePrefix(value); + if (this.scadaSymbolUrl !== value) { + this.reset(); + this.scadaSymbolUrl = value; + this.detectLinkType(); + if (this.linkType === ScadaSymbolLinkType.resource) { + const params = extractParamsFromImageResourceUrl(this.scadaSymbolUrl); + if (params) { + this.loadingImageResource = true; + this.imageService.getImageInfo(params.type, params.key, {ignoreLoading: true, ignoreErrors: true}).subscribe( + { + next: (res) => { + this.imageResource = res; + this.loadingImageResource = false; + this.cd.markForCheck(); + }, + error: () => { + this.reset(); + this.loadingImageResource = false; + this.cd.markForCheck(); + } + } + ); + } else { + this.reset(); + this.cd.markForCheck(); + } + } + } + } + + private detectLinkType() { + if (this.scadaSymbolUrl) { + this.linkType = ScadaSymbolLinkType.resource; + } else { + this.linkType = ScadaSymbolLinkType.none; + } + } + + private updateModel(value: string) { + this.cd.markForCheck(); + if (this.scadaSymbolUrl !== value) { + this.scadaSymbolUrl = value; + this.propagateChange(prependTbImagePrefix(this.scadaSymbolUrl)); + } + } + + private reset() { + this.linkType = ScadaSymbolLinkType.none; + this.imageResource = null; + } + + clearSymbol() { + this.reset(); + this.updateModel(null); + } + + openGallery($event: Event): void { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(ImageGalleryDialogComponent, { + autoFocus: false, + disableClose: false, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + imageSubType: ResourceSubType.SCADA_SYMBOL + } + }).afterClosed().subscribe((image) => { + if (image) { + this.linkType = ScadaSymbolLinkType.resource; + this.imageResource = image; + this.updateModel(image.link); + } + }); + } + +} diff --git a/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.html b/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.html index 5255234ba8..6f928639db 100644 --- a/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.html +++ b/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.html @@ -17,7 +17,7 @@ -->
-

{{ ( uploadImage ? 'image.upload-image' : 'image.update-image' ) | translate }}

+

{{ ( uploadImage ? (isScada ? 'scada.upload-symbol' : 'image.upload-image') : (isScada ? 'scada.update-symbol' : 'image.update-image') ) | translate }}

- +
-
+
{{ 'widgets-bundle.export-widgets-bundle-widgets-prompt' | translate }}
diff --git a/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts b/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts index 025a2ddf4c..f012dc77fe 100644 --- a/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts +++ b/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts @@ -27,6 +27,7 @@ import { isDefinedAndNotNull } from '@core/utils'; export interface ExportWidgetsBundleDialogData { widgetsBundle: WidgetsBundle; includeBundleWidgetsInExport: boolean; + ignoreLoading?: boolean; } export interface ExportWidgetsBundleDialogResult { @@ -44,6 +45,8 @@ export class ExportWidgetsBundleDialogComponent extends DialogComponent, @@ -52,6 +55,7 @@ export class ExportWidgetsBundleDialogComponent extends DialogComponent) { super(store, router, dialogRef); this.widgetsBundle = data.widgetsBundle; + this.ignoreLoading = data.ignoreLoading; if (isDefinedAndNotNull(data.includeBundleWidgetsInExport)) { this.exportWidgetsFormControl.patchValue(data.includeBundleWidgetsInExport, {emitEvent: false}); } diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index 9902177ebd..4746365c50 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -20,7 +20,7 @@ import { TranslateService } from '@ngx-translate/core'; import { select, Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { ActionNotificationShow } from '@core/notification/notification.actions'; -import { Dashboard, DashboardLayoutId } from '@shared/models/dashboard.models'; +import { BreakpointId, Dashboard, DashboardLayoutId } from '@shared/models/dashboard.models'; import { deepClone, guid, isDefined, isNotEmptyStr, isObject, isString, isUndefined } from '@core/utils'; import { WINDOW } from '@core/services/window.service'; import { DOCUMENT } from '@angular/common'; @@ -55,7 +55,7 @@ import { EntityType } from '@shared/models/entity-type.models'; import { UtilsService } from '@core/services/utils.service'; import { WidgetService } from '@core/http/widget.service'; import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; -import { ImportEntitiesResultInfo, ImportEntityData } from '@shared/models/entity.models'; +import { EntityInfoData, ImportEntitiesResultInfo, ImportEntityData } from '@shared/models/entity.models'; import { RequestConfig } from '@core/http/http-utils'; import { RuleChain, RuleChainImport, RuleChainMetaData, RuleChainType } from '@shared/models/rule-chain.models'; import { RuleChainService } from '@core/http/rule-chain.service'; @@ -198,8 +198,9 @@ export class ImportExportService { ); } - public exportWidget(dashboard: Dashboard, sourceState: string, sourceLayout: DashboardLayoutId, widget: Widget, widgetTitle: string) { - const widgetItem = this.itembuffer.prepareWidgetItem(dashboard, sourceState, sourceLayout, widget); + public exportWidget(dashboard: Dashboard, sourceState: string, sourceLayout: DashboardLayoutId, widget: Widget, + widgetTitle: string, breakpoint: BreakpointId) { + const widgetItem = this.itembuffer.prepareWidgetItem(dashboard, sourceState, sourceLayout, widget, breakpoint); const widgetDefaultName = this.widgetService.getWidgetInfoFromCache(widget.typeFullFqn).widgetName; let fileName = widgetDefaultName + (isNotEmptyStr(widgetTitle) ? `_${widgetTitle}` : ''); fileName = fileName.toLowerCase().replace(/\W/g, '_'); @@ -351,28 +352,50 @@ export class ImportExportService { forkJoin(tasks).subscribe({ next: ({includeBundleWidgetsInExport, widgetsBundle}) => { - this.dialog.open(ExportWidgetsBundleDialogComponent, { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], - data: { - widgetsBundle, - includeBundleWidgetsInExport - } - }).afterClosed().subscribe( - (result) => { - if (result) { - if (includeBundleWidgetsInExport !== result.exportWidgets) { - this.store.dispatch(new ActionPreferencesPutUserSettings({includeBundleWidgetsInExport: result.exportWidgets})); - } - if (result.exportWidgets) { - this.exportWidgetsBundleWithWidgetTypes(widgetsBundle); - } else { - this.exportWidgetsBundleWithWidgetTypeFqns(widgetsBundle); - } - } - } - ); + this.handleExportWidgetsBundle(widgetsBundle, includeBundleWidgetsInExport); + }, + error: (e) => { + this.handleExportError(e, 'widgets-bundle.export-failed-error'); + } + }); + } + + public exportEntity(entityData: EntityInfoData): void { + let preparedData; + switch (entityData.id.entityType) { + case EntityType.DEVICE_PROFILE: + case EntityType.ASSET_PROFILE: + preparedData = this.prepareProfileExport(entityData as DeviceProfile | AssetProfile); + break; + case EntityType.RULE_CHAIN: + this.ruleChainService.getRuleChainMetadata(entityData.id.id) + .pipe( + take(1), + map((ruleChainMetaData) => { + const ruleChainExport: RuleChainImport = { + ruleChain: this.prepareRuleChain(entityData as RuleChain), + metadata: this.prepareRuleChainMetaData(ruleChainMetaData) + }; + return ruleChainExport; + })) + .subscribe(ruleChainData => this.exportToPc(ruleChainData, entityData.name)); + return; + case EntityType.WIDGETS_BUNDLE: + this.exportSelectedWidgetsBundle(entityData as WidgetsBundle); + return; + case EntityType.DASHBOARD: + preparedData = this.prepareDashboardExport(entityData as Dashboard); + break; + default: + preparedData = this.prepareExport(entityData); + } + this.exportToPc(preparedData, entityData.name); + } + + private exportSelectedWidgetsBundle(widgetsBundle: WidgetsBundle): void { + this.store.pipe(select(selectUserSettingsProperty( 'includeBundleWidgetsInExport'))).pipe(take(1)).subscribe({ + next: (includeBundleWidgetsInExport) => { + this.handleExportWidgetsBundle(widgetsBundle, includeBundleWidgetsInExport, true); }, error: (e) => { this.handleExportError(e, 'widgets-bundle.export-failed-error'); @@ -380,6 +403,32 @@ export class ImportExportService { }); } + private handleExportWidgetsBundle(widgetsBundle: WidgetsBundle, includeBundleWidgetsInExport: boolean, ignoreLoading?: boolean): void { + this.dialog.open(ExportWidgetsBundleDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + widgetsBundle, + includeBundleWidgetsInExport, + ignoreLoading + } + }).afterClosed().subscribe( + (result) => { + if (result) { + if (includeBundleWidgetsInExport !== result.exportWidgets) { + this.store.dispatch(new ActionPreferencesPutUserSettings({includeBundleWidgetsInExport: result.exportWidgets})); + } + if (result.exportWidgets) { + this.exportWidgetsBundleWithWidgetTypes(widgetsBundle); + } else { + this.exportWidgetsBundleWithWidgetTypeFqns(widgetsBundle); + } + } + } + ); + } + private exportWidgetsBundleWithWidgetTypes(widgetsBundle: WidgetsBundle) { this.widgetService.exportBundleWidgetTypesDetails(widgetsBundle.id.id).subscribe({ next: (widgetTypesDetails) => { @@ -1108,6 +1157,9 @@ export class ImportExportService { if (isDefined(exportedData.externalId)) { delete exportedData.externalId; } + if (isDefined(exportedData.version)) { + delete exportedData.version; + } return exportedData; } diff --git a/ui-ngx/src/app/shared/models/ace/ace.models.ts b/ui-ngx/src/app/shared/models/ace/ace.models.ts index a63064da43..d21859e531 100644 --- a/ui-ngx/src/app/shared/models/ace/ace.models.ts +++ b/ui-ngx/src/app/shared/models/ace/ace.models.ts @@ -347,3 +347,9 @@ export class Range implements Ace.Range { } } + +export interface TbHighlightRule { + class: string; + regex: RegExp; +} + diff --git a/ui-ngx/src/app/shared/models/ace/completion.models.ts b/ui-ngx/src/app/shared/models/ace/completion.models.ts index 9a8613d6f1..bfba8e5616 100644 --- a/ui-ngx/src/app/shared/models/ace/completion.models.ts +++ b/ui-ngx/src/app/shared/models/ace/completion.models.ts @@ -59,6 +59,10 @@ export class TbEditorCompleter implements Ace.Completer { constructor(private editorCompletions: TbEditorCompletions) { } + updateCompletions(completions: TbEditorCompletions): void { + this.editorCompletions = completions; + } + getCompletions(editor: Ace.Editor, session: Ace.EditSession, position: Ace.Point, prefix: string, callback: Ace.CompleterCallback): void { const result = this.prepareCompletions(prefix); diff --git a/ui-ngx/src/app/shared/models/asset.models.ts b/ui-ngx/src/app/shared/models/asset.models.ts index 6ba8bc8abb..7fc97f48e2 100644 --- a/ui-ngx/src/app/shared/models/asset.models.ts +++ b/ui-ngx/src/app/shared/models/asset.models.ts @@ -22,9 +22,9 @@ import { EntitySearchQuery } from '@shared/models/relation.models'; import { AssetProfileId } from '@shared/models/id/asset-profile-id'; import { RuleChainId } from '@shared/models/id/rule-chain-id'; import { DashboardId } from '@shared/models/id/dashboard-id'; -import { EntityInfoData, HasTenantId } from '@shared/models/entity.models'; +import { EntityInfoData, HasTenantId, HasVersion } from '@shared/models/entity.models'; -export interface AssetProfile extends BaseData, HasTenantId, ExportableEntity { +export interface AssetProfile extends BaseData, HasTenantId, HasVersion, ExportableEntity { tenantId?: TenantId; name: string; description?: string; @@ -42,7 +42,7 @@ export interface AssetProfileInfo extends EntityInfoData { defaultDashboardId?: DashboardId; } -export interface Asset extends BaseData, HasTenantId, ExportableEntity { +export interface Asset extends BaseData, HasTenantId, HasVersion, ExportableEntity { tenantId?: TenantId; customerId?: CustomerId; name: string; diff --git a/ui-ngx/src/app/shared/models/audit-log.models.ts b/ui-ngx/src/app/shared/models/audit-log.models.ts index 70e8a0be6b..a399a458c2 100644 --- a/ui-ngx/src/app/shared/models/audit-log.models.ts +++ b/ui-ngx/src/app/shared/models/audit-log.models.ts @@ -48,6 +48,7 @@ export enum ActionType { ALARM_ACK = 'ALARM_ACK', ALARM_CLEAR = 'ALARM_CLEAR', ALARM_ASSIGNED = 'ALARM_ASSIGNED', + ALARM_DELETE = 'ALARM_DELETE', ALARM_UNASSIGNED = 'ALARM_UNASSIGNED', ADDED_COMMENT = 'ADDED_COMMENT', UPDATED_COMMENT = 'UPDATED_COMMENT', @@ -91,6 +92,7 @@ export const actionTypeTranslations = new Map( [ActionType.RELATIONS_DELETED, 'audit-log.type-relations-delete'], [ActionType.ALARM_ACK, 'audit-log.type-alarm-ack'], [ActionType.ALARM_CLEAR, 'audit-log.type-alarm-clear'], + [ActionType.ALARM_DELETE, 'audit-log.type-alarm-delete'], [ActionType.ALARM_ASSIGNED, 'audit-log.type-alarm-assign'], [ActionType.ALARM_UNASSIGNED, 'audit-log.type-alarm-unassign'], [ActionType.ADDED_COMMENT, 'audit-log.type-added-comment'], diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index ec6aa1c9e7..6ab3e3081a 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -133,6 +133,7 @@ export const HelpLinks = { ruleNodeSaveToCustomTable: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#save-to-custom-table`, ruleNodeRuleChain: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#rule-chain-node`, ruleNodeOutputNode: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#output-node`, + ruleNodeAwsLambda: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#aws-lambda-node`, ruleNodeAwsSns: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#aws-sns-node`, ruleNodeAwsSqs: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#aws-sqs-node`, ruleNodeKafka: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#kafka-node`, diff --git a/ui-ngx/src/app/shared/models/customer.model.ts b/ui-ngx/src/app/shared/models/customer.model.ts index 3484dac633..2e8bf65334 100644 --- a/ui-ngx/src/app/shared/models/customer.model.ts +++ b/ui-ngx/src/app/shared/models/customer.model.ts @@ -18,9 +18,9 @@ import { CustomerId } from '@shared/models/id/customer-id'; import { ContactBased } from '@shared/models/contact-based.model'; import { TenantId } from './id/tenant-id'; import { ExportableEntity } from '@shared/models/base-data'; -import { HasTenantId } from '@shared/models/entity.models'; +import { HasTenantId, HasVersion } from '@shared/models/entity.models'; -export interface Customer extends ContactBased, HasTenantId, ExportableEntity { +export interface Customer extends ContactBased, HasTenantId, HasVersion, ExportableEntity { tenantId: TenantId; title: string; additionalInfo?: any; diff --git a/ui-ngx/src/app/shared/models/dashboard.models.ts b/ui-ngx/src/app/shared/models/dashboard.models.ts index 36a14f59c6..2621175030 100644 --- a/ui-ngx/src/app/shared/models/dashboard.models.ts +++ b/ui-ngx/src/app/shared/models/dashboard.models.ts @@ -23,9 +23,9 @@ import { Timewindow } from '@shared/models/time/time.models'; import { EntityAliases } from './alias.models'; import { Filters } from '@shared/models/query/query.models'; import { MatDialogRef } from '@angular/material/dialog'; -import { HasTenantId } from '@shared/models/entity.models'; +import { HasTenantId, HasVersion } from '@shared/models/entity.models'; -export interface DashboardInfo extends BaseData, HasTenantId, ExportableEntity { +export interface DashboardInfo extends BaseData, HasTenantId, HasVersion, ExportableEntity { tenantId?: TenantId; title?: string; image?: string; @@ -49,14 +49,48 @@ export interface WidgetLayouts { [id: string]: WidgetLayout; } +export enum LayoutType { + default = 'default', + scada = 'scada', + divider = 'divider', +} + +export const layoutTypes = Object.keys(LayoutType) as LayoutType[]; + +export const layoutTypeTranslationMap = new Map( + [ + [ LayoutType.default, 'dashboard.layout-type-default' ], + [ LayoutType.scada, 'dashboard.layout-type-scada' ], + [ LayoutType.divider, 'dashboard.layout-type-divider' ], + ] +); + +export enum ViewFormatType { + grid = 'grid', + list = 'list', +} + +export const viewFormatTypes = Object.keys(ViewFormatType) as ViewFormatType[]; + +export const viewFormatTypeTranslationMap = new Map( + [ + [ ViewFormatType.grid, 'dashboard.view-format-type-grid' ], + [ ViewFormatType.list, 'dashboard.view-format-type-list' ], + ] +); + export interface GridSettings { + layoutType?: LayoutType; backgroundColor?: string; columns?: number; + minColumns?: number; margin?: number; outerMargin?: boolean; + viewFormat?: ViewFormatType; backgroundSizeMode?: string; backgroundImageUrl?: string; autoFillHeight?: boolean; + rowHeight?: number; mobileAutoFillHeight?: boolean; mobileRowHeight?: number; mobileDisplayLayoutFirst?: boolean; @@ -67,16 +101,47 @@ export interface GridSettings { export interface DashboardLayout { widgets: WidgetLayouts; gridSettings: GridSettings; + breakpoints?: {[breakpointId in BreakpointId]?: Omit}; } -export interface DashboardLayoutInfo { +export declare type DashboardLayoutInfo = {[breakpointId in BreakpointId]?: BreakpointLayoutInfo}; + +export interface BreakpointLayoutInfo { widgetIds?: string[]; widgetLayouts?: WidgetLayouts; gridSettings?: GridSettings; } +export declare type BreakpointSystemId = 'default' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; +export declare type BreakpointId = BreakpointSystemId | string; + +export interface BreakpointInfo { + id: BreakpointId; + maxWidth?: number; + minWidth?: number; + value?: string; +} + +export const breakpointIdTranslationMap = new Map([ + ['default', 'dashboard.breakpoints-id.default'], + ['xs', 'dashboard.breakpoints-id.xs'], + ['sm', 'dashboard.breakpoints-id.sm'], + ['md', 'dashboard.breakpoints-id.md'], + ['lg', 'dashboard.breakpoints-id.lg'], + ['xl', 'dashboard.breakpoints-id.xl'], +]); + +export const breakpointIdIconMap = new Map([ + ['default', 'desktop_windows'], + ['xs', 'phone_iphone'], + ['sm', 'tablet_mac'], + ['md', 'computer'], + ['lg', 'monitor'], + ['xl', 'desktop_windows'], +]); + export interface LayoutDimension { - type?: LayoutType; + type?: LayoutDimensionType; fixedWidth?: number; fixedLayout?: DashboardLayoutId; leftWidthPercentage?: number; @@ -84,7 +149,7 @@ export interface LayoutDimension { export declare type DashboardLayoutId = 'main' | 'right'; -export declare type LayoutType = 'percentage' | 'fixed'; +export declare type LayoutDimensionType = 'percentage' | 'fixed'; export declare type DashboardStateLayouts = {[key in DashboardLayoutId]?: DashboardLayout}; diff --git a/ui-ngx/src/app/shared/models/device.models.ts b/ui-ngx/src/app/shared/models/device.models.ts index b38833bbaa..89a887e76d 100644 --- a/ui-ngx/src/app/shared/models/device.models.ts +++ b/ui-ngx/src/app/shared/models/device.models.ts @@ -22,7 +22,7 @@ import { DeviceCredentialsId } from '@shared/models/id/device-credentials-id'; import { EntitySearchQuery } from '@shared/models/relation.models'; import { DeviceProfileId } from '@shared/models/id/device-profile-id'; import { RuleChainId } from '@shared/models/id/rule-chain-id'; -import { EntityInfoData, HasTenantId } from '@shared/models/entity.models'; +import { EntityInfoData, HasTenantId, HasVersion } from '@shared/models/entity.models'; import { FilterPredicateValue, KeyFilter } from '@shared/models/query/query.models'; import { TimeUnit } from '@shared/models/time/time.models'; import * as _moment from 'moment'; @@ -584,7 +584,7 @@ export interface DeviceProfileData { provisionConfiguration?: DeviceProvisionConfiguration; } -export interface DeviceProfile extends BaseData, HasTenantId, ExportableEntity { +export interface DeviceProfile extends BaseData, HasTenantId, HasVersion, ExportableEntity { tenantId?: TenantId; name: string; description?: string; @@ -655,7 +655,7 @@ export enum SnmpAuthenticationProtocol { SHA_256 = 'SHA_256', SHA_384 = 'SHA_384', SHA_512 = 'SHA_512', - MD5 = 'MD%' + MD5 = 'MD5' } export const SnmpAuthenticationProtocolTranslationMap = new Map([ @@ -711,7 +711,7 @@ export interface DeviceData { transportConfiguration: DeviceTransportConfiguration; } -export interface Device extends BaseData, HasTenantId, ExportableEntity { +export interface Device extends BaseData, HasTenantId, HasVersion, ExportableEntity { tenantId?: TenantId; customerId?: CustomerId; name: string; @@ -801,7 +801,7 @@ export const credentialTypesByTransportType = new Map { +export interface DeviceCredentials extends BaseData, HasTenantId { deviceId: DeviceId; credentialsType: DeviceCredentialsType; credentialsId: string; diff --git a/ui-ngx/src/app/shared/models/edge.models.ts b/ui-ngx/src/app/shared/models/edge.models.ts index 569b2bec8a..6e1fe5dd70 100644 --- a/ui-ngx/src/app/shared/models/edge.models.ts +++ b/ui-ngx/src/app/shared/models/edge.models.ts @@ -22,9 +22,9 @@ import { EntitySearchQuery } from '@shared/models/relation.models'; import { RuleChainId } from '@shared/models/id/rule-chain-id'; import { BaseEventBody } from '@shared/models/event.models'; import { EventId } from '@shared/models/id/event-id'; -import { HasTenantId } from '@shared/models/entity.models'; +import { HasTenantId, HasVersion } from '@shared/models/entity.models'; -export interface Edge extends BaseData, HasTenantId { +export interface Edge extends BaseData, HasTenantId, HasVersion { tenantId?: TenantId; customerId?: CustomerId; name: string; diff --git a/ui-ngx/src/app/shared/models/entity-view.models.ts b/ui-ngx/src/app/shared/models/entity-view.models.ts index cb05dd3be5..f817456bbe 100644 --- a/ui-ngx/src/app/shared/models/entity-view.models.ts +++ b/ui-ngx/src/app/shared/models/entity-view.models.ts @@ -20,7 +20,7 @@ import { CustomerId } from '@shared/models/id/customer-id'; import { EntityViewId } from '@shared/models/id/entity-view-id'; import { EntityId } from '@shared/models/id/entity-id'; import { EntitySearchQuery } from '@shared/models/relation.models'; -import { HasTenantId } from '@shared/models/entity.models'; +import { HasTenantId, HasVersion } from '@shared/models/entity.models'; export interface AttributesEntityView { cs: Array; @@ -33,7 +33,7 @@ export interface TelemetryEntityView { attributes: AttributesEntityView; } -export interface EntityView extends BaseData, HasTenantId, ExportableEntity { +export interface EntityView extends BaseData, HasTenantId, HasVersion, ExportableEntity { tenantId: TenantId; customerId: CustomerId; entityId: EntityId; diff --git a/ui-ngx/src/app/shared/models/entity.models.ts b/ui-ngx/src/app/shared/models/entity.models.ts index cf8acd1c33..40ec570b13 100644 --- a/ui-ngx/src/app/shared/models/entity.models.ts +++ b/ui-ngx/src/app/shared/models/entity.models.ts @@ -187,3 +187,7 @@ export const entityFields: {[fieldName: string]: EntityField} = { export interface HasTenantId { tenantId?: TenantId; } + +export interface HasVersion { + version?: number; +} diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index a35e87864e..688493d22f 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -27,6 +27,11 @@ export enum ResourceType { JS_MODULE = 'JS_MODULE' } +export enum ResourceSubType { + IMAGE = 'IMAGE', + SCADA_SYMBOL = 'SCADA_SYMBOL' +} + export const ResourceTypeMIMETypes = new Map( [ [ResourceType.LWM2M_MODEL, 'application/xml,text/xml'], @@ -59,6 +64,7 @@ export interface TbResourceInfo extends Omit, 'name' | resourceKey?: string; title?: string; resourceType: ResourceType; + resourceSubType?: ResourceSubType; fileName: string; public: boolean; publicResourceKey?: string; @@ -94,6 +100,7 @@ export interface ImageExportData { mediaType: string; fileName: string; title: string; + subType: string; resourceKey: string; public: boolean; publicResourceKey: string; @@ -160,7 +167,11 @@ export const isImageResourceUrl = (url: string): boolean => url && IMAGES_URL_RE export const extractParamsFromImageResourceUrl = (url: string): {type: ImageResourceType; key: string} => { const res = url.match(IMAGES_URL_REGEXP); - return {type: res[1] as ImageResourceType, key: res[2]}; + if (res?.length > 2) { + return {type: res[1] as ImageResourceType, key: res[2]}; + } else { + return null; + } }; export const isBase64DataImageUrl = (url: string): boolean => url && url.startsWith(IMAGE_BASE64_URL_PREFIX); diff --git a/ui-ngx/src/app/shared/models/rule-chain.models.ts b/ui-ngx/src/app/shared/models/rule-chain.models.ts index aa7ec47301..0bfdfbc459 100644 --- a/ui-ngx/src/app/shared/models/rule-chain.models.ts +++ b/ui-ngx/src/app/shared/models/rule-chain.models.ts @@ -20,9 +20,9 @@ import { RuleChainId } from '@shared/models/id/rule-chain-id'; import { RuleNodeId } from '@shared/models/id/rule-node-id'; import { RuleNode, RuleNodeComponentDescriptor, RuleNodeType } from '@shared/models/rule-node.models'; import { ComponentClusteringMode, ComponentType } from '@shared/models/component-descriptor.models'; -import { HasTenantId } from '@shared/models/entity.models'; +import { HasTenantId, HasVersion } from '@shared/models/entity.models'; -export interface RuleChain extends BaseData, HasTenantId, ExportableEntity { +export interface RuleChain extends BaseData, HasTenantId, HasVersion, ExportableEntity { tenantId: TenantId; name: string; firstRuleNodeId: RuleNodeId; diff --git a/ui-ngx/src/app/shared/models/rule-node.models.ts b/ui-ngx/src/app/shared/models/rule-node.models.ts index 1d99e29498..ef3b5147fb 100644 --- a/ui-ngx/src/app/shared/models/rule-node.models.ts +++ b/ui-ngx/src/app/shared/models/rule-node.models.ts @@ -493,6 +493,7 @@ const ruleNodeClazzHelpLinkMap = { 'org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode': 'ruleNodeSaveAttributes', 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode': 'ruleNodeSaveTimeseries', 'org.thingsboard.rule.engine.action.TbSaveToCustomCassandraTableNode': 'ruleNodeSaveToCustomTable', + 'org.thingsboard.rule.engine.aws.lambda.TbLambdaNode': 'ruleNodeAwsLambda', 'org.thingsboard.rule.engine.aws.sns.TbSnsNode': 'ruleNodeAwsSns', 'org.thingsboard.rule.engine.aws.sqs.TbSqsNode': 'ruleNodeAwsSqs', 'org.thingsboard.rule.engine.kafka.TbKafkaNode': 'ruleNodeKafka', diff --git a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts index e1c6af620b..8cf07e1128 100644 --- a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts +++ b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts @@ -38,7 +38,7 @@ import { entityFields } from '@shared/models/entity.models'; import { isDefinedAndNotNull, isUndefined } from '@core/utils'; import { CmdWrapper, WsService, WsSubscriber } from '@shared/models/websocket/websocket.models'; import { TelemetryWebsocketService } from '@core/ws/telemetry-websocket.service'; -import { Notification } from '@shared/models/notification.models'; +import { Notification, NotificationType } from '@shared/models/notification.models'; import { WebsocketService } from '@core/ws/websocket.service'; export const NOT_SUPPORTED = 'Not supported!'; @@ -304,11 +304,14 @@ export class UnreadCountSubCmd implements WebsocketCmd { export class UnreadSubCmd implements WebsocketCmd { limit: number; + types: Array; cmdId: number; type = WsCmdType.NOTIFICATIONS; - constructor(limit = 10) { + constructor(limit = 10, + types: Array = []) { this.limit = limit; + this.types = types; } } @@ -911,6 +914,8 @@ export class NotificationSubscriber extends WsSubscriber { public messageLimit = 10; + public notificationType = []; + public notificationCount$ = this.notificationCountSubject.asObservable().pipe(map(msg => msg.totalUnreadCount)); public notifications$ = this.notificationsSubject.asObservable().pipe(map(msg => msg.notifications )); @@ -923,8 +928,8 @@ export class NotificationSubscriber extends WsSubscriber { } public static createNotificationsSubscription(websocketService: WebsocketService, - zone: NgZone, limit = 10): NotificationSubscriber { - const subscriptionCommand = new UnreadSubCmd(limit); + zone: NgZone, limit = 10, types: Array = []): NotificationSubscriber { + const subscriptionCommand = new UnreadSubCmd(limit, types); const subscriber = new NotificationSubscriber(websocketService, zone); subscriber.messageLimit = limit; subscriber.subscriptionCommands.push(subscriptionCommand); diff --git a/ui-ngx/src/app/shared/models/widget-settings.models.ts b/ui-ngx/src/app/shared/models/widget-settings.models.ts index 5124458ff0..acb82c6d5d 100644 --- a/ui-ngx/src/app/shared/models/widget-settings.models.ts +++ b/ui-ngx/src/app/shared/models/widget-settings.models.ts @@ -195,8 +195,8 @@ export const colorRangeIncludes = (range: ColorRange, toCheck: ColorRange): bool } }; -export const filterIncludingColorRanges = (ranges: Array): Array => { - const result = [...ranges]; +export const filterIncludingColorRanges = (ranges: Array | ColorRangeSettings): Array => { + const result = [...(Array.isArray(ranges) ? ranges : ranges.range)]; let includes = true; while (includes) { let index = -1; @@ -935,6 +935,24 @@ export interface BackgroundSettings { overlay: OverlaySettings; } +export const isBackgroundSettings = (background: any): background is BackgroundSettings => { + if (background && background.type && background.overlay && background.overlay.color) { + return true; + } else { + return false; + } +}; + +export const colorBackground = (color: string): BackgroundSettings => ({ + type: BackgroundType.color, + color, + overlay: { + enabled: false, + color: 'rgba(255,255,255,0.72)', + blur: 3 + } +}); + export const iconStyle = (size: number | string, sizeUnit: cssUnit = 'px'): ComponentStyle => { const iconSize = typeof size === 'number' ? size + sizeUnit : size; return { diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index 050c655de9..3b60cd727e 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -37,12 +37,13 @@ import { AbstractControl, UntypedFormGroup } from '@angular/forms'; import { Observable } from 'rxjs'; import { Dashboard } from '@shared/models/dashboard.models'; import { IAliasController } from '@core/api/widget-api.models'; -import { isNotEmptyStr, mergeDeep } from '@core/utils'; +import { isNotEmptyStr, mergeDeepIgnoreArray } from '@core/utils'; import { WidgetConfigComponentData } from '@home/models/widget-component.models'; import { ComponentStyle, Font, TimewindowStyle } from '@shared/models/widget-settings.models'; import { NULL_UUID } from '@shared/models/id/has-uuid'; -import { HasTenantId } from '@shared/models/entity.models'; +import { HasTenantId, HasVersion } from '@shared/models/entity.models'; import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; export enum widgetType { timeseries = 'timeseries', @@ -186,6 +187,7 @@ export interface WidgetTypeParameters { defaultLatestDataKeysFunction?: (configComponent: any, configData: any) => DataKey[]; dataKeySettingsFunction?: DataKeySettingsFunction; displayRpcMessageToast?: boolean; + targetDeviceOptional?: boolean; } export interface WidgetControllerDescriptor { @@ -197,11 +199,12 @@ export interface WidgetControllerDescriptor { actionSources?: {[actionSourceId: string]: WidgetActionSource}; } -export interface BaseWidgetType extends BaseData, HasTenantId { +export interface BaseWidgetType extends BaseData, HasTenantId, HasVersion { tenantId: TenantId; fqn: string; name: string; deprecated: boolean; + scada: boolean; } export const fullWidgetTypeFqn = (type: BaseWidgetType): string => @@ -359,7 +362,7 @@ export interface DataKey extends KeyInfo { _hash?: number; } -export type CellClickColumnInfo = Pick +export type CellClickColumnInfo = Pick; export enum DataKeyConfigMode { general = 'general', @@ -536,6 +539,7 @@ export interface LegendData { } export enum WidgetActionType { + doNothing = 'doNothing', openDashboardState = 'openDashboardState', updateDashboardState = 'updateDashboardState', openDashboard = 'openDashboard', @@ -560,6 +564,7 @@ export const widgetActionTypes = Object.keys(WidgetActionType) as WidgetActionTy export const widgetActionTypeTranslationMap = new Map( [ + [ WidgetActionType.doNothing, 'widget-action.do-nothing' ], [ WidgetActionType.openDashboardState, 'widget-action.open-dashboard-state' ], [ WidgetActionType.updateDashboardState, 'widget-action.update-dashboard-state' ], [ WidgetActionType.openDashboard, 'widget-action.open-dashboard' ], @@ -833,6 +838,7 @@ export interface WidgetSize { export interface IWidgetSettingsComponent { aliasController: IAliasController; + callbacks: WidgetConfigCallbacks; dataKeyCallbacks: DataKeysCallbacks; dashboard: Dashboard; widget: Widget; @@ -851,6 +857,8 @@ export abstract class WidgetSettingsComponent extends PageComponent implements aliasController: IAliasController; + callbacks: WidgetConfigCallbacks; + dataKeyCallbacks: DataKeysCallbacks; dashboard: Dashboard; @@ -878,7 +886,7 @@ export abstract class WidgetSettingsComponent extends PageComponent implements if (!value) { this.settingsValue = this.defaultSettings(); } else { - this.settingsValue = mergeDeep(this.defaultSettings(), value); + this.settingsValue = mergeDeepIgnoreArray(this.defaultSettings(), value); } if (!this.settingsSet) { this.settingsSet = true; diff --git a/ui-ngx/src/app/shared/models/widgets-bundle.model.ts b/ui-ngx/src/app/shared/models/widgets-bundle.model.ts index 9a6dbb5700..7066defdf7 100644 --- a/ui-ngx/src/app/shared/models/widgets-bundle.model.ts +++ b/ui-ngx/src/app/shared/models/widgets-bundle.model.ts @@ -17,13 +17,14 @@ import { BaseData, ExportableEntity } from '@shared/models/base-data'; import { TenantId } from '@shared/models/id/tenant-id'; import { WidgetsBundleId } from '@shared/models/id/widgets-bundle-id'; -import { HasTenantId } from '@shared/models/entity.models'; +import { HasTenantId, HasVersion } from '@shared/models/entity.models'; -export interface WidgetsBundle extends BaseData, HasTenantId, ExportableEntity { +export interface WidgetsBundle extends BaseData, HasTenantId, HasVersion, ExportableEntity { tenantId: TenantId; alias: string; title: string; image: string; + scada: boolean; description: string; order: number; } diff --git a/ui-ngx/src/app/shared/pipe/custom-translate.pipe.ts b/ui-ngx/src/app/shared/pipe/custom-translate.pipe.ts new file mode 100644 index 0000000000..141d24015e --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/custom-translate.pipe.ts @@ -0,0 +1,33 @@ +/// +/// 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 { Pipe, PipeTransform } from '@angular/core'; +import { UtilsService } from '@core/services/utils.service'; + +@Pipe({ + name: 'customTranslate' +}) +export class CustomTranslatePipe implements PipeTransform { + + constructor(private utils: UtilsService) { } + + transform(translationValue: string, defaultValue?: string): string { + if (!defaultValue) { + defaultValue = translationValue; + } + return this.utils.customTranslation(translationValue, defaultValue); + } +} diff --git a/ui-ngx/src/app/shared/pipe/key-value-not-empty.pipe.ts b/ui-ngx/src/app/shared/pipe/key-value-not-empty.pipe.ts new file mode 100644 index 0000000000..08afba9d81 --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/key-value-not-empty.pipe.ts @@ -0,0 +1,67 @@ +/// +/// 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 { + inject, + KeyValueChangeRecord, + KeyValueChanges, + KeyValueDiffer, + KeyValueDiffers, + Pipe, + PipeTransform +} from '@angular/core'; +import { KeyValue } from '@angular/common'; +import { isDefinedAndNotNull } from '@core/utils'; + +@Pipe({ + name: 'keyValueIsNotEmpty', + pure: false, + standalone: true, +}) +export class KeyValueIsNotEmptyPipe implements PipeTransform { + private differs: KeyValueDiffers = inject(KeyValueDiffers); + private differ!: KeyValueDiffer; + private keyValues: Array> = []; + + // This is a custom implementation of angular keyvalue pipe + // https://github.com/angular/angular/blob/main/packages/common/src/pipes/keyvalue_pipe.ts + transform( + input: Record, + ): Array> { + if (!input || (!(input instanceof Map) && typeof input !== 'object')) { + return null; + } + + this.differ ??= this.differs.find(input).create(); + + const differChanges: KeyValueChanges | null = this.differ.diff(input); + + if (differChanges) { + this.keyValues = []; + differChanges.forEachItem((r: KeyValueChangeRecord) => { + if (isDefinedAndNotNull(r.currentValue)) { + this.keyValues.push(this.makeKeyValuePair(r.key, r.currentValue!)); + } + }); + } + + return this.keyValues; + } + + private makeKeyValuePair(key: string, value: unknown): KeyValue { + return {key, value}; + } +} diff --git a/ui-ngx/src/app/shared/public-api.ts b/ui-ngx/src/app/shared/public-api.ts index bd3d565da3..1f38b7f70c 100644 --- a/ui-ngx/src/app/shared/public-api.ts +++ b/ui-ngx/src/app/shared/public-api.ts @@ -19,3 +19,4 @@ export * from './decorators/public-api'; export * from './models/public-api'; export * from './pipe/public-api'; export * from './shared.module'; +export * from './directives/public-api'; diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 567b80ed83..3d4edacd1d 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -67,6 +67,7 @@ import { ColorPickerModule } from '@iplab/ngx-color-picker'; import { NgxHmCarouselModule } from 'ngx-hm-carousel'; import { EditorModule, TINYMCE_SCRIPT_SRC } from '@tinymce/tinymce-angular'; import { UserMenuComponent } from '@shared/components/user-menu.component'; +import { TruncateWithTooltipDirective } from '@shared/directives/truncate-with-tooltip.directive'; import { NospacePipe } from '@shared/pipe/nospace.pipe'; import { TranslateModule } from '@ngx-translate/core'; import { TbCheckboxComponent } from '@shared/components/tb-checkbox.component'; @@ -219,6 +220,8 @@ import { ImageGalleryDialogComponent } from '@shared/components/image/image-gall import { RuleChainSelectPanelComponent } from '@shared/components/rule-chain/rule-chain-select-panel.component'; import { WidgetButtonComponent } from '@shared/components/button/widget-button.component'; import { HexInputComponent } from '@shared/components/color-picker/hex-input.component'; +import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; +import { ScadaSymbolInputComponent } from '@shared/components/image/scada-symbol-input.component'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -237,6 +240,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) SafePipe, ShortNumberPipe, ImagePipe, + CustomTranslatePipe, { provide: FlowInjectionToken, useValue: Flow @@ -353,6 +357,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) NavTreeComponent, LedLightComponent, MarkdownEditorComponent, + TruncateWithTooltipDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -362,6 +367,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) FileSizePipe, DateAgoPipe, ImagePipe, + CustomTranslatePipe, SafePipe, ShortNumberPipe, SelectableColumnsPipe, @@ -418,7 +424,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) EmbedImageDialogComponent, ImageGalleryDialogComponent, WidgetButtonComponent, - HexInputComponent + HexInputComponent, + ScadaSymbolInputComponent ], imports: [ CommonModule, @@ -606,6 +613,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) NavTreeComponent, LedLightComponent, MarkdownEditorComponent, + TruncateWithTooltipDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -616,6 +624,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) FileSizePipe, DateAgoPipe, ImagePipe, + CustomTranslatePipe, SafePipe, ShortNumberPipe, SelectableColumnsPipe, @@ -671,7 +680,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) MultipleGalleryImageInputComponent, EmbedImageDialogComponent, ImageGalleryDialogComponent, - WidgetButtonComponent + WidgetButtonComponent, + ScadaSymbolInputComponent ] }) export class SharedModule { } diff --git a/ui-ngx/src/assets/help/en_US/rulenode/common_node_fields_templatization.md b/ui-ngx/src/assets/help/en_US/rulenode/common_node_fields_templatization.md index 6779128c89..0542e592d3 100644 --- a/ui-ngx/src/assets/help/en_US/rulenode/common_node_fields_templatization.md +++ b/ui-ngx/src/assets/help/en_US/rulenode/common_node_fields_templatization.md @@ -1,4 +1,4 @@ Fields templatization feature allows you to process the incoming messages with dynamic configuration by substitution of templates specified in the configuration fields with values from message or message metadata. -For more detailed information, please refer to the ThingsBoard [documentation](${siteBaseUrl}/docs${docPlatformPrefix}/user-guide/templatization/) +For more detailed information, please refer to the ThingsBoard [documentation{:target="_blank"}](${siteBaseUrl}/docs${docPlatformPrefix}/user-guide/templatization/) diff --git a/ui-ngx/src/assets/help/en_US/rulenode/node-templatization-doc.md b/ui-ngx/src/assets/help/en_US/rulenode/node-templatization-doc.md new file mode 100644 index 0000000000..9e21a0bcb5 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/node-templatization-doc.md @@ -0,0 +1,83 @@ +# Rule nodes fields templatization + +Templatization is the process of using predefined templates to dynamically insert or substitute values into text. +These templates serve as placeholders for variables that can be filled in later with actual data. + +In the context of rule engine, templates are used to extract data from incoming messages during runtime. +This is particularly helpful in the rule node configuration, where templatization allows for dynamic configuration by replacing static values in the configuration fields with real-time values from the incoming messages. +This enables more flexible and automated handling of data, making it easier to perform conditional operations based on varying inputs. + +## Syntax + +Templates start with a dollar sign (`$`), followed by brackets with a key name inside. +Square brackets (`[]`) are used for message keys, while curly brackets (`{}`) are used for message metadata keys. +For example: +- `$[messageKey]` - will extract value of `messageKey` from incoming message. +- `${metadataKey}` - will extract value of `metadataKey` from incoming message metadata. + +In the example above, `messageKey` and `metadataKey` represent any key name that may exist within the message or its metadata. + +## Example + +Let's review an example. First JSON is message, second is message metadata: + +```json +{ + "temperature": 26.5, + "humidity": 75.2, + "soilMoisture": 28.9, + "windSpeed": 26.2, + "location": "riverside" +} +``` +```json +{ + "deviceType": "weather_sensor", + "deviceName": "weather1", + "ts": "1685379440000" +} +``` + +Assume, we detected an unusually high wind speed and want to send this telemetry reading to some external REST API. +Every reading needs to be associated with specific device and location - this information is available only in real-time. +We can use templates extract necessary data and to construct URL for sending data: + +`example-base-url.com/report-reading?location=$[location]&deviceName=${deviceName}` + +This template will be resolved to: + +`example-base-url.com/report-reading?location=riverside&deviceName=weather1` + +Templates are ideal for scenarios where the specific values aren't known at the time of configuration but will become available at runtime. + +## Notes + +- Templates can be combined with regular text. For example: "Fuel tanks are filled to `$[fuelLevel]`%". +- You can access nested keys in JSON object using dot notation: `$[object.key]`. +- If specified key is missing or value associated with that key is an object or an array, then template string will be returned unchanged. + +To illustrate written above let's review an example. Here's content of a message: +```json +{ + "number": 123.45, + "string": "text", + "boolean": true, + "array": [1, 2, 3], + "object": { + "property": "propertyValue" + }, + "null": null +} +``` +Here's a table with comparison between templates and extracted values: + +| **Template** | **Extracted value** | +|--------------------|---------------------| +| $[number] | 123.45 | +| $[string] | text | +| $[boolean] | true | +| $[array] | $[array] | +| $[object] | $[object] | +| $[object.property] | propertyValue | +| $[null] | null | +| $[doesNotExist] | $[doesNotExist] | diff --git a/ui-ngx/src/assets/help/en_US/scada/symbol_state_render_fn.md b/ui-ngx/src/assets/help/en_US/scada/symbol_state_render_fn.md new file mode 100644 index 0000000000..bdb0d45234 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/scada/symbol_state_render_fn.md @@ -0,0 +1,27 @@ +#### Symbol state render function + +
+
+ +*function (ctx, svg): void* + +A JavaScript function used to render SCADA symbol state. + +**Parameters:** + +
    +
  • ctx: ScadaSymbolContext - Context of the SCADA symbol. +
  • +
  • svg: SVG.Svg - A root svg node. Instance of SVG.Svg. +
  • +
+ +
+ +##### Examples + +
+ +TODO + + diff --git a/ui-ngx/src/assets/help/en_US/scada/tag_click_action_fn.md b/ui-ngx/src/assets/help/en_US/scada/tag_click_action_fn.md new file mode 100644 index 0000000000..26ec4f9bb3 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/scada/tag_click_action_fn.md @@ -0,0 +1,29 @@ +#### Symbol tag click action function + +
+
+ +*function (ctx, element, event): void* + +A JavaScript function invoked when user clicks on SVG element with specific tag. + +**Parameters:** + +
    +
  • ctx: ScadaSymbolContext - Context of the SCADA symbol. +
  • +
  • element: Element - SVG element.
    + See Manipulating section to manipulate the element.
    + See Animating section to animate the element. +
  • +
  • event: Event - DOM event. +
  • +
+ +
+ +##### Examples + +
+ +TODO diff --git a/ui-ngx/src/assets/help/en_US/scada/tag_state_render_fn.md b/ui-ngx/src/assets/help/en_US/scada/tag_state_render_fn.md new file mode 100644 index 0000000000..caed8060d4 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/scada/tag_state_render_fn.md @@ -0,0 +1,27 @@ +#### Symbol tag state render function + +
+
+ +*function (ctx, element): void* + +A JavaScript function used to render SCADA symbol element with specific tag. + +**Parameters:** + +
    +
  • ctx: ScadaSymbolContext - Context of the SCADA symbol. +
  • +
  • element: Element - SVG element.
    + See Manipulating section to manipulate the element.
    + See Animating section to animate the element. +
  • +
+ +
+ +##### Examples + +
+ +TODO diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/gateway/attributes-identifier_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/attributes-identifier_fn.md new file mode 100644 index 0000000000..7989fd7357 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/attributes-identifier_fn.md @@ -0,0 +1,54 @@ +An **Identifier** type is a unique ID assigned to a node within the OPC-UA server. It is used to directly reference specific nodes without navigating through the namespace hierarchy. +The **Identifier** type in the OPC-UA connector configuration can be used in various forms to uniquely reference nodes in the OPC-UA server's address space. Identifiers can be of different types, such as numeric (i), string (s), byte string (b), and GUID (g). Below is an explanation of each identifier type with examples. + +- ##### Numeric Identifier (`i`) + A **numeric identifier** uses an integer value to uniquely reference a node in the OPC-UA server. + ###### Example: + Gateway expects that the node exist and the value of “**ns=2;i=1235**” node is **21.34**. + + _Expression:_ + + **`${ns=2;i=1235}`** + + _Converted data:_ + + **`21.34`** + +- ##### String Identifier (`s`) + A **string identifier** uses a string value to uniquely reference a node in the OPC-UA server. + ###### Example: + Gateway expects that the node exist and the value of “**ns=3;s=TemperatureSensor**” node is **21.34**. + + _Expression:_ + + **`${ns=3;s=TemperatureSensor}`** + + _Converted data:_ + + **`21.34`** + +- ##### Byte String Identifier (`b`) + A **byte string identifier** uses a byte string to uniquely reference a node in the OPC-UA server. This is useful for binary data that can be converted to a byte string. + ###### Example: + Gateway expects that the node exist and the value of “**ns=4;b=Q2xpZW50RGF0YQ==**” node is **21.34**. + + _Expression:_ + + **`${ns=4;b=Q2xpZW50RGF0YQ==}`** + + _Converted data:_ + + **`21.34`** + +- ##### GUID Identifier (`g`) + A **GUID identifier** uses a globally unique identifier (GUID) to uniquely reference a node in the OPC-UA server. + ###### Example: + Gateway expects that the node exist and the value of “**ns=1;g=550e8400-e29b-41d4-a716-446655440000**” node is **21.34**. + + _Expression:_ + + **`${ns=1;g=550e8400-e29b-41d4-a716-446655440000}`** + + _Converted data:_ + + **`21.34`** diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/gateway/attributes-path_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/attributes-path_fn.md new file mode 100644 index 0000000000..48ae478273 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/attributes-path_fn.md @@ -0,0 +1,36 @@ +A **Path** type refers to the hierarchical address within the OPC-UA server's namespace. It is used to navigate to specific +nodes in the server. + +The path for the attribute value can be absolute or relative. + +### Absolute Path +An **absolute path** specifies the full hierarchical address from the root of the OPC-UA server's namespace to the target node. + +###### Example: +Gateway expects that the node exist and the value of Root\\.Objects\\.TempSensor\\.Temperature is 23.54. + +_Expression:_ + +**`${Root\.Objects\.TempSensor\.Temperature}`** + +_Converted data:_ + +**`23.54`** + +### Relative Path +A **relative path** specifies the address relative to a predefined starting point in the OPC-UA server's namespace. +###### Example: +Gateway expects that the node exist and the value of “Root\\.Objects\\.TempSensor\\.Temperature” is 23.56. + +_Expression:_ + +**`${Temperature}`** + +_Converted data:_ + +**`23.56`** + +Additionally, you can use the node browser name to ensure that your path cannot be altered by the user or server. + +###### Example: +**`Root\.0:Objects\.3:Simulation`** diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/gateway/attributes_updates-identifier_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/attributes_updates-identifier_fn.md new file mode 100644 index 0000000000..0ffb3d8cd1 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/attributes_updates-identifier_fn.md @@ -0,0 +1,46 @@ +An **Identifier** type is a unique ID assigned to a node within the OPC-UA server. It is used to directly reference +specific nodes without navigating through the namespace hierarchy. + +The **Identifier** type in the OPC-UA connector configuration can be used in various forms to uniquely reference nodes +in the OPC-UA server's address space. Identifiers can be of different types, such as numeric (`i`), string (`s`), +byte string (`b`), and GUID (`g`). Below is an explanation of each identifier type with examples. + +- ##### Numeric Identifier (`i`) + A **numeric identifier** uses an integer value to uniquely reference a node in the OPC-UA server. + + ###### Example: + Gateway expects that the node exist. + + _Expression:_ + + **`ns=2;i=1236`** + +- ##### String Identifier (`s`) + A **string identifier** uses a string value to uniquely reference a node in the OPC-UA server. + + ###### Example: + Gateway expects that the node exist. + + _Expression:_ + + **`ns=3;s=TemperatureSensor`** + +- ##### Byte String Identifier (`b`) + A **byte string identifier** uses a byte string to uniquely reference a node in the OPC-UA server. This is useful for binary data that can be converted to a byte string. + + ###### Example: + Gateway expects that the node exist. + + _Expression:_ + + **`ns=4;b=Q2xpZW50RGF0YQ==`** + +- ##### GUID Identifier (`g`) + A **GUID identifier** uses a globally unique identifier (GUID) to uniquely reference a node in the OPC-UA server. + + ###### Example: + Gateway expects that the node exist. + + _Expression:_ + + **`ns=1;g=550e8400-e29b-41d4-a716-446655440000`** diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/gateway/attributes_updates-path_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/attributes_updates-path_fn.md new file mode 100644 index 0000000000..f710c0fc72 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/attributes_updates-path_fn.md @@ -0,0 +1,34 @@ +A **Path** type refers to the hierarchical address within the OPC-UA server's namespace. It is used to navigate to +specific nodes in the server. + +The path for device name can be absolute or relative. + +### Absolute Path +An **absolute path** specifies the full hierarchical address from the root of the OPC-UA server's namespace to +the target node. + +###### Example: +Gateway expects that the node exist and the value of **Root\\.Objects\\.TempSensor\\.Version** is “**1.0.3**”. + +_Expression:_ + +**`Root\.Objects\.TempSensor\.Version`** + +In this example, the attribute update request will write the received data to the configured node above. + +### Relative Path +A **relative path** specifies the address relative to a predefined starting point in the OPC-UA server's namespace. + +###### Example: +Gateway expects that the node exist and the value of **Root\\.Objects\\.TempSensor\\.Name** is “**TH-101**”. + +_Device Node expression:_ + +**`Root\.Objects\.TempSensor`** + +_Expression:_ + +**`.Version`** + +In this example, **the gateway will search for the child node "Name" in the device node (parent node) +"Root\\.Objects\\.TempSensor"** and will write received data on it. diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/gateway/device-node-identifier_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/device-node-identifier_fn.md new file mode 100644 index 0000000000..6ad440978e --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/device-node-identifier_fn.md @@ -0,0 +1,34 @@ +## Device Node Field + +### Identifier: + +Device Node field value is used to identify the node for the current device. + +An Identifier type is a unique ID assigned to a node within the OPC-UA server. It is used to directly reference specific nodes without navigating through the namespace hierarchy. +The Identifier type in the OPC-UA connector configuration can be used in various forms to uniquely reference nodes in the OPC-UA server's address space. Identifiers can be of different types, such as numeric (`i`), string (`s`), byte string (`b`), and GUID (`g`). Below is an explanation of each identifier type with examples. + +- ##### Numeric Identifier (`i`) +A numeric identifier uses an integer value to uniquely reference a node in the OPC-UA server. +###### Example: +`ns=2;i=1234` +In this example, `ns=2` specifies the namespace index `2`, and `i=1234` specifies the numeric identifier `1234` for the node. + +- ##### String Identifier (`s`) +A string identifier uses a string value to uniquely reference a node in the OPC-UA server. +###### Example: +`ns=3;s=TemperatureSensor` +Here, `ns=3` specifies the namespace index `3`, and `s=TemperatureSensor` specifies the string identifier for the node. + +- ##### Byte String Identifier (`b`) +An byte string identifier uses a byte string to uniquely reference a node in the OPC-UA server. This is useful for binary data that can be converted to a byte string. +###### Example: +`ns=4;b=Q2xpZW50RGF0YQ==` +In this example, `ns=4` specifies the namespace index `4`, and `b=Q2xpZW50RGF0YQ==` specifies the byte string identifier for the node (base64 encoded). + +- ##### GUID Identifier (`g`) +A GUID identifier uses a globally unique identifier (GUID) to uniquely reference a node in the OPC-UA server. +###### Example: +`ns=1;g=550e8400-e29b-41d4-a716-446655440000` +Here, `ns=1` specifies the namespace index `1`, and `g=550e8400-e29b-41d4-a716-446655440000` specifies the GUID for the node. + +By using these different identifier types, you can accurately and uniquely reference nodes in the OPC-UA server's address space, regardless of the format of the node identifiers. diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/gateway/device-node-path_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/device-node-path_fn.md new file mode 100644 index 0000000000..73dde417de --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/device-node-path_fn.md @@ -0,0 +1,20 @@ +## Device Node Field +### Path: + +Device Node field value is used to identify the node for the current device. The connector will use this node as the parent node of the device. + +A Path type refers to the hierarchical address within the OPC-UA server's namespace. It is used to navigate to specific nodes in the server. + +The path for Device node can be only absolute. + +An absolute path specifies the full hierarchical address from the root of the OPC-UA server's namespace to the target node. + +###### Examples: + +- `Root\.Objects\.TempSensor` + +In this example, the `Value` specifies the full path to the `TempSensor` node located in the `Objects` namespace, starting from the root. + +Additionally, you can use the node browser name to ensure that your path cannot be altered by the user or server. + +- `Root\.0:Objects\.3:Simulation` diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/gateway/modbus-functions-data-types_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/modbus-functions-data-types_fn.md new file mode 100644 index 0000000000..6a38f9220f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/modbus-functions-data-types_fn.md @@ -0,0 +1,33 @@ +# Modbus Functions + +The Modbus connector supports the following Modbus functions: + +| Modbus Function Code | Description | +|----------------------|----------------------------------| +| 1 | Read Coils | +| 2 | Read Discrete Inputs | +| 3 | Read Multiple Holding Registers | +| 4 | Read Input Registers | +| 5 | Write Coil | +| 6 | Write Register | +| 15 | Write Coils | +| 16 | Write Registers | + +## Data Types + +A list and description of the supported data types for reading/writing data: + +| Type | Function Code | Objects Count | Note | +|----------|---------------|---------------|----------------------------------------------------| +| string | 3-4 | 1-… | Read bytes from registers and decode it (‘UTF-8’ coding). | +| bytes | 3-4 | 1-… | Read bytes from registers. | +| bits | 1-4 | 1-… | Read coils. If the objects count is 1, result will be interpreted as a boolean. Otherwise, the result will be an array with bits. | +| 16int | 3-4 | 1 | Integer 16 bit. | +| 16uint | 3-4 | 1 | Unsigned integer 16 bit. | +| 16float | 3-4 | 1 | Float 16 bit. | +| 32int | 3-4 | 2 | Integer 32 bit. | +| 32uint | 3-4 | 2 | Unsigned integer 32 bit. | +| 32float | 3-4 | 2 | Float 32 bit. | +| 64int | 3-4 | 4 | Integer 64 bit. | +| 64uint | 3-4 | 4 | Unsigned integer 64 bit. | +| 64float | 3-4 | 4 | Float 64 bit. | diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/gateway/name-field-identifier_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/name-field-identifier_fn.md new file mode 100644 index 0000000000..0550afb4c1 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/name-field-identifier_fn.md @@ -0,0 +1,67 @@ +## Device Name Field + +Device name field is used for looking the device name in some variable. + +An **Identifier** type is a unique ID assigned to a node within the OPC-UA server. It is used to directly reference +specific nodes without navigating through the namespace hierarchy. + +**Identifier** type in the OPC-UA connector configuration can be used in various forms to uniquely reference nodes in +the OPC-UA server's address space. Identifiers can be of different types, such as numeric (`**i**`), string (`**s**`), +byte string (`**b**`), and GUID (`**g**`). Below is an explanation of each identifier type with examples. + +- ##### **Numeric Identifier (`i`)** + + A **numeric identifier** uses an integer value to uniquely reference a node in the OPC-UA server. + + ###### **Example:** + + Gateway expects that the node exist and the value of **ns=2;i=1234** node is “**TH-101**”. + + _Expression:_ + + **`Device ${ns=2;i=1234}`** + + In this example, created device on platform will have “**Device TH-101**” name. + +- ##### **String Identifier (`s`)** + + A **string identifier** uses a string value to uniquely reference a node in the OPC-UA server. + + ###### **Example:** + + Gateway expects that the node exist and the value of **ns=3;s=TemperatureSensor** node is “**TH-101**”. + + _Expression:_ + + **`Device ${ns=3;s=TemperatureSensor}`** + + In this example, created device on platform will have “**Device TH-101**” name. + +- ##### **Byte String Identifier (`b`)** + + A **byte string identifier** uses a byte string to uniquely reference a node in the OPC-UA server. + This is useful for binary data that can be converted to a byte string. + + ###### **Example:** + + Gateway expects that the node exist and the value of **ns=4;b=Q2xpZW50RGF0YQ==** node is “**TH-101**”. + + _Expression:_ + + **`Device ${ns=4;b=Q2xpZW50RGF0YQ==}`** + + In this example, created device on platform will have “**Device TH-101**” name. + +- ##### GUID Identifier (**`g`**) + + A **GUID identifier** uses a globally unique identifier (GUID) to uniquely reference a node in the OPC-UA server. + + ###### **Example:** + + Gateway expects that the node exist and the value of **ns=1;g=550e8400-e29b-41d4-a716-446655440000** node is “**TH-101**”. + + _Expression:_ + + **`Device ${ns=1;g=550e8400-e29b-41d4-a716-446655440000}`** + + In this example, created device on platform will have “**Device TH-101**” name. diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/gateway/name-field-path_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/name-field-path_fn.md new file mode 100644 index 0000000000..80e3a65192 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/name-field-path_fn.md @@ -0,0 +1,41 @@ +## Device Name Field + +Device name field is used for looking the device name in some variable. + +A **Path** type refers to the hierarchical address within the OPC-UA server's namespace. It is used to navigate to +specific nodes in the server. + +The path for device name can be absolute or relative. + +### Absolute Path +An **absolute path** specifies the full hierarchical address from the root of the OPC-UA server's namespace to the +target node. + +##### **Example:** + +Gateway expects that the node exist and the value of **Root\\.Objects\\.TempSensor\\.Name** is “**TH-101**”. + +_Expression:_ + +`**Device ${Root\.Objects\.TempSensor\.Name}**` + +In this example, created device on platform will have “**Device TH-101**” name. + +### Relative Path +A relative path specifies the address relative to a predefined starting point in the OPC-UA server's namespace. + +##### **Example:** + +Gateway expects that the node exist and the value of **Root\\.Objects\\.TempSensor\\.Name** is “**TH-101**”. + +_Device Node expression:_ + +`**Root\.Objects\.TempSensor**` + +_Expression:_ + +`**Device ${Name}**` + +In this example, **the gateway will search for the child node "Name" in the device node (parent node) +"Root\\.Objects\\.TempSensor"** and created device on the platform will be named "**Device TH-101**". + diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/gateway/profile-name-identifier_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/profile-name-identifier_fn.md new file mode 100644 index 0000000000..eb8f4e44bf --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/profile-name-identifier_fn.md @@ -0,0 +1,60 @@ +## Profile Name Field + +Profile name field is used for looking the device profile name in some variable. + +An **Identifier** type is a unique ID assigned to a node within the OPC-UA server. It is used to directly reference +specific nodes without navigating through the namespace hierarchy. + +The **Identifier** type in the OPC-UA connector configuration can be used in various forms to uniquely reference nodes +in the OPC-UA server's address space. Identifiers can be of different types, such as numeric (**`i`**), string (**`s`**), +byte string (**`b`**), and GUID (**`g`**). Below is an explanation of each identifier type with examples. + +- ##### Numeric Identifier (**`i`**) + A **numeric identifier** uses an integer value to uniquely reference a node in the OPC-UA server. + + ###### Example: + Gateway expects that the node exist and the value of **ns=2;i=1235** node is “**thermostat**”. + + _Expression:_ + + **`Device ${ns=2;i=1235}`** + + In this example, created device on platform will have “**thermostat**” profile name. + +- ##### String Identifier (**`s`**) + A **string identifier** uses a string value to uniquely reference a node in the OPC-UA server. + + ###### Example: + Gateway expects that the node exist and the value of **ns=3;s=TemperatureSensorType** node is “**thermostat**”. + + _Expression:_ + + **`Device ${ns=3;s=TemperatureSensorType}`** + + In this example, created device on platform will have “**thermostat**” profile name. + +- ##### Byte String Identifier (**`b`**) + A **byte string identifier** uses a byte string to uniquely reference a node in the OPC-UA server. This is useful for + binary data that can be converted to a byte string. + + ###### Example: + Gateway expects that the node exist and the value of **ns=4;b=Q2xpZW50RGF0YQ==** node is “**thermostat**”. + + _Expression:_ + + **`Device ${ns=4;b=Q2xpZW50RGF0YQ==}`** + + In this example, created device on platform will have “**thermostat**” profile name. + +- ##### GUID Identifier (**`g`**) + A **GUID identifier** uses a globally unique identifier (GUID) to uniquely reference a node in the OPC-UA server. + + ###### Example: + Gateway expects that the node exist and the value of **ns=1;g=550e8400-e29b-41d4-a716-446655440000** node is + “**thermostat**”. + + _Expression:_ + + **`Device ${ns=1;g=550e8400-e29b-41d4-a716-446655440000}`** + + In this example, created device on platform will have “**thermostat**” profile name. diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/gateway/profile-name-path_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/profile-name-path_fn.md new file mode 100644 index 0000000000..78fa2a028e --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/profile-name-path_fn.md @@ -0,0 +1,38 @@ +## Profile Name Field + +The profile name field is used for looking the device profile name in some variable. + +A **Path** type refers to the hierarchical address within the OPC-UA server's namespace. It is used to navigate to +specific nodes in the server. + +The path for device name can be absolute or relative. + +### Absolute Path +An absolute path specifies the full hierarchical address from the root of the OPC-UA server's namespace to the target +node. + +##### Example: +Gateway expects that the node exist and the value of **Root\\.Objects\\.TempSensor\\.Name** is “**thermostat**”. + +_Expression:_ + +**`Device ${Root\.Objects\.TempSensor\.Type}`** + +In this example, created device on platform will have “**thermostat**” profile name. + +### Relative Path +A relative path specifies the address relative to a predefined starting point in the OPC-UA server's namespace. + +##### Example: +Gateway expects that the node exist and the value of Root\\.Objects\\.TempSensor\\.Type is “thermostat”. + +_Device Node expression:_ + +**`Root\.Objects\.TempSensor`** + +_Expression:_ + +**`Device ${Type}`** + +In this example, **the gateway will search for the child node "Name" in the device node (parent node) +"Root\\.Objects\\.TempSensor"** and created device on the platform will have "thermostat" profile name. diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/gateway/timeseries-identifier_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/timeseries-identifier_fn.md new file mode 100644 index 0000000000..f144dcac52 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/timeseries-identifier_fn.md @@ -0,0 +1,62 @@ +An **Identifier** type is a unique ID assigned to a node within the OPC-UA server. It is used to directly reference +specific nodes without navigating through the namespace hierarchy. + +The **Identifier** type in the OPC-UA connector configuration can be used in various forms to uniquely reference nodes in +the OPC-UA server's address space. Identifiers can be of different types, such as numeric (`i`), string (`s`), +byte string (`b`), and GUID (`g`). Below is an explanation of each identifier type with examples. + +- ##### Numeric Identifier (`i`) + A **numeric identifier** uses an integer value to uniquely reference a node in the OPC-UA server. + + ###### Example: + Gateway expects that the node exist and the value of “**ns=2;i=1235**” node is **21.34**. + + _Expression:_ + + **`${ns=2;i=1235}`** + + _Converted data:_ + + **`21.34`** + +- ##### String Identifier (`s`) + A **string identifier** uses a string value to uniquely reference a node in the OPC-UA server. + + ###### Example: + Gateway expects that the node exist and the value of “**ns=3;s=TemperatureSensor**” node is **21.34**. + + _Expression:_ + + **`${ns=3;s=TemperatureSensor}`** + + _Converted data:_ + + **`21.34`** + +- ##### Byte String Identifier (`b`) + A **byte string identifier** uses a byte string to uniquely reference a node in the OPC-UA server. This is useful for binary data that can be converted to a byte string. + + ###### Example: + Gateway expects that the node exist and the value of “**ns=4;b=Q2xpZW50RGF0YQ==**” node is **21.34**. + + _Expression:_ + + **`${ns=4;b=Q2xpZW50RGF0YQ==}`** + + _Converted data:_ + + **`21.34`** + +- ##### GUID Identifier (`g`) + A **GUID identifier** uses a globally unique identifier (GUID) to uniquely reference a node in the OPC-UA server. + + ###### Example: + Gateway expects that the node exist and the value of “**ns=1;g=550e8400-e29b-41d4-a716-446655440000**” node is **21.34**. + + _Expression:_ + + **`${ns=1;g=550e8400-e29b-41d4-a716-446655440000}`** + + _Converted data:_ + + **`21.34`** diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/gateway/timeseries-path_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/timeseries-path_fn.md new file mode 100644 index 0000000000..bb2bf59c4f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/timeseries-path_fn.md @@ -0,0 +1,45 @@ +A **Path** type refers to the hierarchical address within the OPC-UA server's namespace. It is used to navigate to +specific nodes in the server. + +The path for the attribute value can be absolute or relative. + +### Absolute Path +An **absolute path** specifies the full hierarchical address from the root of the OPC-UA server's namespace to +the target node. + +###### Example: +Gateway expects that the node exist and the value of **Root\\.Objects\\.TempSensor\\.Temperature** is **23.54**. + +_Expression:_ + +**`${Root\.Objects\.TempSensor\.Temperature}`** + +_Converted data:_ + +**`23.54`** + +### Relative Path +A **relative path** specifies the address relative to a predefined starting point in the OPC-UA server's namespace. + +###### Example: +Gateway expects that the node exist and the value of “**Root\\.Objects\\.TempSensor\\.Temperature**” is **23.56**. + +_Device Node expression:_ + +**`Root\.Objects\.TempSensor`** + +_Expression:_ + +**`${Temperature}`** + +_Converted data:_ + +**`23.56`** + +In this example, **the gateway will search for the child node "Temperature" in the device node (parent node) +"Root\\.Objects\\.TempSensor"** and will send converted data to the device. + +Additionally, you can use the node browser name to ensure that your path cannot be altered by the user or server. + +###### Example: +**`Root\.0:Objects\.3:Simulation`** diff --git a/ui-ngx/src/assets/locale/locale.constant-ar_AE.json b/ui-ngx/src/assets/locale/locale.constant-ar_AE.json index 19b5113a24..3f148cab2d 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ar_AE.json +++ b/ui-ngx/src/assets/locale/locale.constant-ar_AE.json @@ -1283,7 +1283,6 @@ "maximum-upload-file-size": "الحد الأقصى لحجم الملف المرفوع: {{ size }}", "cannot-upload-file": "لا يمكن تحميل الملف", "settings": "الإعدادات", - "layout-settings": "إعدادات التخطيط", "columns-count": "عدد الأعمدة", "columns-count-required": "عدد الأعمدة مطلوب.", "min-columns-count-message": "الحد الأدنى المسموح به هو 10 أعمدة.", diff --git a/ui-ngx/src/assets/locale/locale.constant-ca_ES.json b/ui-ngx/src/assets/locale/locale.constant-ca_ES.json index e043a60025..f66f248303 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ca_ES.json +++ b/ui-ngx/src/assets/locale/locale.constant-ca_ES.json @@ -1056,7 +1056,6 @@ "maximum-upload-file-size": "Mida màxima del fitxer de pujada: {{ size }}", "cannot-upload-file": "No s'ha pogut pujar el fitxer", "settings": "Configuració", - "layout-settings": "Paràmetres de la disposició", "columns-count": "Número de columnes", "columns-count-required": "Cal número de columnes.", "min-columns-count-message": "Sol es permet un número mínim de 10 columnes.", diff --git a/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json b/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json index 57e19e9faa..3be2bfdd24 100644 --- a/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json +++ b/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json @@ -745,7 +745,6 @@ "maximum-upload-file-size": "Maximální velikost souboru: {{ size }}", "cannot-upload-file": "Soubor nelze nahrát", "settings": "Nastavení", - "layout-settings": "Nastavení rozložení", "columns-count": "Počet sloupců", "columns-count-required": "Počet sloupců je povinný.", "min-columns-count-message": "Minimální povolený počet sloupců je 10.", diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index b1b17ca3aa..b7e96b9faf 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -936,6 +936,7 @@ "type-relations-delete": "All relation deleted", "type-alarm-ack": "Acknowledged", "type-alarm-clear": "Cleared", + "type-alarm-delete": "Deleted", "type-alarm-assign": "Assigned", "type-alarm-unassign": "Unassigned", "type-added-comment": "Added comment", @@ -1163,11 +1164,21 @@ "maximum-upload-file-size": "Maximum upload file size: {{ size }}", "cannot-upload-file": "Cannot upload file", "settings": "Settings", - "layout-settings": "Layout settings", + "move-all-widgets": "Move all widgets", + "move-by": "Move by", + "cols": "cols", + "rows": "rows", + "layout": "Layout", + "layout-type-default": "Default", + "layout-type-scada": "SCADA", + "layout-type-divider": "Divider", + "layout-settings-type": "Layout settings: {{ type }}", "columns-count": "Columns count", "columns-count-required": "Columns count is required.", "min-columns-count-message": "Only 10 minimum column count is allowed.", "max-columns-count-message": "Only 1000 maximum column count is allowed.", + "min-layout-width": "Minimum layout width", + "columns-suffix": "columns", "widgets-margins": "Margin between widgets", "margin-required": "Margin value is required.", "min-margin-message": "Only 0 is allowed as minimum margin value.", @@ -1183,10 +1194,14 @@ "apply-outer-margin": "Apply margin to the sides of the layout", "autofill-height": "Auto fill layout height", "mobile-layout": "Mobile layout settings", - "mobile-row-height": "Mobile row height, px", + "mobile-row-height": "Mobile row height", "mobile-row-height-required": "Mobile row height value is required.", "min-mobile-row-height-message": "Only 5 pixels is allowed as minimum mobile row height value.", "max-mobile-row-height-message": "Only 200 pixels is allowed as maximum mobile row height value.", + "row-height": "Row height", + "row-height-required": "Row height value is required.", + "min-row-height-message": "Only 5 pixels is allowed as minimum row height value.", + "max-row-height-message": "Only 200 pixels is allowed as maximum row height value.", "display-first-in-mobile-view": "Display first in mobile view", "title-settings": "Title settings", "display-title": "Display dashboard title", @@ -1265,7 +1280,19 @@ "assign-dashboard-to-edge-text": "Please select the dashboards to assign to the edge", "non-existent-dashboard-state-error": "Dashboard state with id \"{{ stateId }}\" is not found", "edit-mode": "Edit mode", - "duplicate-state-action": "Duplicate state" + "duplicate-state-action": "Duplicate state", + "breakpoint-value": "Breakpoint ({{ value }})", + "breakpoints-id": { + "default": "Default", + "xs": "Mobile (xs)", + "sm": "Tablet (sm)", + "md": "Laptop (md)", + "lg": "Desktop (lg)", + "xl": "Desktop (xl)" + }, + "view-format-type-grid": "Grid", + "view-format-type-list": "List", + "view-format": "View format" }, "datakey": { "settings": "Settings", @@ -2283,6 +2310,12 @@ "type-edges": "Edges", "list-of-edges": "{ count, plural, =1 {One edge} other {List of # edges} }", "edge-name-starts-with": "Edges whose names start with '{{prefix}}'", + "version-conflict": { + "message": "Do you want to overwrite existing version or discard changes and load the latest version?", + "link": "You can download your version of the {{entityType}} using this", + "overwrite": "Overwrite version", + "discard": "Discard changes" + }, "type-tb-resource": "Resource", "type-tb-resources": "Resources", "list-of-tb-resources": "{ count, plural, =1 {One resource} other {List of # resources} }", @@ -2301,7 +2334,8 @@ "type-notification-request": "Notification request", "type-notification-template": "Notification template", "type-notification-templates": "Notification templates", - "list-of-notification-templates": "{ count, plural, =1 {One notification template} other {List of # notification templates} }" + "list-of-notification-templates": "{ count, plural, =1 {One notification template} other {List of # notification templates} }", + "link": "link" }, "entity-field": { "created-time": "Created time", @@ -2755,13 +2789,29 @@ "function": "Function" }, "gateway": { + "address": "Address", + "address-required": "Address required", "add-entry": "Add configuration", "add-attribute": "Add attribute", + "add-attribute-update": "Add attribute update", "add-key": "Add key", "add-timeseries": "Add time series", "add-mapping": "Add mapping", + "add-slave": "Add Slave", + "arguments": "Arguments", + "add-rpc-method": "Add method", + "add-rpc-request": "Add request", + "add-value": "Add argument", + "baudrate": "Baudrate", + "bytesize": "Bytesize", + "delete-value": "Delete value", + "delete-rpc-method": "Delete method", + "delete-rpc-request": "Delete request", + "delete-attribute-update": "Delete attribute update", "advanced": "Advanced", + "advanced-connection-settings": "Advanced connection settings", "attributes": "Attributes", + "attribute-updates": "Attribute updates", "attribute-filter": "Attribute filter", "attribute-filter-hint": "Filter for incoming attribute name from platform, supports regular expression.", "attribute-filter-required": "Attribute filter required.", @@ -2769,6 +2819,8 @@ "attribute-name-expression-required": "Attribute name expression required.", "attribute-name-expression-hint": "Hint for Attribute name expression", "basic": "Basic", + "byte-order": "Byte order", + "word-order": "Word order", "broker": { "connection": "Connection to broker", "name": "Broker name", @@ -2803,6 +2855,9 @@ "connectors-table-actions": "Actions", "connectors-table-key": "Key", "connectors-table-class": "Class", + "connection-timeout": "Connection timeout (s)", + "connect-attempt-time": "Connect attempt time (ms)", + "connect-attempt-count": "Connect attempt count", "copy-username": "Copy username", "copy-password": "Copy password", "copy-client-id": "Copy client ID", @@ -2824,14 +2879,15 @@ "name": "Name", "profile-name": "Profile name", "device-name-expression": "Device name expression", - "device-name-expression-required": "Device name expression required.", - "device-profile-expression-required": "Device profile expression required." + "device-name-expression-required": "Device name expression is required.", + "device-profile-expression-required": "Device profile expression is required." }, "device-name-filter": "Device name filter", "device-name-filter-hint": "This field supports Regular expressions to filter incoming data by device name.", "device-name-filter-required": "Device name filter is required.", "details": "Details", - "delete-mapping-title": "Delete mapping ?", + "delete-mapping-title": "Delete mapping?", + "delete-slave-title": "Delete slave?", "download-configuration-file": "Download configuration file", "download-docker-compose": "Download docker-compose.yml for your gateway", "enable-remote-logging": "Enable remote logging", @@ -2850,6 +2906,7 @@ "configuration-delete-dialog-confirm": "Turn Off", "connector-duplicate-name": "Connector with such name already exists.", "connector-side": "Connector side", + "client-communication-type": "Client communication type", "payload-type": "Payload type", "platform-side": "Platform side", "JSON": "JSON", @@ -2864,13 +2921,23 @@ "data-conversion": "Data conversion", "data-mapping": "Data mapping", "data-mapping-hint": "Data mapping provides the capability to parse and convert the data received from a MQTT client in incoming messages into specific attributes and time series data keys.", + "opcua-data-mapping-hint": "Data mapping provides the capability to parse and convert the data received from a OPCUA server into specific data keys.", "delete": "Delete configuration", "delete-attribute": "Delete attribute", "delete-key": "Delete key", "delete-timeseries": "Delete time series", "default": "Default", + "device-node": "Device node", + "device-node-required": "Device node required.", + "device-node-hint": "Path or identifier for device node on OPC UA server. Relative paths from it for attributes and time series can be used.", + "device-name": "Device name", + "device-profile": "Device profile", + "device-name-required": "Device name required", + "device-profile-required": "Device profile required", "download-tip": "Download configuration file", "drop-file": "Drop file here or", + "enable": "Enable", + "enable-subscription": "Enable subscription", "extension": "Extension", "extension-hint": "Put your converter classname in the field. Custom converter with such class should be in extension/mqtt folder.", "extension-required": "Extension is required.", @@ -2880,6 +2947,17 @@ "fill-connector-defaults-hint": "This property allows to fill connector configuration with default values on it's creation.", "from-device-request-settings": "Input request parsing", "from-device-request-settings-hint": "These fields support JSONPath expressions to extract a name from incoming message.", + "function-code": "Function code", + "function-codes": { + "read-coils": "01 - Read Coils", + "read-discrete-inputs": "02 - Read Discrete Inputs", + "read-multiple-holding-registers": "03 - Read Multiple Holding Registers", + "read-input-registers": "04 - Read Input Registers", + "write-single-coil": "05 - Write Single Coil", + "write-single-holding-register": "06 - Write Single Holding Register", + "write-multiple-coils": "15 - Write Multiple Coils", + "write-multiple-holding-registers": "16 - Write Multiple Holding Registers" + }, "to-device-response-settings": "Output request processing", "to-device-response-settings-hint": "For these fields you can use the following variables and they will be replaced with actual values: ${deviceName}, ${attributeKey}, ${attributeValue}", "gateway": "Gateway", @@ -2909,6 +2987,7 @@ "grpc-max-pings-without-data-required": "Max pings without data is required", "grpc-max-pings-without-data-min": "Max pings without data can not be less then 1", "grpc-max-pings-without-data-pattern": "Max pings without data is not valid", + "identity": "Identity", "inactivity-check-period-seconds": "Inactivity check period (in sec)", "inactivity-check-period-seconds-required": "Inactivity check period is required", "inactivity-check-period-seconds-min": "Inactivity check period can not be less then 1", @@ -2917,8 +2996,13 @@ "inactivity-timeout-seconds-required": "Inactivity timeout is required", "inactivity-timeout-seconds-min": "Inactivity timeout can not be less then 1", "inactivity-timeout-seconds-pattern": "Inactivity timeout is not valid", + "unit-id": "Unit ID", "host": "Host", "host-required": "Host is required.", + "holding_registers": "Holding registers", + "coils_initializer": "Coils initializer", + "input_registers": "Input registers", + "discrete_inputs": "Discrete inputs", "json-parse": "Not valid JSON.", "json-required": "Field cannot be empty.", "JSONPath-hint": "This field supports constants and JSONPath expressions.", @@ -2952,30 +3036,45 @@ "max-messages-queue-for-worker": "Max messages queue per worker", "max-messages-queue-for-worker-hint": "Maximal messages count that will be in the queue \nfor each converter worker.", "max-messages-queue-for-worker-required": "Max messages queue per worker is required.", + "method": "Method", + "method-name": "Method name", + "method-required": "Method name is required.", "min-pack-send-delay": "Min pack send delay (in ms)", "min-pack-send-delay-required": "Min pack send delay is required", - "min-pack-send-delay-min": "Min pack send delay can not be less then 0", + "min-pack-send-delay-min": "Min pack send delay can not be less then 10", + "min-pack-send-delay-pattern": "Min pack send delay is not valid", + "mode": "Mode", + "model-name": "Model name", "mqtt-version": "MQTT version", "name": "Name", "name-required": "Name is required.", "no-attributes": "No attributes", + "no-attribute-updates": "No attribute updates", "no-connectors": "No connectors", "no-data": "No configurations", "no-gateway-found": "No gateway found.", "no-gateway-matching": " '{{item}}' not found.", "no-timeseries": "No time series", "no-keys": "No keys", + "no-value": "No arguments", + "no-rpc-methods": "No RPC methods", + "no-rpc-requests": "No RPC requests", "path-hint": "The path is local to the gateway file system", "path-logs": "Path to log files", "path-logs-required": "Path is required.", "password": "Password", "password-required": "Password is required.", "permit-without-calls": "Keep alive permit without calls", + "poll-period": "Poll period (ms)", + "poll-period-error": "Poll period should be at least {{min}} (ms).", "port": "Port", "port-required": "Port is required.", "port-limits-error": "Port should be number from {{min}} to {{max}}.", "private-key-path": "Path to private key file", "path-to-private-key-required": "Path to private key file is required.", + "parity": "Parity", + "product-code": "Product code", + "product-name": "Product name", "raw": "Raw", "retain": "Retain", "retain-hint": "This flag tells the broker to store the message for a topic\nand ensures any new client subscribing to that topic\nwill receive the stored message.", @@ -2984,6 +3083,9 @@ "remove-entry": "Remove configuration", "remote-shell": "Remote shell", "remote-configuration": "Remote Configuration", + "retries": "Retries", + "retries-on-empty": "Retries on empty", + "retries-on-invalid": "Retries on invalid", "rpc": { "title": "{{type}} Connector RPC parameters", "templates-title": "Connector RPC Templates", @@ -3064,16 +3166,10 @@ "template-name-duplicate": "Template with such name already exists, it will be updated.", "command": "Command", "params": "Params", - "read-coils": "01: Read Coils", - "read-discrete-inputs": "02: Read Discrete Inputs", - "read-multiple-holding-registers": "03: Read Multiple Holding Registers", - "read-input-registers": "04: Read Input Registers", - "write-single-coil": "05: Write Single Coil", - "write-single-holding-register": "06: Write Single Holding Register", - "write-multiple-coils": "15: Write Multiple Coils", - "write-multiple-holding-registers": "16: Write Multiple Holding Registers", "json-value-invalid": "JSON value has an invalid format" }, + "rpc-methods": "RPC methods", + "rpc-requests": "RPC requests", "request" : { "connect-request": "Connect request", "disconnect-request": "Disconnect request", @@ -3085,6 +3181,7 @@ "requests-mapping": "Requests mapping", "requests-mapping-hint": "MQTT Connector requests allows you to connect, disconnect, process attribute requests from the device, handle attribute updates on the server and RPC processing configuration.", "request-topic-expression": "Request topic expression", + "request-client-certificate": "Request client certificate", "request-topic-expression-required": "Request topic expression is required.", "response-timeout": "Response timeout (ms)", "response-timeout-required": "Response timeout is required.", @@ -3095,7 +3192,10 @@ "response-topic-expression-required": "Response topic expression is required.", "response-value-expression": "Response value expression", "response-value-expression-required": "Response value expression is required.", + "vendor-name": "Vendor name", + "vendor-url": "Vendor URL", "value": "Value", + "values": "Values", "value-required": "Value is required.", "value-expression": "Value expression", "value-expression-required": "Value expression is required.", @@ -3103,6 +3203,10 @@ "without-response": "Without response", "other": "Other", "save-tip": "Save configuration file", + "scan-period": "Scan period (ms)", + "scan-period-error": "Scan period should be at least {{min}} (ms).", + "sub-check-period": "Subscription check period (ms)", + "sub-check-period-error": "Subscription check period should be at least {{min}} (ms).", "security": "Security", "security-type": "Security type", "security-types": { @@ -3114,19 +3218,42 @@ }, "select-connector": "Select connector to display config", "send-change-data": "Send data only on change", + "send-data-TB": "Send data to ThingsBoard", + "send-data-on-change": "Send data only on change", "send-change-data-hint": "The values will be saved to the database only if they are different from the corresponding values in the previous converted message. This functionality applies to both attributes and time series in the converter output.", + "server": "Server", + "server-hostname": "Server hostname", + "server-slave": "Server (Slave)", + "servers-slaves": "Servers (Slaves)", "server-port": "Server port", + "server-url": "Server endpoint url", + "server-connection": "Server Connection", + "server-config": "Server configuration", + "server-slave-config": "Server (Slave) configuration", + "server-url-required": "Server endpoint url is required.", + "stopbits": "Stopbits", + "strict": "Strict", "set": "Set", + "show-map": "Show map", "statistics": { "statistic": "Statistic", "statistics": "Statistics", "statistic-commands-empty": "No configured statistic keys found. You can configure them in \"Statistics\" tab in general configuration.", + "statistics-button": "Go to configuration", "commands": "Commands", "send-period": "Statistic send period (in sec)", "send-period-required": "Statistic send period is required", "send-period-min": "Statistic send period can not be less then 60", "send-period-pattern": "Statistic send period is not valid", "check-connectors-configuration": "Check connectors configuration (in sec)", + "max-payload-size-bytes": "Max payload size in bytes", + "max-payload-size-bytes-required": "Max payload size in bytes is required", + "max-payload-size-bytes-min": "Max payload size in bytes can not be less then 100", + "max-payload-size-bytes-pattern": "Max payload size in bytes is not valid", + "min-pack-size-to-send": "Min packet size to send", + "min-pack-size-to-send-required": "Min packet size to send is required", + "min-pack-size-to-send-min": "Min packet size to send can not be less then 100", + "min-pack-size-to-send-pattern": "Min packet size to send is not valid", "check-connectors-configuration-required": "Check connectors configuration is required", "check-connectors-configuration-min": "Check connectors configuration can not be less then 1", "check-connectors-configuration-pattern": "Check connectors configuration is not valid", @@ -3178,7 +3305,9 @@ "source-type": { "msg": "Extract from message", "topic": "Extract from topic", - "const": "Constant" + "const": "Constant", + "identifier": "Identifier", + "path": "Path" }, "workers-settings": "Workers settings", "thingsboard": "ThingsBoard", @@ -3196,12 +3325,16 @@ "thingsboard-port-required": "Port is required.", "tidy": "Tidy", "tidy-tip": "Tidy config JSON", + "timeout": "Timeout (ms)", + "timeout-error": "Timeout should be at least {{min}} (ms).", "title-connectors-json": "Connector {{typeName}} configuration", "type": "Type", "topic-filter": "Topic filter", "topic-required": "Topic filter is required.", "tls-path-ca-certificate": "Path to CA certificate on gateway", "tls-path-client-certificate": "Path to client certificate on gateway", + "tls-connection": "TLS Connection", + "master-connections": "Master Connections", "method-filter": "Method filter", "method-filter-hint": "Regular expression to filter incoming RPC method from platform.", "method-filter-required": "Method filter is required.", @@ -3221,13 +3354,23 @@ "at-least-once": "1 - At least once", "exactly-once": "2 - Exactly once" }, + "objects-count": "Objects count", + "objects-count-required": "Objects count is required", + "wait-after-failed-attempts": "Wait after failed attempts (ms)", "tls-path-private-key": "Path to private key on gateway", "toggle-fullscreen": "Toggle fullscreen", "transformer-json-config": "Configuration JSON*", "update-config": "Add/update configuration JSON", "username": "Username", "username-required": "Username is required.", + "unit-id-required": "Unit ID is required.", + "write-coil": "Write Coil", + "write-coils": "Write Coils", + "write-register": "Write Register", + "write-registers": "Write Registers", "hints": { + "modbus-master": "Configuration sections for connecting to Modbus servers and reading data from them.", + "modbus-server": "Configuration section for the Modbus server, storing data and sending updates to the platform when changes occur or at fixed intervals.", "remote-configuration": "Enables remote configuration and management of the gateway", "remote-shell": "Enables remote control of the operating system with the gateway from the Remote Shell widget", "host": "Hostname or IP address of platform server", @@ -3266,9 +3409,46 @@ "grpc-max-pings-without-data": "Maximum number of keepalive ping messages that the server can send without receiving any data before it considers the connection dead.", "grpc-min-ping-interval-without-data": "Minimum amount of time the server should wait between sending keepalive ping messages when there is no data being sent or received.", "permit-without-calls": "Allow server to keep the GRPC connection alive even when there are no active RPC calls.", + "path-in-os": "Path in gateway os.", "memory": "Your data will be stored in the in-memory queue, it is a fastest but no persistence guarantee.", "file": "Your data will be stored in separated files and will be saved even after the gateway restart.", - "sqlite": "Your data will be stored in file based database. And will be saved even after the gateway restart." + "sqlite": "Your data will be stored in file based database. And will be saved even after the gateway restart.", + "opc-timeout": "Timeout in milliseconds for connecting to OPC-UA server.", + "scan-period": "Period in milliseconds to rescan the server.", + "sub-check-period": "Period to check the subscriptions in the OPC-UA server.", + "enable-subscription": "If true - the gateway will subscribe to interesting nodes and wait for data update and if false - the gateway will rescan OPC-UA server every scanPeriodInMillis.", + "show-map": "Show nodes on scanning.", + "method-name": "Name of method on OPC-UA server.", + "arguments": "Arguments for the method (will be overwritten by arguments from the RPC request).", + "min-pack-size-to-send": "Minimum package size for sending.", + "max-payload-size-bytes": "Maximum package size in bytes", + "poll-period": "Period in milliseconds to read data from nodes.", + "modbus": { + "framer-type": "Type of a framer (Socket, RTU, or ASCII), if needed.", + "host": "Hostname or IP address of Modbus server.", + "port": "Modbus server port for connection.", + "unit-id": "Modbus slave ID.", + "connection-timeout": "Connection timeout (in seconds) for the Modbus server.", + "byte-order": "Byte order for reading data.", + "word-order": "Word order when reading multiple registers.", + "retries": "Retrying data transmission to the master. Acceptable values: true or false.", + "retries-on-empty": "Retry sending data to the master if the data is empty.", + "retries-on-invalid": "Retry sending data to the master if it fails.", + "poll-period": "Period in milliseconds to check attributes and telemetry on the slave.", + "connect-attempt-time": "A waiting period in milliseconds before establishing a connection to the master.", + "connect-attempt-count": "The number of connection attempts made through the gateway.", + "wait-after-failed-attempts": "A waiting period in milliseconds before attempting to send data to the master.", + "serial-port": "Serial port for connection.", + "baudrate": "Baud rate for the serial device.", + "stopbits": "The number of stop bits sent after each character in a message to indicate the end of the byte.", + "bytesize": "The number of bits in a byte of serial data. This can be one of 5, 6, 7, or 8.", + "parity": "The type of checksum used to verify data integrity. Options: (E)ven, (O)dd, (N)one.", + "strict": "Use inter-character timeout for baudrates ≤ 19200.", + "objects-count": "Depends on the selected type.", + "address": "Register address to verify.", + "key": "Key to be used as the attribute key for the platform instance.", + "data-keys": "For more information about function codes and data types click on help icon" + } } }, "grid": { @@ -3432,6 +3612,212 @@ "error-entities": "There was an error creating {{count}} entities." } }, + "scada": { + "symbols": "SCADA symbols", + "search": "Search symbol", + "selected-symbols": "{ count, plural, =1 {1 symbol} other {# symbols} } selected", + "download-symbol": "Download SCADA symbol", + "export-symbol": "Export SCADA symbol to JSON", + "import-symbol": "Import SCADA symbol from JSON", + "upload-symbol": "Upload SCADA symbol", + "update-symbol": "Update SCADA symbol", + "edit-symbol": "Edit SCADA symbol", + "symbol-details": "SCADA symbol details", + "no-symbols": "No symbols found", + "show-hidden-elements": "Show hidden elements", + "hide-hidden-elements": "Hide hidden elements", + "delete-symbol": "Delete SCADA symbol", + "delete-symbol-title": "Are you sure you want to delete SCADA symbol '{{imageTitle}}'?", + "delete-symbol-text": "Be careful, after the confirmation SCADA symbol will become unrecoverable.", + "delete-symbols-title": "Are you sure you want to delete { count, plural, =1 {1 SCADA symbol} other {# SCADA symbols} }?", + "delete-symbols-text": "Be careful, after the confirmation all selected SCADA symbols will be removed and all related data will become unrecoverable.", + "include-system-symbols": "Include system symbols", + "symbol-preview": "Symbol preview", + "general": "General", + "tags": "Tags", + "properties": "Properties", + "title": "Title", + "description": "Description", + "search-tags": "Search tags", + "widget-size": "Widget size", + "cols": "cols", + "rows": "rows", + "state-render-function": "State render function", + "preview": "Preview", + "preview-widget-action-text": "Widget action '{{type}}' successfully invoked!", + "no-symbol": "No SCADA symbol", + "no-symbol-selected": "No SCADA symbol selected", + "clear-symbol": "Clear SCADA symbol", + "browse-symbol-from-gallery": "Browse SCADA symbol from gallery", + "zoom-in": "Zoom In", + "zoom-out": "Zoom Out", + "create-widget": "Create widget", + "create-widget-from-symbol": "Create widget from SCADA symbol", + "hidden": "hidden", + "tag": { + "tag": "Tag", + "on-click-action": "On click action", + "no-tags": "No tags configured", + "delete-tag-text": "Are you sure you want to delete tag
{{tag}} from {{elementType}} element?", + "update-tag": "Update tag", + "enter-tag": "Enter tag", + "tag-settings": "Tag settings", + "remove-tag": "Remove tag", + "add-tag": "Add tag" + }, + "behavior": { + "behavior": "Behavior", + "id": "Id", + "name": "Name", + "type": "Type", + "no-behaviors": "No behaviors configured", + "add-behavior": "Add behavior", + "type-action": "Action", + "type-value": "Value", + "type-widget-action": "Widget action", + "behavior-settings": "Behavior settings", + "remove-behavior": "Remove behavior", + "hint": "Hint", + "group-title": "Group title", + "value-type": "Value type", + "default-value": "Default value", + "true-label": "True label", + "false-label": "False label", + "state-label": "State label", + "default-payload": "Default payload", + "not-unique-behavior-ids-error": "Behavior Ids must be unique!", + "default-settings": "Default settings" + }, + "property": { + "property": "Property", + "id": "Id", + "name": "Name", + "type": "Type", + "type-text": "Text", + "type-number": "Number", + "type-switch": "Switch", + "type-color": "Color", + "type-color-settings": "Color settings", + "type-font": "Font", + "type-units": "Units", + "no-properties": "No properties configured", + "add-property": "Add property", + "property-settings": "Property settings", + "remove-property": "Remove property", + "default-value": "Default value", + "value-required": "Value required", + "number-settings": "Number settings", + "min": "Min", + "max": "Max", + "step": "Step", + "advanced-ui-settings": "Advanced UI settings", + "disable-on-property": "Disable on property", + "sub-label": "Sub label", + "vertical-divider-after": "Vertical divider after", + "input-field-suffix": "Input field suffix", + "property-row-classes": "Property row classes", + "property-field-classes": "Property field classes", + "not-unique-property-ids-error": "Property Ids must be unique!" + }, + "symbol": { + "symbol": "SCADA symbol", + "fluid-presence": "Fluid presence", + "fluid-presence-hint": "Indicates whether fluid is present in pipe.", + "fluid-present": "Fluid present", + "present": "Present", + "absent": "Absent", + "flow-presence": "Flow presence", + "flow-presence-hint": "Indicates whether fluid is flowing in pipe.", + "flow-present": "Flow present", + "flow-direction": "Flow direction", + "flow-direction-hint": "Indicates fluid flow direction.", + "forward": "Forward", + "reverse": "Reverse", + "flow-animation-speed": "Flow animation speed", + "flow-animation-speed-hint": "Double value indicating animation speed of the flow. 1 - normal speed, 0 - no animation, < 1 - slower animation, > 1 - faster animation.", + "leak": "Leak", + "leak-hint": "Indicates whether is leak present in pipe.", + "leak-present": "Leak present", + "fluid-color": "Fluid color", + "pipe-color": "Pipe color", + "horizontal-pipe": "Horizontal pipe", + "vertical-pipe": "Vertical pipe", + "horizontal-fluid-color": "Horizontal fluid color", + "vertical-fluid-color": "Vertical fluid color", + "left-pipe": "Left pipe", + "right-pipe": "Right pipe", + "top-pipe": "Top pipe", + "bottom-pipe": "Bottom pipe", + "left-fluid-color": "Left fluid color", + "right-fluid-color": "Right fluid color", + "top-fluid-color": "Top fluid color", + "bottom-fluid-color": "Bottom fluid color", + "display": "Display", + "value": "Value", + "units": "Units", + "flow-meter-value-hint": "Double value showing on flow meter display", + "running": "Running", + "running-hint": "Indicates whether component is in running state.", + "warning-state": "Warning state", + "warning": "Warning", + "warning-state-hint": "Indicates whether component is in warning state.", + "critical-state": "Critical state", + "critical": "Critical", + "critical-state-hint": "Indicates whether component is in critical state.", + "critical-state-animation": "Critical state animation", + "critical-state-animation-hint": "Whether to enable blinking animation when component is in critical state.", + "animation": "Animation", + "broken": "Broken", + "broken-hint": "Indicates whether component is broken.", + "on-display-click": "On display click", + "on-display-click-hint": "Action invoked when user clicks on the display.", + "pipe": "Pipe", + "default-border-color": "Default border color", + "active-border-color": "Active border color", + "warning-border-color": "Warning border color", + "critical-border-color": "Critical border color", + "background-color": "Background color", + "rotation-animation-speed": "Rotation animation speed", + "rotation-animation-speed-hint": "Double value indicating rotation animation speed. 1 - normal speed, 0 - no animation, < 1 - slower animation, > 1 - faster animation.", + "on-click": "On click", + "on-click-hint": "Action invoked when user clicks on the component.", + "right-top-connector": "Right top connector", + "right-bottom-connector": "Right bottom connector", + "left-top-connector": "Left top connector", + "left-bottom-connector": "Left bottom connector", + "top-left-connector": "Top left connector", + "top-right-connector": "Top right connector", + "running-color": "Running color", + "stopped-color": "Stopped color", + "warning-color": "Warning color", + "critical-color": "Critical color", + "opened": "Opened", + "opened-hint": "Indicates whether component is in opened state.", + "open": "Open", + "open-hint": "Action invoked when user clicks to open component.", + "close": "Close", + "close-hint": "Action invoked when user clicks to close component.", + "opened-color": "Opened color", + "closed-color": "Closed color", + "opened-rotation-angle": "Opened rotation angle", + "closed-rotation-angle": "Closed rotation angle", + "tank-capacity": "Tank capacity", + "tank-capacity-hint": "Double value indicating total tank capacity.", + "current-volume": "Current volume", + "current-volume-hint": "Double value indicating the current occupied volume.", + "tank-color": "Tank color", + "value-box": "Value box", + "value-text": "Value text", + "scale": "Scale", + "transparent-mode": "Transparent mode", + "major-ticks": "Major ticks", + "intervals": "Intervals", + "major-ticks-color": "Major ticks color", + "normal": "Normal", + "minor-ticks": "Minor ticks", + "minor-ticks-color": "Minor ticks color" + } + }, "item": { "selected": "Selected" }, @@ -3471,7 +3857,14 @@ "left-width-percentage-required": "Left percentage is required", "divider": "Divider", "right-side": "Right side layout", - "left-side": "Left side layout" + "left-side": "Left side layout", + "add-new-breakpoint": "Add new breakpoint", + "breakpoint": "Breakpoint", + "breakpoints": "Breakpoints", + "copy-from": "Copy from", + "size": "Size", + "delete-breakpoint-title": "Are you sure you want to delete the breakpoint '{{name}}'?", + "delete-breakpoint-text": "Please note, after confirmation, the breakpoint will become unrecoverable, and the settings will revert to the default breakpoint." }, "legend": { "direction": "Direction", @@ -5296,6 +5689,7 @@ "edit": "Edit widget", "remove-widget-title": "Are you sure you want to remove the widget '{{widgetTitle}}'?", "remove-widget-text": "After the confirmation the widget and all related data will become unrecoverable.", + "replace-reference-with-widget-copy": "Replace reference with widget copy", "timeseries": "Time series", "search-data": "Search data", "no-data-found": "No data found", @@ -5345,6 +5739,7 @@ "latest-data-key-settings-form-selector": "Latest data key settings form selector", "all": "All", "actual": "Actual", + "scada": "SCADA symbol", "deprecated": "Deprecated", "has-basic-mode": "Has basic mode", "basic-mode-form-selector": "Basic mode form selector", @@ -5375,10 +5770,12 @@ "search": "Search widget", "filter": "Widget filter type", "loading-widgets": "Loading widgets...", - "widget-template-error": "Invalid widget HTML template." + "widget-template-error": "Invalid widget HTML template.", + "reference": "Reference" }, "widget-action": { "header-button": "Widget header button", + "do-nothing": "Do nothing", "open-dashboard-state": "Navigate to new dashboard state", "update-dashboard-state": "Update current dashboard state", "open-dashboard": "Navigate to other dashboard", @@ -5453,6 +5850,7 @@ "title-max-length": "Title should be less than 256", "description": "Description", "image-preview": "Image preview", + "scada": "SCADA widgets bundle", "order": "Order", "add-widgets-bundle-text": "Add new widgets bundle", "no-widgets-bundles-text": "No widgets bundles found", @@ -5581,7 +5979,8 @@ "card-appearance": "Card appearance", "color": "Color", "tooltip": "Tooltip", - "units-required": "Unit is required." + "units-required": "Unit is required.", + "list-layout": "List layout" }, "widget-type": { "import": "Import widget type", @@ -5621,6 +6020,9 @@ "layout-default-volume": "Default.Volume", "layout-simplified-volume": "Simplified.Volume", "layout-outlined-volume": "Outlined.Volume", + "layout-default-icon": "Default.Icon", + "layout-simplified-icon": "Simplified.Icon", + "layout-outlined-icon": "Outlined.Icon", "main": "Main", "background": "Background", "power-on-colors": "Power 'On' colors", @@ -6237,7 +6639,8 @@ "min-and-max-value": "Min and max value", "min-and-max-label": "Min and max label", "font": "Font", - "tick-width-and-color": "Tick width and color" + "tick-width-and-color": "Tick width and color", + "min-max-validation-text": "Max value must be bigger than min value" }, "gpio": { "pin": "Pin", @@ -7001,6 +7404,23 @@ "bar-background": "Bar background", "progress-bar-card-style": "Progress bar card style" }, + "notification": { + "max-notification-display": "Maximum notifications to display", + "counter": "Counter", + "counter-hint": "Counter will be displayed if \"Widget title\" is enabled", + "icon": "Icon", + "counter-value": "Value", + "counter-color": "Color", + "notification-button": "Notification buttons", + "button-view-all": "View all", + "button-filter": "Filter", + "type-filter": "Type filter", + "button-mark-read": "Mark all as read", + "notification-types": "Notification types", + "notification-type": "Notification type", + "search-type": "Search type", + "any-type": "Any type" + }, "alarm-count": { "alarm-count-card-style": "Alarm count card style" }, diff --git a/ui-ngx/src/assets/locale/locale.constant-es_ES.json b/ui-ngx/src/assets/locale/locale.constant-es_ES.json index f5f62850b2..86539fef58 100644 --- a/ui-ngx/src/assets/locale/locale.constant-es_ES.json +++ b/ui-ngx/src/assets/locale/locale.constant-es_ES.json @@ -1066,7 +1066,6 @@ "maximum-upload-file-size": "Tamaño máximo de fichero: {{ size }}", "cannot-upload-file": "No es posible subir el fichero", "settings": "Ajustes", - "layout-settings": "Ajustes de diseño", "columns-count": "Número de columnas", "columns-count-required": "Número de columnas requerido.", "min-columns-count-message": "Solo se permite un número mínimo de 10 columnas.", diff --git a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json index 33201a70b6..70f348a769 100644 --- a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json +++ b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json @@ -830,7 +830,6 @@ "empty-image": "Aucune image", "maximum-upload-file-size": "Taille de fichier maximum: {{ size }}", "cannot-upload-file": "Le téléchargement a échoué", - "layout-settings": "Paramètres de mise en page", "margin-required": "Valeur de marge requise.", "min-margin-message": "0 est la valeur minimum permise.", "max-margin-message": "50 est la valeur maximum permise.", diff --git a/ui-ngx/src/assets/locale/locale.constant-lt_LT.json b/ui-ngx/src/assets/locale/locale.constant-lt_LT.json index 8b85f7f480..23e0682d6e 100644 --- a/ui-ngx/src/assets/locale/locale.constant-lt_LT.json +++ b/ui-ngx/src/assets/locale/locale.constant-lt_LT.json @@ -1256,7 +1256,6 @@ "maximum-upload-file-size": "Maksimalus įkeliamo failo dydis: {{ size }}", "cannot-upload-file": "Failo įkelti nepavyko", "settings": "Nustatymai", - "layout-settings": "Išdėstymo nustatymai", "columns-count": "Stulpelių skaičius", "columns-count-required": "Stulpelių skaičius būtinas.", "min-columns-count-message": "Minimalus stulpelių skaičius - 10.", diff --git a/ui-ngx/src/assets/locale/locale.constant-nl_BE.json b/ui-ngx/src/assets/locale/locale.constant-nl_BE.json index fb736931f4..2188379a4b 100644 --- a/ui-ngx/src/assets/locale/locale.constant-nl_BE.json +++ b/ui-ngx/src/assets/locale/locale.constant-nl_BE.json @@ -1168,7 +1168,6 @@ "maximum-upload-file-size": "Maximale bestandsgrootte uploaden: {{ size }}", "cannot-upload-file": "Kan bestand niet uploaden", "settings": "Instellingen", - "layout-settings": "Lay-out instellingen", "columns-count": "Aantal kolommen", "columns-count-required": "Het aantal kolommen is vereist.", "min-columns-count-message": "Er is slechts een minimum van 10 kolommen toegestaan.", diff --git a/ui-ngx/src/assets/locale/locale.constant-pl_PL.json b/ui-ngx/src/assets/locale/locale.constant-pl_PL.json index 400d42c7d0..bec945acf9 100644 --- a/ui-ngx/src/assets/locale/locale.constant-pl_PL.json +++ b/ui-ngx/src/assets/locale/locale.constant-pl_PL.json @@ -1256,7 +1256,6 @@ "maximum-upload-file-size": "Maksymalny rozmiar przesyłanego pliku: {{ size }}", "cannot-upload-file": "Nie można przesłać pliku", "settings": "Ustawienia", - "layout-settings": "Ustawienia układu", "columns-count": "Liczba kolumn", "columns-count-required": "Liczba kolumn jest wymagana.", "min-columns-count-message": "Dozwolona jest tylko minimalna liczba kolumn wynosząca 10.", diff --git a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json index d3e63db882..b01c3548ab 100644 --- a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json +++ b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json @@ -748,7 +748,6 @@ "maximum-upload-file-size": "Maksimum yükleme dosyası boyutu: {{ size }}", "cannot-upload-file": "Dosya yüklenemiyor", "settings": "Ayarlar", - "layout-settings": "Görünüm ayarları", "columns-count": "Sütun sayısı", "columns-count-required": "Sütun sayısı gerekli.", "min-columns-count-message": "Yalnızca 10 minimum sütun sayısına izin verilir.", diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json index 4ae424c843..2e3f3e0355 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json @@ -1097,7 +1097,6 @@ "maximum-upload-file-size": "最大上传文件大小: {{ size }}", "cannot-upload-file": "无法上传文件", "settings": "设置", - "layout-settings": "布局设置", "columns-count": "列数", "columns-count-required": "需要列数。", "min-columns-count-message": "只允许最少10列", diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_TW.json b/ui-ngx/src/assets/locale/locale.constant-zh_TW.json index 68df8adcb1..959d1d2048 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_TW.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_TW.json @@ -828,7 +828,6 @@ "maximum-upload-file-size": "最大上傳文件大小: {{ size }}", "cannot-upload-file": "無法上傳文件", "settings": "設定", - "layout-settings": "佈局設定", "columns-count": "列數", "columns-count-required": "需要列數。", "min-columns-count-message": "只允許最少10列", diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/modbus.json b/ui-ngx/src/assets/metadata/connector-default-configs/modbus.json index 437a6abd95..441f63ab5f 100644 --- a/ui-ngx/src/assets/metadata/connector-default-configs/modbus.json +++ b/ui-ngx/src/assets/metadata/connector-default-configs/modbus.json @@ -2,6 +2,7 @@ "master": { "slaves": [ { + "name": "Slave 1", "host": "127.0.0.1", "port": 5021, "type": "tcp", @@ -15,6 +16,7 @@ "pollPeriod": 5000, "unitId": 1, "deviceName": "Temp Sensor", + "deviceType": "default", "sendDataOnlyOnChange": true, "connectAttemptTimeMs": 5000, "connectAttemptCount": 5, @@ -185,64 +187,60 @@ "wordOrder": "LITTLE", "unitId": 0, "values": { - "holding_registers": [ - { - "attributes": [ - { - "address": 1, - "type": "string", - "tag": "sm", - "objectsCount": 1, - "value": "ON" - } - ], - "timeseries": [ - { - "address": 2, - "type": "int", - "tag": "smm", - "objectsCount": 1, - "value": "12334" - } - ], - "attributeUpdates": [ - { - "tag": "shared_attribute_write", - "type": "32int", - "functionCode": 6, - "objectsCount": 2, - "address": 29, - "value": 1243 - } - ], - "rpc": [ - { - "tag": "setValue", - "type": "bits", - "functionCode": 5, - "objectsCount": 1, - "address": 31, - "value": 22 - } - ] + "holding_registers": { + "attributes": [ + { + "address": 1, + "type": "string", + "tag": "sm", + "objectsCount": 1, + "value": "ON" + } + ], + "timeseries": [ + { + "address": 2, + "type": "8int", + "tag": "smm", + "objectsCount": 1, + "value": "12334" + } + ], + "attributeUpdates": [ + { + "tag": "shared_attribute_write", + "type": "32int", + "functionCode": 6, + "objectsCount": 2, + "address": 29, + "value": 1243 + } + ], + "rpc": [ + { + "tag": "setValue", + "type": "bits", + "functionCode": 5, + "objectsCount": 1, + "address": 31, + "value": 22 + } + ] + }, + "coils_initializer": { + "attributes": [ + { + "address": 5, + "type": "string", + "tag": "sm", + "objectsCount": 1, + "value": "12" + } + ], + "timeseries": [], + "attributeUpdates": [], + "rpc": [] } - ], - "coils_initializer": [ - { - "attributes": [ - { - "address": 5, - "type": "string", - "tag": "sm", - "objectsCount": 1, - "value": "12" - } - ], - "timeseries": [], - "attributeUpdates": [], - "rpc": [] - } - ] } } } diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/opcua.json b/ui-ngx/src/assets/metadata/connector-default-configs/opcua.json index 91ee295648..e9351b23f6 100644 --- a/ui-ngx/src/assets/metadata/connector-default-configs/opcua.json +++ b/ui-ngx/src/assets/metadata/connector-default-configs/opcua.json @@ -3,50 +3,65 @@ "name": "OPC-UA Default Server", "url": "localhost:4840/freeopcua/server/", "timeoutInMillis": 5000, - "scanPeriodInMillis": 5000, - "disableSubscriptions": false, + "scanPeriodInMillis": 3600000, + "pollPeriodInMillis": 5000, + "enableSubscriptions": true, "subCheckPeriodInMillis": 100, "showMap": false, "security": "Basic128Rsa15", "identity": { "type": "anonymous" + } + }, + "mapping": [{ + "deviceNodePattern": "Root\\.Objects\\.Device1", + "deviceNodeSource": "path", + "deviceInfo": { + "deviceNameExpression": "Device ${Root\\.Objects\\.Device1\\.serialNumber}", + "deviceNameExpressionSource": "path", + "deviceProfileExpression": "Device", + "deviceProfileExpressionSource": "constant" }, - "mapping": [ + "attributes": [ { - "deviceNodePattern": "Root\\.Objects\\.Device1", - "deviceNamePattern": "Device ${Root\\.Objects\\.Device1\\.serialNumber}", - "attributes": [ - { - "key": "temperature °C", - "path": "${ns=2;i=5}" - } - ], - "timeseries": [ + "key": "temperature °C", + "type": "path", + "value": "${ns=2;i=5}" + } + ], + "timeseries": [ + { + "key": "humidity", + "type": "path", + "value": "${Root\\.Objects\\.Device1\\.TemperatureAndHumiditySensor\\.Humidity}" + }, + { + "key": "batteryLevel", + "type": "path", + "value": "${Battery\\.batteryLevel}" + } + ], + "rpc_methods": [ + { + "method": "multiply", + "arguments": [ { - "key": "humidity", - "path": "${Root\\.Objects\\.Device1\\.TemperatureAndHumiditySensor\\.Humidity}" + "type": "integer", + "value": 2 }, { - "key": "batteryLevel", - "path": "${Battery\\.batteryLevel}" - } - ], - "rpc_methods": [ - { - "method": "multiply", - "arguments": [ - 2, - 4 - ] - } - ], - "attributes_updates": [ - { - "attributeOnThingsBoard": "deviceName", - "attributeOnDevice": "Root\\.Objects\\.Device1\\.serialNumber" + "type": "integer", + "value": 4 } ] } + ], + "attributes_updates": [ + { + "key": "deviceName", + "type": "path", + "value": "Root\\.Objects\\.Device1\\.serialNumber" + } ] - } -} \ No newline at end of file + }] +} diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/opcua_asyncio.json b/ui-ngx/src/assets/metadata/connector-default-configs/opcua_asyncio.json index 91ee295648..65fb6c5145 100644 --- a/ui-ngx/src/assets/metadata/connector-default-configs/opcua_asyncio.json +++ b/ui-ngx/src/assets/metadata/connector-default-configs/opcua_asyncio.json @@ -3,7 +3,8 @@ "name": "OPC-UA Default Server", "url": "localhost:4840/freeopcua/server/", "timeoutInMillis": 5000, - "scanPeriodInMillis": 5000, + "scanPeriodInMillis": 3600000, + "pollPeriodInMillis": 5000, "disableSubscriptions": false, "subCheckPeriodInMillis": 100, "showMap": false, @@ -49,4 +50,4 @@ } ] } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/notification-bell.svg b/ui-ngx/src/assets/notification-bell.svg index f5bb251293..25b9cc3086 100644 --- a/ui-ngx/src/assets/notification-bell.svg +++ b/ui-ngx/src/assets/notification-bell.svg @@ -1 +1,13 @@ - + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/widget/power-button/default-icon-layout.svg b/ui-ngx/src/assets/widget/power-button/default-icon-layout.svg new file mode 100644 index 0000000000..9da9ba8535 --- /dev/null +++ b/ui-ngx/src/assets/widget/power-button/default-icon-layout.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/widget/power-button/outlined-icon-layout.svg b/ui-ngx/src/assets/widget/power-button/outlined-icon-layout.svg new file mode 100644 index 0000000000..b884cd06b0 --- /dev/null +++ b/ui-ngx/src/assets/widget/power-button/outlined-icon-layout.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/widget/power-button/simplified-icon-layout.svg b/ui-ngx/src/assets/widget/power-button/simplified-icon-layout.svg new file mode 100644 index 0000000000..be3596da29 --- /dev/null +++ b/ui-ngx/src/assets/widget/power-button/simplified-icon-layout.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index c9580bcf74..c7eae0fe6f 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -582,6 +582,7 @@ .tb-form-table-header { height: 48px; + min-height: 48px; border-bottom: 1px solid rgba(0, 0, 0, 0.12); &-cell { color: rgba(0, 0, 0, 0.54); diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index 4d5ae09a15..969cb431e6 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -392,6 +392,7 @@ mat-icon { } .tb-ace-doc-tooltip { + white-space: pre-wrap; code { color: #444; &.title { @@ -920,6 +921,12 @@ mat-icon { } .mat-mdc-icon-button { + &.tb-mat-16 { + @include tb-mat-icon-button-size(16); + .mat-icon { + @include tb-mat-icon-size(16); + } + } &.tb-mat-20 { @include tb-mat-icon-button-size(20); .mat-icon { diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 68e9ea2523..0f46efe018 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -2695,11 +2695,18 @@ dependencies: "@svgdotjs/svg.js" "^3.1.1" -"@svgdotjs/svg.js@^3.1.1", "@svgdotjs/svg.js@^3.2.0": +"@svgdotjs/svg.js@^3.0.16", "@svgdotjs/svg.js@^3.1.1", "@svgdotjs/svg.js@^3.2.0": version "3.2.0" resolved "https://registry.yarnpkg.com/@svgdotjs/svg.js/-/svg.js-3.2.0.tgz#6baa8cef6778a93818ac18faa2055222e60aa644" integrity sha512-Tr8p+QVP7y+QT1GBlq1Tt57IvedVH8zCPoYxdHLX0Oof3a/PqnC/tXAkVufv1JQJfsDHlH/UrjcDfgxSofqSNA== +"@svgdotjs/svg.panzoom.js@^2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@svgdotjs/svg.panzoom.js/-/svg.panzoom.js-2.1.2.tgz#50e66a861f7c4f9e3992707f8e62e6e8da5c5223" + integrity sha512-0Nzo2TRlTebW3pzfAPtHx8Ye7Y3kuMEkK7hwVJi0SgQUB/vstjg7fvCJxB++EqsuDEetP0/SC+4CpLMVm6Lh2g== + dependencies: + "@svgdotjs/svg.js" "^3.0.16" + "@tinymce/tinymce-angular@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@tinymce/tinymce-angular/-/tinymce-angular-7.0.0.tgz#010de497d5774a8bdc5d5936bf4fb976adf05f56"